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