Auto: focas-f4b — cnc_wrmacro + cnc_wrparam writes

Closes #269
This commit is contained in:
Joseph Doherty
2026-04-26 04:54:28 -04:00
parent 71af554497
commit f48f31cfc7
15 changed files with 1066 additions and 36 deletions

View File

@@ -515,9 +515,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
// PR F4-b (issue #269) — granular per-kind gates on top of Writes.Enabled
// and per-tag Writable. PARAM: tags require Writes.AllowParameter,
// MACRO: tags require Writes.AllowMacro. Both default false so a
// deployment that flips the master switch on without explicitly opting
// into a kind still gets BadNotWritable for that kind. Fires BEFORE
// EnsureConnectedAsync so a kind whose gate is closed doesn't even
// attempt to construct a wire client (mirrors the F4-a master-switch
// short-circuit). ACL note: PARAM tags surface
// SecurityClassification.Configure (server-layer requires
// WriteConfigure) and MACRO tags surface Operate (WriteOperate) — see
// DiscoverAsync. Driver-level gates and ACL are independent: this gate
// is a deployment-side kill switch, ACL is the per-session gate.
if (parsed.Kind == FocasAreaKind.Parameter && !_options.Writes.AllowParameter)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
if (parsed.Kind == FocasAreaKind.Macro && !_options.Writes.AllowMacro)
{
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)
{
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
@@ -528,7 +552,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false);
device.LastSetPath = parsed.PathId;
}
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
// 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
{
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),
};
results[i] = new WriteResult(status);
}
catch (OperationCanceledException) { throw; }
@@ -574,9 +610,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
SecurityClass: ClassifyTag(tag),
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
@@ -765,6 +799,30 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_ => DriverDataType.String,
};
/// <summary>
/// Plan PR F4-b (issue #269) — declare the per-tag write classification the
/// server-layer ACL gate (DriverNodeManager) consumes. Per the
/// <c>feedback_acl_at_server_layer</c> memory the driver only declares metadata;
/// enforcement happens at the server layer, not here.
/// <list type="bullet">
/// <item><c>PARAM:</c> tags → <see cref="SecurityClassification.Configure"/> (server-layer requires <c>WriteConfigure</c>)</item>
/// <item><c>MACRO:</c> tags → <see cref="SecurityClassification.Operate"/> (server-layer requires <c>WriteOperate</c>)</item>
/// <item>Other writable tags (PMC) → <see cref="SecurityClassification.Operate"/></item>
/// <item>Non-writable tags → <see cref="SecurityClassification.ViewOnly"/></item>
/// </list>
/// </summary>
internal static SecurityClassification ClassifyTag(FocasTagDefinition tag)
{
if (!tag.Writable) return SecurityClassification.ViewOnly;
var parsed = FocasAddress.TryParse(tag.Address);
return parsed?.Kind switch
{
FocasAreaKind.Parameter => SecurityClassification.Configure,
FocasAreaKind.Macro => SecurityClassification.Operate,
_ => SecurityClassification.Operate,
};
}
private static string StatusReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Status/{field}";

View File

@@ -92,6 +92,12 @@ public static class FocasDriverFactoryExtensions
Writes = new FocasWritesOptions
{
Enabled = dto.Writes?.Enabled ?? false,
// Plan PR F4-b (issue #269) — granular kill-switches on top of Enabled.
// Default false: even with Enabled=true the operator must explicitly opt
// into parameter and macro writes per kind. A bare Writes section with
// just { Enabled: true } keeps PARAM/MACRO writes locked.
AllowParameter = dto.Writes?.AllowParameter ?? false,
AllowMacro = dto.Writes?.AllowMacro ?? false,
},
};
@@ -186,6 +192,18 @@ public static class FocasDriverFactoryExtensions
internal sealed class FocasWritesDto
{
public bool? Enabled { get; init; }
/// <summary>
/// Plan PR F4-b (issue #269). Default false — see
/// <see cref="FocasWritesOptions.AllowParameter"/>.
/// </summary>
public bool? AllowParameter { get; init; }
/// <summary>
/// Plan PR F4-b (issue #269). Default false — see
/// <see cref="FocasWritesOptions.AllowMacro"/>.
/// </summary>
public bool? AllowMacro { get; init; }
}
internal sealed class FocasDeviceDto

View File

@@ -54,6 +54,35 @@ public sealed record FocasWritesOptions
/// <c>"writes disabled at driver level"</c>.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Issue #269, plan PR F4-b — granular kill-switch for <c>cnc_wrparam</c>
/// parameter writes (defense in depth on top of <see cref="Enabled"/> and the
/// per-tag <see cref="FocasTagDefinition.Writable"/>). Default <c>false</c>: an
/// operator who flips <see cref="Enabled"/> on without explicitly opting into
/// parameter writes still gets <see cref="FocasStatusMapper.BadNotWritable"/>
/// for every <c>PARAM:</c> tag. A misdirected parameter write can put the CNC
/// in a bad state, so the third opt-in keeps the blast radius bounded.
/// <para>Server-layer ACL: <c>PARAM:</c> tags additionally surface a
/// <see cref="Core.Abstractions.SecurityClassification.Configure"/> classification
/// so the OPC UA gate requires <c>WriteConfigure</c> group membership; this
/// flag is the driver-level kill switch the operator team can flip without a
/// redeploy.</para>
/// </summary>
public bool AllowParameter { get; init; } = false;
/// <summary>
/// Issue #269, plan PR F4-b — granular kill-switch for <c>cnc_wrmacro</c> macro
/// variable writes (defense in depth on top of <see cref="Enabled"/> and the
/// per-tag <see cref="FocasTagDefinition.Writable"/>). Default <c>false</c>:
/// macro writes are gated separately from parameter writes because they're a
/// normal HMI-driven recipe / setpoint surface where parameter writes are
/// mostly emergency commissioning territory.
/// <para>Server-layer ACL: <c>MACRO:</c> tags surface
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
/// gate requires <c>WriteOperate</c> group membership.</para>
/// </summary>
public bool AllowMacro { get; init; } = false;
}
/// <summary>

View File

@@ -19,6 +19,16 @@ public static class FocasStatusMapper
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
/// <summary>
/// OPC UA <c>BadUserAccessDenied</c>. Surfaced when the CNC reports
/// <c>EW_PASSWD</c> (parameter-write switch off, MDI mode required, etc.) — the
/// deployment must escalate the operator's session to satisfy the write gate.
/// Plan PR F4-d will land the unlock workflow that lets operators flip the gate
/// from the OPC UA side; F4-b just maps the status code so clients can branch
/// on it. (Plan PR F4-b, issue #269.)
/// </summary>
public const uint BadUserAccessDenied = 0x801F0000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
@@ -37,7 +47,7 @@ public static class FocasStatusMapper
7 => BadTypeMismatch, // EW_ATTRIB
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
9 => BadCommunicationError, // EW_PARITY
11 => BadNotWritable, // EW_PASSWD
11 => BadUserAccessDenied, // EW_PASSWD — parameter-write switch off / unlock required (F4-d)
-1 => BadDeviceFailure, // EW_BUSY
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch

View File

@@ -84,12 +84,44 @@ internal sealed class FwlibFocasClient : IFocasClient
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
FocasAreaKind.Pmc => WritePmc(address, type, value),
// PR F4-b (issue #269) — route through the typed WriteParameterAsync /
// WriteMacroAsync entry points so the driver-level dispatch can apply the
// granular Writes.AllowParameter / Writes.AllowMacro gates without re-parsing
// the address kind.
FocasAreaKind.Parameter => WriteParameter(address, type, value),
FocasAreaKind.Macro => WriteMacro(address, value),
_ => FocasStatusMapper.BadNotSupported,
};
}
/// <summary>
/// Plan PR F4-b (issue #269) — typed parameter-write entry point. Backed by the
/// same <see cref="WriteParameter"/> helper as the kind-dispatched
/// <see cref="WriteAsync"/> path, so unit tests that go through the typed entry
/// point exercise the same wire encoding the production read/write loop hits.
/// </summary>
public Task<uint> WriteParameterAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(WriteParameter(address, type, value));
}
/// <summary>
/// Plan PR F4-b (issue #269) — typed macro-write entry point. Today
/// <see cref="WriteMacro"/> writes integer-only with
/// <c>decimalPointCount = 0</c>; a follow-up <c>WriteMacroScaled</c> overload
/// can land if fractional macro setpoints become a field requirement.
/// </summary>
public Task<uint> WriteMacroAsync(
FocasAddress address, object? value, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(WriteMacro(address, value));
}
/// <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.

View File

@@ -43,6 +43,39 @@ public interface IFocasClient : IDisposable
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Write a CNC parameter value via <c>cnc_wrparam</c> (FWLIB <c>IODBPSD</c> packet —
/// byte layout symmetric with the <c>cnc_rdparam</c> read side). Plan PR F4-b
/// (issue #269). The <paramref name="address"/> is parsed from a <c>PARAM:N</c>
/// tag string; <paramref name="type"/> drives the payload width (Byte / Int16 /
/// Int32). Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/>
/// so transports that haven't yet routed the write keep compiling.
/// <para>EW_PASSWD from the CNC (parameter-write switch off / unlock required)
/// surfaces as <see cref="FocasStatusMapper.BadUserAccessDenied"/>; F4-d will
/// wire the unlock workflow on top.</para>
/// </summary>
Task<uint> WriteParameterAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
/// <summary>
/// Write a CNC macro variable value via <c>cnc_wrmacro</c> (FWLIB <c>ODBM</c> packet
/// symmetric with the <c>cnc_rdmacro</c> read side). Plan PR F4-b (issue #269).
/// The implementation encodes <paramref name="value"/> as <c>(intValue,
/// decimalPointCount)</c>; today we ship integer-only (<c>decimalPointCount = 0</c>)
/// to match the most common HMI pattern, and a future <c>WriteMacroScaled</c>
/// overload can land if the field calls for fractional macro setpoints.
/// Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/>.
/// </summary>
Task<uint> WriteMacroAsync(
FocasAddress address,
object? value,
CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
/// <summary>
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
/// responds with any valid status.