@@ -26,6 +26,20 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
||||
public int TimeoutMs { get; init; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
|
||||
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
|
||||
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch.
|
||||
/// PASSWORD INVARIANT: never logged. The CLI's Serilog config does not
|
||||
/// include this option in any console / file destructure; the redaction is
|
||||
/// enforced at the <see cref="FocasDeviceOptions"/> layer (record's
|
||||
/// overridden <c>ToString</c>).
|
||||
/// </summary>
|
||||
[CommandOption("cnc-password", Description =
|
||||
"Optional CNC connection password emitted via cnc_wrunlockparam on connect. " +
|
||||
"Required by controllers that gate parameter writes behind a password switch.")]
|
||||
public string? CncPassword { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
@@ -51,7 +65,12 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
Devices = [new FocasDeviceOptions(
|
||||
HostAddress: HostAddress,
|
||||
DeviceName: $"cli-{CncHost}:{CncPort}",
|
||||
Series: Series)],
|
||||
Series: Series,
|
||||
OverrideParameters: null,
|
||||
// PR F4-d (issue #271) — thread the CLI's --cnc-password through to
|
||||
// the driver. Null when the operator didn't supply the flag — the
|
||||
// driver short-circuits the unlock call in that case.
|
||||
Password: CncPassword)],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
|
||||
@@ -451,10 +451,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
|
||||
? await client.ReadDiagnosticAsync(
|
||||
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
|
||||
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
// PR F4-d (issue #271) — single-shot unlock + retry on
|
||||
// BadUserAccessDenied. Some controllers gate selected reads (not just
|
||||
// writes) behind cnc_wrunlockparam; mirrors the WriteAsync retry
|
||||
// shape so a session that lost its unlock state mid-flight (e.g.
|
||||
// because the controller cycled it) self-heals on the first read.
|
||||
async Task<(object? value, uint status)> DispatchReadAsync()
|
||||
{
|
||||
return parsed.Kind == FocasAreaKind.Diagnostic
|
||||
? await client.ReadDiagnosticAsync(
|
||||
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
|
||||
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var (value, status) = await DispatchReadAsync().ConfigureAwait(false);
|
||||
if (status == FocasStatusMapper.BadUserAccessDenied
|
||||
&& !string.IsNullOrEmpty(device.Options.Password))
|
||||
{
|
||||
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
(value, status) = await DispatchReadAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == FocasStatusMapper.Good)
|
||||
@@ -573,39 +591,58 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// 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)
|
||||
//
|
||||
// PR F4-d (issue #271) — wrap the dispatch in a single-shot retry on
|
||||
// BadUserAccessDenied (EW_PASSWD mapping from F4-b). When the device
|
||||
// has a password configured AND the wire call fires EW_PASSWD, the
|
||||
// retry path re-issues UnlockAsync and re-dispatches once. The
|
||||
// `attempted` flag bounds the loop so a second EW_PASSWD propagates
|
||||
// unchanged — no infinite retry on a mismatched password.
|
||||
async Task<uint> DispatchWriteAsync()
|
||||
{
|
||||
status = await client.WriteParameterAsync(
|
||||
if (parsed.Kind == FocasAreaKind.Parameter)
|
||||
{
|
||||
return await client.WriteParameterAsync(
|
||||
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (parsed.Kind == FocasAreaKind.Macro)
|
||||
{
|
||||
return await client.WriteMacroAsync(
|
||||
parsed, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (parsed.Kind == FocasAreaKind.Pmc
|
||||
&& def.DataType == FocasDataType.Bit
|
||||
&& parsed.BitIndex is int bit
|
||||
&& parsed.PmcLetter is string letter)
|
||||
{
|
||||
return await client.WritePmcBitAsync(
|
||||
letter, parsed.PathId, parsed.Number, bit,
|
||||
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (parsed.Kind == FocasAreaKind.Pmc
|
||||
&& def.DataType == FocasDataType.Byte
|
||||
&& parsed.PmcLetter is string byteLetter)
|
||||
{
|
||||
var b = unchecked((byte)Convert.ToSByte(w.Value));
|
||||
return await client.WritePmcRangeAsync(
|
||||
byteLetter, parsed.PathId, parsed.Number, new[] { b },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return await client.WriteAsync(
|
||||
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (parsed.Kind == FocasAreaKind.Macro)
|
||||
|
||||
var status = await DispatchWriteAsync().ConfigureAwait(false);
|
||||
if (status == FocasStatusMapper.BadUserAccessDenied
|
||||
&& !string.IsNullOrEmpty(device.Options.Password))
|
||||
{
|
||||
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);
|
||||
// Single-shot retry: re-issue cnc_wrunlockparam, then redispatch
|
||||
// exactly once. A second EW_PASSWD bubbles up as-is so a wrong
|
||||
// password doesn't loop forever on the wire.
|
||||
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
status = await DispatchWriteAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
@@ -1458,6 +1495,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.Client = null;
|
||||
throw;
|
||||
}
|
||||
// PR F4-d (issue #271) — emit cnc_wrunlockparam on connect when the device
|
||||
// configured a password. Resets on every reconnect because FWLIB unlock state
|
||||
// is bound to the handle's lifetime. Failure here is non-fatal (the
|
||||
// controller may surface password requirements only on certain reads/writes,
|
||||
// not on every session) — the per-call retry path catches EW_PASSWD on the
|
||||
// actual gated wire call. We still record the failure on _health for
|
||||
// operator visibility. PASSWORD INVARIANT: never log device.Options.Password.
|
||||
if (!string.IsNullOrEmpty(device.Options.Password))
|
||||
{
|
||||
try
|
||||
{
|
||||
await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false);
|
||||
device.UnlockApplied = true;
|
||||
// Status text deliberately uses the host address only — no password.
|
||||
_health = new DriverHealth(_health.State, _health.LastSuccessfulRead,
|
||||
$"FOCAS unlock applied for {device.Options.HostAddress}");
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
device.UnlockApplied = false;
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"FOCAS unlock attempt failed for {device.Options.HostAddress}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
// Multi-path bootstrap (issue #264). cnc_rdpathnum runs once per session — the
|
||||
// controller's path topology is fixed at boot. A reconnect resets the wire
|
||||
// session's "last set path" so the next non-default-path read forces a fresh
|
||||
@@ -1474,6 +1536,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — re-issue <c>cnc_wrunlockparam</c> on the active
|
||||
/// wire session and return <c>true</c> when the unlock succeeded so the caller
|
||||
/// can retry the gated read/write once. <c>false</c> means the unlock itself
|
||||
/// failed (mismatched password, transport refused) and the caller surfaces
|
||||
/// <c>BadUserAccessDenied</c> as-is. PASSWORD INVARIANT: the password is never
|
||||
/// logged from this method.
|
||||
/// </summary>
|
||||
private async Task<bool> TryReunlockAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is null || !device.Client.IsConnected) return false;
|
||||
if (string.IsNullOrEmpty(device.Options.Password)) return false;
|
||||
try
|
||||
{
|
||||
await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false);
|
||||
device.UnlockApplied = true;
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch
|
||||
{
|
||||
device.UnlockApplied = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
@@ -1590,6 +1678,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
public int LastSetPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — set when the driver successfully emitted
|
||||
/// <c>cnc_wrunlockparam</c> on this wire session. Reset to <c>false</c>
|
||||
/// on every reconnect because FWLIB unlock state is bound to the handle's
|
||||
/// lifetime. Surfaced as a flag (not a counter) so a future diagnostics
|
||||
/// surface can light up "unlocked" / "needs unlock" in the Admin UI
|
||||
/// without changing the public API.
|
||||
/// </summary>
|
||||
public bool UnlockApplied { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -62,7 +62,13 @@ public static class FocasDriverFactoryExtensions
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
DeviceName: d.DeviceName,
|
||||
Series: ParseSeries(d.Series ?? dto.Series)))]
|
||||
Series: ParseSeries(d.Series ?? dto.Series),
|
||||
OverrideParameters: null,
|
||||
// Plan PR F4-d (issue #271) — optional CNC password for cnc_wrunlockparam.
|
||||
// The DTO carries it through JSON config round-trip; the driver-layer
|
||||
// record overrides ToString to redact (no-log invariant). See
|
||||
// docs/v2/focas-deployment.md § "FOCAS password handling".
|
||||
Password: d.Password))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||
@@ -222,6 +228,20 @@ public static class FocasDriverFactoryExtensions
|
||||
public string? HostAddress { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
public string? Series { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
|
||||
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
|
||||
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch. The
|
||||
/// driver maps <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c> and re-issues
|
||||
/// unlock + retries the gated call once on that mapping.
|
||||
/// <para><b>No-log invariant:</b> never logged through the driver. The host
|
||||
/// <c>FocasDeviceOptions</c> record overrides <c>ToString</c> to redact this
|
||||
/// field. Stored in <c>appsettings.json</c> alongside the rest of the device
|
||||
/// config; treat as a secret per <c>docs/v2/focas-deployment.md</c>
|
||||
/// § "FOCAS password handling" + cross-link to <c>docs/Security.md</c>.</para>
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasTagDto
|
||||
|
||||
@@ -197,12 +197,44 @@ public sealed record FocasFixedTreeOptions
|
||||
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
||||
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
||||
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
||||
/// <paramref name="Password"/> (issue #271, plan PR F4-d) is the CNC connection-level
|
||||
/// password emitted via <c>cnc_wrunlockparam</c> on connect when the controller
|
||||
/// gates parameter writes / certain reads behind a password switch (16i + some
|
||||
/// 30i firmwares with parameter-protect on).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No-log invariant:</b> <see cref="Password"/> is a secret. The driver MUST NOT
|
||||
/// log it. <c>FocasDeviceOptions.ToString()</c> would include the field by default
|
||||
/// because it's a positional record member, so the record's auto-generated
|
||||
/// <c>ToString</c> is overridden via <see cref="PrintMembers"/> below to redact
|
||||
/// the password. Any new logging surface that touches <see cref="FocasDeviceOptions"/>
|
||||
/// must continue to redact. See <c>docs/v2/focas-deployment.md</c> § "FOCAS password
|
||||
/// handling" for the no-log invariant and rotation runbook.</para>
|
||||
/// </remarks>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||
FocasOverrideParameters? OverrideParameters = null);
|
||||
FocasOverrideParameters? OverrideParameters = null,
|
||||
string? Password = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Issue #271 (plan PR F4-d) — record auto-generated <c>ToString</c> would print
|
||||
/// <see cref="Password"/> verbatim. Override the printer so the secret is replaced
|
||||
/// with <c>"***"</c> when the field is non-null. The no-log invariant relies on
|
||||
/// this — every Serilog destructure that flows a <see cref="FocasDeviceOptions"/>
|
||||
/// value through <c>{Device}</c> gets redaction for free.
|
||||
/// </summary>
|
||||
private bool PrintMembers(System.Text.StringBuilder builder)
|
||||
{
|
||||
builder.Append("HostAddress = ").Append(HostAddress);
|
||||
builder.Append(", DeviceName = ").Append(DeviceName);
|
||||
builder.Append(", Series = ").Append(Series);
|
||||
builder.Append(", OverrideParameters = ").Append(OverrideParameters);
|
||||
builder.Append(", Password = ").Append(Password is null ? "<null>" : "***");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
|
||||
@@ -48,6 +48,47 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — emit <c>cnc_wrunlockparam</c> to lift the
|
||||
/// CNC's parameter-protect / read-protect gate. The password is ASCII-encoded
|
||||
/// into a 4-byte buffer (right-padded with <c>0x00</c>; truncated when the
|
||||
/// supplied string exceeds 4 chars — Fanuc's published password buffer is a
|
||||
/// fixed 4-byte slot). Mismatch surfaces as <c>EW_PASSWD</c> mapped to
|
||||
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>; the F4-d retry loop
|
||||
/// in <see cref="FocasDriver"/> re-issues unlock + retries the call once on
|
||||
/// that mapping.
|
||||
/// <para><b>No-log invariant:</b> the password is NOT logged from this method
|
||||
/// and never appears in any exception message. The caller logs only "FOCAS
|
||||
/// unlock applied for {host}" (no password). See <c>FocasDeviceOptions.Password</c>.</para>
|
||||
/// </summary>
|
||||
public Task UnlockAsync(string password, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected)
|
||||
throw new InvalidOperationException(
|
||||
"FOCAS UnlockAsync called before Connect — handle is not yet open.");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Fixed 4-byte buffer (FOCAS password slot). Right-pad with 0x00; truncate
|
||||
// longer inputs. Truncation is a deployment-error not a runtime concern —
|
||||
// the password the operator put in appsettings.json must already match the
|
||||
// controller's slot exactly. We don't surface a different error code for
|
||||
// length mismatch because the controller will reject with EW_PASSWD anyway.
|
||||
var buf = new byte[4];
|
||||
var pwd = password ?? string.Empty;
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes(pwd);
|
||||
Array.Copy(bytes, 0, buf, 0, Math.Min(bytes.Length, buf.Length));
|
||||
|
||||
var ret = FwlibNative.WrUnlockParam(_handle, buf);
|
||||
if (ret != 0)
|
||||
{
|
||||
// Note: deliberately do NOT include `password` in the exception message —
|
||||
// exceptions get logged. The error code is enough for diagnosis.
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_wrunlockparam failed with EW_{ret}.");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -66,6 +66,25 @@ internal static class FwlibNative
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_wrunlockparam</c> — emit the connection-level password that lifts
|
||||
/// the parameter-protect / read-protect gate on certain firmwares (issue #271,
|
||||
/// plan PR F4-d). The Fanuc FOCAS reference describes the password buffer as
|
||||
/// a 4-byte binary array (the controller compares byte-for-byte). Returns the
|
||||
/// usual <c>EW_*</c> family — <c>EW_PASSWD</c> when the supplied bytes don't
|
||||
/// match the configured password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>P/Invoke shape kept narrow: the caller passes a 4-byte buffer. The
|
||||
/// driver layer ASCII-encodes <c>FocasDeviceOptions.Password</c> into the
|
||||
/// buffer (right-padded with <c>0x00</c>, truncated to 4 bytes) — that's the
|
||||
/// shape every public Fanuc password example we've seen uses.</para>
|
||||
/// </remarks>
|
||||
[DllImport(Library, EntryPoint = "cnc_wrunlockparam", ExactSpelling = true)]
|
||||
public static extern short WrUnlockParam(
|
||||
ushort handle,
|
||||
[In] byte[] password);
|
||||
|
||||
// ---- Macro variables ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||
|
||||
@@ -23,6 +23,25 @@ public interface IFocasClient : IDisposable
|
||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — emit the CNC password via FOCAS
|
||||
/// <c>cnc_wrunlockparam</c>. Some controllers (notably 16i + some 30i
|
||||
/// firmwares with parameter-protect on) gate <c>cnc_wrparam</c> and selected
|
||||
/// reads behind a connection-level password; this call lifts the gate for
|
||||
/// the lifetime of the FWLIB handle (resets on reconnect, hence the driver
|
||||
/// re-issues unlock on every <see cref="ConnectAsync"/>).
|
||||
/// <para>Default impl is a no-op (<see cref="Task.CompletedTask"/>) so transport
|
||||
/// variants that don't surface unlock (today: IPC and FAKE clients) keep
|
||||
/// compiling. The FWLIB-backed client overrides this with a real
|
||||
/// <c>cnc_wrunlockparam</c> call.</para>
|
||||
/// <para><b>No-log invariant:</b> <paramref name="password"/> is a secret. The
|
||||
/// wire-client implementation MUST NOT log the password — see
|
||||
/// <c>FocasDeviceOptions.Password</c> + <c>docs/v2/focas-deployment.md</c>
|
||||
/// § "FOCAS password handling".</para>
|
||||
/// </summary>
|
||||
Task UnlockAsync(string password, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Read the value at <paramref name="address"/> in the requested
|
||||
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
||||
|
||||
Reference in New Issue
Block a user