@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user