Auto: focas-f4c — pmc_wrpmcrng with bit-level RMW

Closes #270
This commit is contained in:
Joseph Doherty
2026-04-26 05:15:52 -04:00
parent 0c967af645
commit 54c09d4d5d
17 changed files with 837 additions and 101 deletions

View File

@@ -540,6 +540,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
// PR F4-c (issue #270) — granular gate for PMC writes. PMC is ladder
// working memory; a mistargeted bit can move motion or latch a feedhold
// so the operator team must explicitly opt in via Writes.AllowPmc on
// top of Writes.Enabled + per-tag Writable. Defaults to false so a
// deployment that flips the master switch on without touching the PMC
// gate still gets BadNotWritable for every PMC tag. ACL note: PMC tags
// surface SecurityClassification.Operate (server-layer requires
// WriteOperate) — see ClassifyTag.
if (parsed.Kind == FocasAreaKind.Pmc && !_options.Writes.AllowPmc)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount)
@@ -553,18 +566,47 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.LastSetPath = parsed.PathId;
}
// Dispatch through the typed entry points for PARAM/MACRO so the
// wire-client surface mirrors the per-kind opt-in shape; PMC and other
// kinds fall back to the generic WriteAsync path.
var status = parsed.Kind switch
// Dispatch through the typed entry points for PARAM/MACRO/PMC so the
// wire-client surface mirrors the per-kind opt-in shape. PMC bit
// writes route through the WritePmcBitAsync RMW helper so the wire
// client only ever sees byte-aligned pmc_wrpmcrng calls (PR F4-c,
// issue #270). The fallback generic WriteAsync path is preserved for
// kinds that don't have a typed entry point yet, plus the unit-test
// FakeFocasClient that overrides WriteAsync directly.
uint status;
if (parsed.Kind == FocasAreaKind.Parameter)
{
FocasAreaKind.Parameter => await client.WriteParameterAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await client.WriteMacroAsync(
parsed, w.Value, cancellationToken).ConfigureAwait(false),
_ => await client.WriteAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false),
};
status = await client.WriteParameterAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Macro)
{
status = await client.WriteMacroAsync(
parsed, w.Value, cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Bit
&& parsed.BitIndex is int bit
&& parsed.PmcLetter is string letter)
{
status = await client.WritePmcBitAsync(
letter, parsed.PathId, parsed.Number, bit,
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Byte
&& parsed.PmcLetter is string byteLetter)
{
var b = unchecked((byte)Convert.ToSByte(w.Value));
status = await client.WritePmcRangeAsync(
byteLetter, parsed.PathId, parsed.Number, new[] { b },
cancellationToken).ConfigureAwait(false);
}
else
{
status = await client.WriteAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
}
results[i] = new WriteResult(status);
}
catch (OperationCanceledException) { throw; }

View File

@@ -98,6 +98,11 @@ public static class FocasDriverFactoryExtensions
// just { Enabled: true } keeps PARAM/MACRO writes locked.
AllowParameter = dto.Writes?.AllowParameter ?? false,
AllowMacro = dto.Writes?.AllowMacro ?? false,
// Plan PR F4-c (issue #270) — granular kill-switch for pmc_wrpmcrng.
// Default false: PMC is ladder working memory; a mistargeted bit can
// move motion or latch a feedhold so the operator team must explicitly
// opt in even with Enabled=true.
AllowPmc = dto.Writes?.AllowPmc ?? false,
},
};
@@ -204,6 +209,12 @@ public static class FocasDriverFactoryExtensions
/// <see cref="FocasWritesOptions.AllowMacro"/>.
/// </summary>
public bool? AllowMacro { get; init; }
/// <summary>
/// Plan PR F4-c (issue #270). Default false — see
/// <see cref="FocasWritesOptions.AllowPmc"/>.
/// </summary>
public bool? AllowPmc { get; init; }
}
internal sealed class FocasDeviceDto

View File

@@ -83,6 +83,20 @@ public sealed record FocasWritesOptions
/// gate requires <c>WriteOperate</c> group membership.</para>
/// </summary>
public bool AllowMacro { get; init; } = false;
/// <summary>
/// Issue #270, plan PR F4-c — granular kill-switch for <c>pmc_wrpmcrng</c> PMC
/// range writes (and the bit-level read-modify-write that wraps it). Default
/// <c>false</c>: PMC is ladder working memory — a mistargeted bit can move
/// motion, latch a feedhold, or flip a safety interlock. Even with
/// <see cref="Enabled"/> on and a tag's <see cref="FocasTagDefinition.Writable"/>
/// flag flipped on, PMC writes stay locked until this third opt-in fires.
/// <para>Server-layer ACL: PMC tags surface
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
/// gate requires <c>WriteOperate</c> group membership; this flag is the driver-
/// level kill switch the operator team can flip without a redeploy.</para>
/// </summary>
public bool AllowPmc { get; init; } = false;
}
/// <summary>

View File

@@ -123,8 +123,10 @@ internal sealed class FwlibFocasClient : IFocasClient
}
/// <summary>
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
/// concurrent bit writes against the same byte serialise and neither loses its update.
/// Read-modify-write one bit within a PMC byte (Plan PR F4-c, issue #270).
/// Acquires a per-byte semaphore so concurrent bit writes against the same
/// byte serialise and neither loses its update. The wire call is byte-addressed
/// so we read the parent byte, mask the target bit, then write the byte back.
/// </summary>
private async Task<uint> WritePmcBitAsync(
FocasAddress address, bool newValue, CancellationToken cancellationToken)
@@ -151,19 +153,8 @@ internal sealed class FwlibFocasClient : IFocasClient
? (byte)(current | (1 << bit))
: (byte)(current & ~(1 << bit));
// Write the updated byte.
var writeBuf = new FwlibNative.IODBPMC
{
TypeA = addrType,
TypeD = FocasPmcDataType.Byte,
DatanoS = (ushort)address.Number,
DatanoE = (ushort)address.Number,
Data = new byte[40],
};
writeBuf.Data[0] = updated;
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
// Write the updated byte via pmc_wrpmcrng (1-byte range).
return WritePmcRange(addrType, address.Number, new[] { updated });
}
finally
{
@@ -171,6 +162,52 @@ internal sealed class FwlibFocasClient : IFocasClient
}
}
/// <summary>
/// Plan PR F4-c (issue #270) — typed PMC-range write entry point. Writes a
/// contiguous run of bytes via <c>pmc_wrpmcrng</c>. The FWLIB <c>IODBPMC.Data</c>
/// payload caps at ~40 bytes so larger ranges are chunked into 32-byte
/// sub-calls, mirroring the read-side <see cref="ReadPmcRangeAsync"/> shape.
/// </summary>
public Task<uint> WritePmcRangeAsync(
string letter, int pathId, int startByte, byte[] bytes, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
cancellationToken.ThrowIfCancellationRequested();
if (bytes is null || bytes.Length == 0) return Task.FromResult(FocasStatusMapper.Good);
var addrType = FocasPmcAddrType.FromLetter(letter)
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
return Task.FromResult(WritePmcRange(addrType, startByte, bytes));
}
/// <summary>
/// Synchronous PMC range write helper — chunked at 32 bytes so each
/// <c>pmc_wrpmcrng</c> call fits inside the FWLIB <c>IODBPMC.Data</c> 40-byte
/// window (8-byte header + 32-byte payload). Stops on the first non-zero
/// EW_* return so a partial write doesn't claim Good.
/// </summary>
private uint WritePmcRange(short addrType, int startByte, byte[] bytes)
{
const int chunkBytes = 32;
var offset = 0;
while (offset < bytes.Length)
{
var thisChunk = Math.Min(chunkBytes, bytes.Length - offset);
var writeBuf = new FwlibNative.IODBPMC
{
TypeA = addrType,
TypeD = FocasPmcDataType.Byte,
DatanoS = (ushort)(startByte + offset),
DatanoE = (ushort)(startByte + offset + thisChunk - 1),
Data = new byte[40],
};
Array.Copy(bytes, offset, writeBuf.Data, 0, thisChunk);
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)(8 + thisChunk), ref writeBuf);
if (ret != 0) return FocasStatusMapper.MapFocasReturn(ret);
offset += thisChunk;
}
return FocasStatusMapper.Good;
}
public Task<int> GetPathCountAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(1);

View File

@@ -232,6 +232,49 @@ public interface IFocasClient : IDisposable
int depth, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
/// <summary>
/// Write a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_wrpmcrng</c>) for the given <paramref name="letter"/> starting at
/// <paramref name="startByte"/>, copying every byte from <paramref name="bytes"/>.
/// Plan PR F4-c (issue #270). The wire call is byte-addressed; bit-level writes
/// are handled upstream by the <see cref="WritePmcBitAsync"/> read-modify-write
/// wrapper which performs <c>pmc_rdpmcrng</c> + bit mask + this method on a
/// per-byte semaphore (so two concurrent bit writes against the same byte don't
/// lose one another's update).
/// <para>Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/> so
/// transport variants that haven't yet routed the write keep compiling — those
/// variants surface BadNotSupported on PMC writes until the wire client is
/// extended.</para>
/// </summary>
Task<uint> WritePmcRangeAsync(
string letter, int pathId, int startByte, byte[] bytes, CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
/// <summary>
/// Read-modify-write one bit within a PMC byte (Plan PR F4-c, issue #270). The
/// wire call <c>pmc_wrpmcrng</c> is byte-addressed, so the driver reads the
/// parent byte first, masks the target bit, then writes the byte back. Default
/// impl uses <see cref="ReadPmcRangeAsync"/> + <see cref="WritePmcRangeAsync"/>
/// so transport variants get correct RMW semantics for free; the FWLIB-backed
/// client overrides this with a per-byte semaphore so two concurrent bit writes
/// against the same byte serialise.
/// </summary>
async Task<uint> WritePmcBitAsync(
string letter, int pathId, int byteAddress, int bitIndex, bool newValue,
CancellationToken cancellationToken)
{
if (bitIndex is < 0 or > 7) return FocasStatusMapper.BadOutOfRange;
var (buf, status) = await ReadPmcRangeAsync(letter, pathId, byteAddress, 1, cancellationToken)
.ConfigureAwait(false);
if (status != FocasStatusMapper.Good || buf is null || buf.Length < 1) return status;
var current = buf[0];
var updated = newValue
? (byte)(current | (1 << bitIndex))
: (byte)(current & ~(1 << bitIndex));
return await WritePmcRangeAsync(letter, pathId, byteAddress, new[] { updated }, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>