Auto: focas-f2a — DIAG: address scheme

New FocasAreaKind.Diagnostic parsed from DIAG:nnn (whole-CNC) and
DIAG:nnn/axis (per-axis), validated against a per-series
FocasCapabilityMatrix.DiagnosticRange table (16i: 0-499; 0i-F family:
0-999; 30i/31i/32i: 0-1023; Power Motion i: 0-255; Unknown: permissive
per existing matrix convention).

IFocasClient gains ReadDiagnosticAsync(diagNumber, axisOrZero, type,
ct) with a default returning BadNotSupported so older transport
variants degrade gracefully. FwlibFocasClient implements it via a new
cnc_rddiag P/Invoke that reuses the IODBPSD struct (same shape as
cnc_rdparam). FocasDriver.ReadAsync dispatches Diagnostic addresses
through the new path; non-Diagnostic kinds keep the existing
ReadAsync route unchanged.

Tests: parser positives (DIAG:1031, DIAG:280/2, case-insensitive,
zero, axis-8) + negatives (malformed, axis>31), capability matrix
boundaries per series, driver-level dispatch verifying axis index
threads through, init-time rejection on out-of-range, and
BadNotSupported fallback when the wire client doesn't override the
default. 266/266 pass in Driver.FOCAS.Tests.

Closes #263

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-25 19:31:49 -04:00
parent 6743d51db8
commit 451b37a632
9 changed files with 364 additions and 9 deletions

View File

@@ -1,17 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
/// <see cref="FocasAreaKind.Diagnostic"/> (CNC diagnostic number, optionally per-axis —
/// <c>DIAG:1031</c>, <c>DIAG:280/2</c>) routed through <c>cnc_rddiag</c>.
/// </summary>
/// <remarks>
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 07 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
/// </remarks>
public sealed record FocasAddress(
FocasAreaKind Kind,
@@ -28,6 +32,9 @@ public sealed record FocasAddress(
? $"PARAM:{Number}"
: $"PARAM:{Number}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}",
FocasAreaKind.Diagnostic => BitIndex is null or 0
? $"DIAG:{Number}"
: $"DIAG:{Number}/{BitIndex}",
_ => $"?{Number}",
};
@@ -42,6 +49,9 @@ public sealed record FocasAddress(
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
// PMC path: letter + digits + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant();
@@ -92,4 +102,12 @@ public enum FocasAreaKind
Pmc,
Parameter,
Macro,
/// <summary>
/// CNC diagnostic number routed through <c>cnc_rddiag</c>. <c>DIAG:nnn</c> is a
/// whole-CNC diagnostic (axis = 0); <c>DIAG:nnn/axis</c> is per-axis (axis is the
/// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float /
/// Bit shapes — the driver picks the wire shape based on the configured tag's
/// <see cref="FocasDataType"/>.
/// </summary>
Diagnostic,
}

View File

@@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix
return address.Kind switch
{
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
_ => null,
};
}
@@ -73,6 +74,29 @@ public static class FocasCapabilityMatrix
_ => (0, int.MaxValue),
};
/// <summary>
/// CNC diagnostic number range accepted by a series; from <c>cnc_rddiag</c>
/// (and <c>cnc_rddiagdgn</c> for axis-scoped reads). Returning <c>null</c>
/// means the series doesn't support <c>cnc_rddiag</c> at all — the driver
/// rejects every <c>DIAG:</c> address on that series. Conservative ceilings
/// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F
/// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a
/// narrow diagnostic surface (0..255).
/// </summary>
internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => (0, 499),
FocasCncSeries.Zero_i_D => (0, 499),
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => (0, 999),
FocasCncSeries.Thirty_i or
FocasCncSeries.ThirtyOne_i or
FocasCncSeries.ThirtyTwo_i => (0, 1023),
FocasCncSeries.PowerMotion_i => (0, 255),
_ => (0, int.MaxValue),
};
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
/// signal groups that 30i-family ladder programs use.</summary>
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
@@ -143,6 +167,16 @@ public static class FocasCapabilityMatrix
: null;
}
private static string? ValidateDiagnostic(FocasCncSeries series, int number)
{
if (DiagnosticRange(series) is not { } range)
return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range).";
var (min, max) = range;
return (number < min || number > max)
? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}."
: null;
}
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
{
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";

View File

@@ -154,7 +154,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var parsed = FocasAddress.TryParse(tag.Address)
?? throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500, DIAG:1031, DIAG:280/2.");
if (_devices.TryGetValue(tag.DeviceHostAddress, out var device)
&& FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
{
@@ -377,7 +377,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
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}'.");
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
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);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good)

View File

@@ -58,7 +58,8 @@ public sealed record FocasDeviceOptions(
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
/// </summary>
public sealed record FocasTagDefinition(
string Name,

View File

@@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
FocasAreaKind.Diagnostic => Task.FromResult(
ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)),
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
};
}
public Task<(object? value, uint status)> ReadDiagnosticAsync(
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type));
}
public async Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
{
@@ -467,6 +477,36 @@ internal sealed class FwlibFocasClient : IFocasClient
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
}
private (object? value, uint status) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var length = DiagnosticReadLength(type);
var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf);
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
var value = type switch
{
FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 0),
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
};
return (value, FocasStatusMapper.Good);
}
private static int DiagnosticReadLength(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
FocasDataType.Int16 => 4 + 2,
FocasDataType.Int32 => 4 + 4,
FocasDataType.Float32 => 4 + 4,
FocasDataType.Float64 => 4 + 8,
_ => 4 + 4,
};
private (object? value, uint status) ReadMacro(FocasAddress address)
{
var buf = new FwlibNative.ODBM();

View File

@@ -176,6 +176,25 @@ internal static class FwlibNative
ref short outCount,
ref IODBAXIS figureinfo);
// ---- Diagnostics ----
/// <summary>
/// <c>cnc_rddiag</c> — read a CNC diagnostic value. <paramref name="number"/> is the
/// diagnostic number (e.g. 1031 = current alarm cause); <paramref name="axis"/> is 0
/// for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
/// <paramref name="length"/> is sized like <see cref="RdParam"/> — 4-byte header +
/// widest payload (8 bytes for Float64). The shape of the payload depends on the
/// diagnostic; the managed side decodes via <see cref="FocasDataType"/> on the
/// configured tag (issue #263).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rddiag", ExactSpelling = true)]
public static extern short RdDiag(
ushort handle,
ushort number,
short axis,
short length,
ref IODBPSD buffer);
// ---- Currently-executing block ----
/// <summary>

View File

@@ -148,6 +148,20 @@ public interface IFocasClient : IDisposable
/// </summary>
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
/// <summary>
/// Read a CNC diagnostic value via <c>cnc_rddiag</c>. <paramref name="diagNumber"/> is
/// the diagnostic number (validated against <see cref="FocasCapabilityMatrix.DiagnosticRange"/>
/// by <see cref="FocasDriver.InitializeAsync"/>). <paramref name="axisOrZero"/>
/// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
/// The shape of the returned value depends on the diagnostic — Int / Float / Bit are
/// all possible. Returns <c>null</c> on default (transport variants that haven't yet
/// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes
/// until the wire client is extended (issue #263).
/// </summary>
Task<(object? value, uint status)> ReadDiagnosticAsync(
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
}
/// <summary>