Merge pull request '[focas] FOCAS — DIAG: address scheme' (#354) from auto/focas/F2-a into auto/driver-gaps
This commit was merged in pull request #354.
This commit is contained in:
@@ -1,17 +1,21 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>,
|
/// <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>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"/>
|
/// <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>).
|
/// (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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
/// 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>
|
/// 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;
|
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
/// bit index when present is 0–7 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>
|
/// </remarks>
|
||||||
public sealed record FocasAddress(
|
public sealed record FocasAddress(
|
||||||
FocasAreaKind Kind,
|
FocasAreaKind Kind,
|
||||||
@@ -28,6 +32,9 @@ public sealed record FocasAddress(
|
|||||||
? $"PARAM:{Number}"
|
? $"PARAM:{Number}"
|
||||||
: $"PARAM:{Number}/{BitIndex}",
|
: $"PARAM:{Number}/{BitIndex}",
|
||||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||||
|
FocasAreaKind.Diagnostic => BitIndex is null or 0
|
||||||
|
? $"DIAG:{Number}"
|
||||||
|
: $"DIAG:{Number}/{BitIndex}",
|
||||||
_ => $"?{Number}",
|
_ => $"?{Number}",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +49,9 @@ public sealed record FocasAddress(
|
|||||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
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
|
// PMC path: letter + digits + optional .bit
|
||||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||||
var letter = src[0..1].ToUpperInvariant();
|
var letter = src[0..1].ToUpperInvariant();
|
||||||
@@ -92,4 +102,12 @@ public enum FocasAreaKind
|
|||||||
Pmc,
|
Pmc,
|
||||||
Parameter,
|
Parameter,
|
||||||
Macro,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public static class FocasCapabilityMatrix
|
|||||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||||
|
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,29 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => (0, int.MaxValue),
|
_ => (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
|
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
||||||
/// signal groups that 30i-family ladder programs use.</summary>
|
/// signal groups that 30i-family ladder programs use.</summary>
|
||||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||||
@@ -143,6 +167,16 @@ public static class FocasCapabilityMatrix
|
|||||||
: null;
|
: 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)
|
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var parsed = FocasAddress.TryParse(tag.Address)
|
var parsed = FocasAddress.TryParse(tag.Address)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
|
$"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)
|
if (_devices.TryGetValue(tag.DeviceHostAddress, out var device)
|
||||||
&& FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
|
&& 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 client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = FocasAddress.TryParse(def.Address)
|
var parsed = FocasAddress.TryParse(def.Address)
|
||||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{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);
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||||
if (status == FocasStatusMapper.Good)
|
if (status == FocasStatusMapper.Good)
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ public sealed record FocasDeviceOptions(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
/// 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>
|
/// </summary>
|
||||||
public sealed record FocasTagDefinition(
|
public sealed record FocasTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||||
|
FocasAreaKind.Diagnostic => Task.FromResult(
|
||||||
|
ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)),
|
||||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
_ => 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(
|
public async Task<uint> WriteAsync(
|
||||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
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);
|
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)
|
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||||
{
|
{
|
||||||
var buf = new FwlibNative.ODBM();
|
var buf = new FwlibNative.ODBM();
|
||||||
|
|||||||
@@ -176,6 +176,25 @@ internal static class FwlibNative
|
|||||||
ref short outCount,
|
ref short outCount,
|
||||||
ref IODBAXIS figureinfo);
|
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 ----
|
// ---- Currently-executing block ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ public interface IFocasClient : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||||
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
=> 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>
|
/// <summary>
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ internal class FakeFocasClient : IFocasClient
|
|||||||
return Task.FromResult(status);
|
return Task.FromResult(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
|
||||||
|
|
||||||
|
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||||
|
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||||
|
DiagnosticReads.Add((diagNumber, axisOrZero, type));
|
||||||
|
var key = axisOrZero == 0 ? $"DIAG:{diagNumber}" : $"DIAG:{diagNumber}/{axisOrZero}";
|
||||||
|
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||||
|
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||||
|
return Task.FromResult((value, status));
|
||||||
|
}
|
||||||
|
|
||||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||||
|
|
||||||
public virtual void Dispose()
|
public virtual void Dispose()
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coverage for the <c>DIAG:</c> address scheme — parser, capability matrix,
|
||||||
|
/// driver dispatch (issue #263, plan PR F2-a). DIAG: addresses route to
|
||||||
|
/// <c>cnc_rddiag</c> on the wire; the driver validates against
|
||||||
|
/// <see cref="FocasCapabilityMatrix.DiagnosticRange"/> at init time + dispatches
|
||||||
|
/// <see cref="FocasAreaKind.Diagnostic"/> reads through
|
||||||
|
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> at runtime.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasDiagnosticAddressTests
|
||||||
|
{
|
||||||
|
// ---- Parser positive ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DIAG:1000", 1000, 0)]
|
||||||
|
[InlineData("DIAG:280/2", 280, 2)]
|
||||||
|
[InlineData("DIAG:0", 0, 0)]
|
||||||
|
[InlineData("diag:500", 500, 0)] // case-insensitive prefix
|
||||||
|
[InlineData("DIAG:1023/8", 1023, 8)]
|
||||||
|
public void TryParse_accepts_DIAG_forms(string input, int expectedNumber, int expectedAxis)
|
||||||
|
{
|
||||||
|
var parsed = FocasAddress.TryParse(input);
|
||||||
|
parsed.ShouldNotBeNull();
|
||||||
|
parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic);
|
||||||
|
parsed.Number.ShouldBe(expectedNumber);
|
||||||
|
(parsed.BitIndex ?? 0).ShouldBe(expectedAxis);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DIAG:abc")]
|
||||||
|
[InlineData("DIAG:")]
|
||||||
|
[InlineData("DIAG:-1")]
|
||||||
|
[InlineData("DIAG:100/-1")]
|
||||||
|
[InlineData("DIAG:100/99")] // axis > 31 (parser ceiling)
|
||||||
|
public void TryParse_rejects_malformed_DIAG(string input)
|
||||||
|
{
|
||||||
|
FocasAddress.TryParse(input).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Canonical_round_trip_for_DIAG_whole_CNC()
|
||||||
|
{
|
||||||
|
var parsed = FocasAddress.TryParse("DIAG:1000");
|
||||||
|
parsed!.Canonical.ShouldBe("DIAG:1000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Canonical_round_trip_for_DIAG_per_axis()
|
||||||
|
{
|
||||||
|
var parsed = FocasAddress.TryParse("DIAG:280/2");
|
||||||
|
parsed!.Canonical.ShouldBe("DIAG:280/2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Capability matrix ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, 1023, true)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, 1024, false)]
|
||||||
|
[InlineData(FocasCncSeries.ThirtyOne_i, 500, true)]
|
||||||
|
[InlineData(FocasCncSeries.ThirtyTwo_i, 0, true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, 499, true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, 500, false)] // 16i caps lower
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, 999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, 1000, false)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, 280, true)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, 600, false)]
|
||||||
|
[InlineData(FocasCncSeries.PowerMotion_i, 255, true)]
|
||||||
|
[InlineData(FocasCncSeries.PowerMotion_i, 256, false)]
|
||||||
|
public void Diagnostic_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, number, null);
|
||||||
|
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||||
|
(result is null).ShouldBe(accepted,
|
||||||
|
$"DIAG:{number} on {series}: expected {(accepted ? "accept" : "reject")}, got '{result}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_series_accepts_any_diagnostic_number()
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 99_999, null);
|
||||||
|
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diagnostic_rejection_message_names_series_and_limit()
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 5_000, null);
|
||||||
|
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Sixteen_i, address);
|
||||||
|
reason.ShouldNotBeNull();
|
||||||
|
reason.ShouldContain("5000");
|
||||||
|
reason.ShouldContain("Sixteen_i");
|
||||||
|
reason.ShouldContain("499");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver dispatch ----
|
||||||
|
|
||||||
|
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(
|
||||||
|
FocasCncSeries series,
|
||||||
|
params FocasTagDefinition[] tags)
|
||||||
|
{
|
||||||
|
var factory = new FakeFocasClientFactory();
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: series)],
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
return (drv, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DIAG_read_routes_through_ReadDiagnosticAsync_with_axis_zero()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
FocasCncSeries.Thirty_i,
|
||||||
|
new FocasTagDefinition("AlarmCause", "focas://10.0.0.5:8193", "DIAG:1000", FocasDataType.Int32));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () =>
|
||||||
|
{
|
||||||
|
var c = new FakeFocasClient();
|
||||||
|
c.Values["DIAG:1000"] = 42;
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["AlarmCause"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
|
snapshots.Single().Value.ShouldBe(42);
|
||||||
|
var fake = factory.Clients.Single();
|
||||||
|
fake.DiagnosticReads.Single().ShouldBe((1000, 0, FocasDataType.Int32));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DIAG_per_axis_read_threads_axis_index_through()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
FocasCncSeries.Thirty_i,
|
||||||
|
new FocasTagDefinition("ServoLoad2", "focas://10.0.0.5:8193", "DIAG:280/2", FocasDataType.Int16));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () =>
|
||||||
|
{
|
||||||
|
var c = new FakeFocasClient();
|
||||||
|
c.Values["DIAG:280/2"] = (short)17;
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["ServoLoad2"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
|
snapshots.Single().Value.ShouldBe((short)17);
|
||||||
|
factory.Clients.Single().DiagnosticReads.Single().ShouldBe((280, 2, FocasDataType.Int16));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DIAG_out_of_range_for_series_rejected_at_init()
|
||||||
|
{
|
||||||
|
var (drv, _) = NewDriver(
|
||||||
|
FocasCncSeries.Sixteen_i,
|
||||||
|
new FocasTagDefinition("Bad", "focas://10.0.0.5:8193", "DIAG:5000", FocasDataType.Int32));
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||||
|
ex.Message.ShouldContain("5000");
|
||||||
|
ex.Message.ShouldContain("Sixteen_i");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DIAG_default_interface_method_surfaces_BadNotSupported()
|
||||||
|
{
|
||||||
|
// Stand-in client that does NOT override ReadDiagnosticAsync — falls through to
|
||||||
|
// the IFocasClient default returning BadNotSupported. Models a transport variant
|
||||||
|
// (e.g. older IPC contract) that hasn't extended its wire surface to diagnostics.
|
||||||
|
var factory = new BareFocasClientFactory();
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: FocasCncSeries.Unknown)],
|
||||||
|
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "DIAG:100", FocasDataType.Int32)],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test stand-in that overrides every interface method we need EXCEPT
|
||||||
|
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> — exercising the default
|
||||||
|
/// implementation that returns <c>BadNotSupported</c> for transports that
|
||||||
|
/// haven't extended their wire surface yet.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeWithoutDiagnosticOverride : IFocasClient
|
||||||
|
{
|
||||||
|
public bool IsConnected { get; private set; }
|
||||||
|
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{ IsConnected = true; return Task.CompletedTask; }
|
||||||
|
public Task<(object? value, uint status)> ReadAsync(
|
||||||
|
FocasAddress address, FocasDataType type, CancellationToken ct) =>
|
||||||
|
Task.FromResult<(object?, uint)>((null, FocasStatusMapper.Good));
|
||||||
|
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
|
||||||
|
Task.FromResult(FocasStatusMapper.Good);
|
||||||
|
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BareFocasClientFactory : IFocasClientFactory
|
||||||
|
{
|
||||||
|
public IFocasClient Create() => new FakeWithoutDiagnosticOverride();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user