Merge pull request '[focas] FOCAS — Modal codes + overrides' (#327) from auto/focas/F1-c into auto/driver-gaps
This commit was merged in pull request #327.
This commit is contained in:
@@ -28,6 +28,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
@@ -50,6 +54,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
|
||||
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
|
||||
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
|
||||
/// </summary>
|
||||
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the four operator-override child nodes per device — Feed / Rapid /
|
||||
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
|
||||
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
|
||||
/// given field has the matching node omitted from the address space.
|
||||
/// </summary>
|
||||
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
@@ -110,6 +129,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
foreach (var field in ProductionFieldNames)
|
||||
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
foreach (var field in ModalFieldNames)
|
||||
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
if (device.Options.OverrideParameters is { } op)
|
||||
{
|
||||
foreach (var field in OverrideFieldNames)
|
||||
{
|
||||
if (OverrideParamFor(op, field) is null) continue;
|
||||
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
@@ -151,6 +182,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_tagsByName.Clear();
|
||||
_statusNodesByName.Clear();
|
||||
_productionNodesByName.Clear();
|
||||
_modalNodesByName.Clear();
|
||||
_overrideNodesByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -191,6 +224,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
|
||||
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
|
||||
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
|
||||
{
|
||||
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
|
||||
{
|
||||
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -347,6 +393,48 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
|
||||
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
|
||||
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
|
||||
// per series + group (issue #259, plan PR F1-c).
|
||||
var modalFolder = deviceFolder.Folder("Modal", "Modal");
|
||||
foreach (var field in ModalFieldNames)
|
||||
{
|
||||
var fullRef = ModalReferenceFor(device.HostAddress, field);
|
||||
modalFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
|
||||
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
|
||||
// OverrideParameters is null; per-field nodes whose parameter is null are
|
||||
// omitted so a deployment can hide overrides their MTB doesn't wire up.
|
||||
if (device.OverrideParameters is { } overrideParams)
|
||||
{
|
||||
var overrideFolder = deviceFolder.Folder("Override", "Override");
|
||||
foreach (var field in OverrideFieldNames)
|
||||
{
|
||||
if (OverrideParamFor(overrideParams, field) is null) continue;
|
||||
var fullRef = OverrideReferenceFor(device.HostAddress, field);
|
||||
overrideFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -357,6 +445,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private static string ProductionReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Production/{field}";
|
||||
|
||||
private static string ModalReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Modal/{field}";
|
||||
|
||||
private static string OverrideReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Override/{field}";
|
||||
|
||||
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
|
||||
{
|
||||
"Feed" => p.FeedParam,
|
||||
"Rapid" => p.RapidParam,
|
||||
"Spindle" => p.SpindleParam,
|
||||
"Jog" => p.JogParam,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
||||
{
|
||||
"Tmmode" => s.Tmmode,
|
||||
@@ -380,6 +483,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickModalField(FocasModalInfo m, string field) => field switch
|
||||
{
|
||||
"MCode" => m.MCode,
|
||||
"SCode" => m.SCode,
|
||||
"TCode" => m.TCode,
|
||||
"BCode" => m.BCode,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
|
||||
{
|
||||
"Feed" => o.Feed,
|
||||
"Rapid" => o.Rapid,
|
||||
"Spindle" => o.Spindle,
|
||||
"Jog" => o.Jog,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -427,6 +548,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.LastProduction = production;
|
||||
state.LastProductionUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
|
||||
// policy as Status/ + Production/. Override snapshot is suppressed when
|
||||
// the device has no OverrideParameters configured (issue #259).
|
||||
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
|
||||
if (modal is not null)
|
||||
{
|
||||
state.LastModal = modal;
|
||||
state.LastModalUtc = DateTime.UtcNow;
|
||||
}
|
||||
if (state.Options.OverrideParameters is { } overrideParams)
|
||||
{
|
||||
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
|
||||
if (ov is not null)
|
||||
{
|
||||
state.LastOverride = ov;
|
||||
state.LastOverrideUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
@@ -465,6 +604,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.LastProductionUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastModal is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickModalField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastModalUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastOverride is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickOverrideField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastOverrideUtc, now);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -536,6 +701,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public FocasProductionInfo? LastProduction { get; set; }
|
||||
public DateTime LastProductionUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
|
||||
/// Reads of the per-device <c>Modal/<field></c> nodes serve from this cache
|
||||
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
|
||||
/// </summary>
|
||||
public FocasModalInfo? LastModal { get; set; }
|
||||
public DateTime LastModalUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
|
||||
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
|
||||
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
|
||||
/// </summary>
|
||||
public FocasOverrideInfo? LastOverride { get; set; }
|
||||
public DateTime LastOverrideUtc { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -17,11 +17,15 @@ public sealed class FocasDriverOptions
|
||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||
/// <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).
|
||||
/// </summary>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||
FocasOverrideParameters? OverrideParameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
|
||||
@@ -187,6 +187,61 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryReadInt16Param(ushort number, out short value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
|
||||
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
|
||||
// Best-effort — if any single read fails we still surface the others as 0; the
|
||||
// probe loop only updates the cache on a non-null return so a partial snapshot
|
||||
// is preferable to throwing away every successful field.
|
||||
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
|
||||
MCode: ReadModalAux(type: 100),
|
||||
SCode: ReadModalAux(type: 101),
|
||||
TCode: ReadModalAux(type: 102),
|
||||
BCode: ReadModalAux(type: 103)));
|
||||
}
|
||||
|
||||
private short ReadModalAux(short type)
|
||||
{
|
||||
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
|
||||
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
|
||||
if (ret != 0) return 0;
|
||||
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
|
||||
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
|
||||
// record contract; oversized values would have been truncated by FWLIB anyway.
|
||||
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
}
|
||||
|
||||
public Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
|
||||
// Each parameter is independently nullable — a null parameter number keeps the
|
||||
// corresponding field at null + skips the wire call. A successful read on at
|
||||
// least one parameter is enough to publish a snapshot; this matches the
|
||||
// best-effort policy used by GetProductionAsync (issue #259).
|
||||
var feed = TryReadOverride(parameters.FeedParam);
|
||||
var rapid = TryReadOverride(parameters.RapidParam);
|
||||
var spindle = TryReadOverride(parameters.SpindleParam);
|
||||
var jog = TryReadOverride(parameters.JogParam);
|
||||
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
|
||||
}
|
||||
|
||||
private short? TryReadOverride(ushort? param)
|
||||
{
|
||||
if (param is null) return null;
|
||||
return TryReadInt16Param(param.Value, out var v) ? v : null;
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
|
||||
@@ -99,6 +99,19 @@ internal static class FwlibNative
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
|
||||
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
|
||||
|
||||
// ---- Modal codes ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
|
||||
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
|
||||
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
|
||||
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
|
||||
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
|
||||
/// varies by group + series (issue #259).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
|
||||
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
@@ -151,6 +164,22 @@ internal static class FwlibNative
|
||||
public int Msec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
|
||||
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
|
||||
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
|
||||
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
|
||||
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBMDL
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
|
||||
@@ -70,6 +70,27 @@ public interface IFocasClient : IDisposable
|
||||
/// </summary>
|
||||
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasProductionInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
|
||||
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
|
||||
/// issue body permits surfacing only the universally-present M/S/T/B fields in
|
||||
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasModalInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four operator override values (feed / rapid / spindle / jog) via
|
||||
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
|
||||
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
|
||||
/// field's read (the corresponding node is also omitted from the address space).
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
|
||||
/// </summary>
|
||||
Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOverrideInfo?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,6 +123,47 @@ public sealed record FocasProductionInfo(
|
||||
int PartsTotal,
|
||||
int CycleTimeSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
|
||||
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
|
||||
/// the issue body permits the first cut to surface only the universally-present
|
||||
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
|
||||
/// </summary>
|
||||
public sealed record FocasModalInfo(
|
||||
short MCode,
|
||||
short SCode,
|
||||
short TCode,
|
||||
short BCode);
|
||||
|
||||
/// <summary>
|
||||
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
|
||||
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
|
||||
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
|
||||
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
|
||||
/// wire up rather than always serving Bad.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideParameters(
|
||||
ushort? FeedParam,
|
||||
ushort? RapidParam,
|
||||
ushort? SpindleParam,
|
||||
ushort? JogParam)
|
||||
{
|
||||
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
|
||||
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
|
||||
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
|
||||
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
|
||||
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideInfo(
|
||||
short? Feed,
|
||||
short? Rapid,
|
||||
short? Spindle,
|
||||
short? Jog);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasModalOverrideFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.6:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
|
||||
/// </summary>
|
||||
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasModalInfo? Modal { get; set; }
|
||||
public FocasOverrideInfo? Override { get; set; }
|
||||
public FocasOverrideParameters? LastOverrideParams { get; private set; }
|
||||
|
||||
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Modal);
|
||||
|
||||
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken ct)
|
||||
{
|
||||
LastOverrideParams = parameters;
|
||||
return Task.FromResult(Override);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-modal", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
|
||||
var modalVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Modal/")).ToList();
|
||||
modalVars.Count.ShouldBe(4);
|
||||
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-no-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
|
||||
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_only_configured_Override_fields()
|
||||
{
|
||||
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-partial-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
|
||||
var overrideVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Override/")).ToList();
|
||||
overrideVars.Count.ShouldBe(2);
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ModalAwareFakeFocasClient
|
||||
{
|
||||
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
|
||||
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-modal-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate both caches.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Modal/MCode",
|
||||
$"{Host}::Modal/SCode",
|
||||
$"{Host}::Modal/TCode",
|
||||
$"{Host}::Modal/BCode",
|
||||
$"{Host}::Override/Feed",
|
||||
$"{Host}::Override/Rapid",
|
||||
$"{Host}::Override/Spindle",
|
||||
$"{Host}::Override/Jog",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)8);
|
||||
snaps[1].Value.ShouldBe((short)1200);
|
||||
snaps[2].Value.ShouldBe((short)101);
|
||||
snaps[3].Value.ShouldBe((short)0);
|
||||
snaps[4].Value.ShouldBe((short)100);
|
||||
snaps[5].Value.ShouldBe((short)50);
|
||||
snaps[6].Value.ShouldBe((short)110);
|
||||
snaps[7].Value.ShouldBe((short)25);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
// The driver hands the device's configured override parameters to the wire client
|
||||
// verbatim — defaulting to 30i numbers.
|
||||
fake.LastOverrideParams.ShouldNotBeNull();
|
||||
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
|
||||
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither modal nor override caches populate; the nodes still
|
||||
// resolve as known references but report Bad until the first successful poll.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-empty-cache", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must short-
|
||||
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetOverrideAsync(
|
||||
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user