[focas] FOCAS — Parts count + cycle time #326
@@ -26,6 +26,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,6 +40,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
|
||||||
|
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
|
||||||
|
/// seconds (issue #258). Order matters for deterministic discovery output.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] ProductionFieldNames =
|
||||||
|
[
|
||||||
|
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
||||||
|
];
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
@@ -95,6 +107,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
foreach (var field in StatusFieldNames)
|
foreach (var field in StatusFieldNames)
|
||||||
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
||||||
(device.Options.HostAddress, field);
|
(device.Options.HostAddress, field);
|
||||||
|
foreach (var field in ProductionFieldNames)
|
||||||
|
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
|
||||||
|
(device.Options.HostAddress, field);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_options.Probe.Enabled)
|
if (_options.Probe.Enabled)
|
||||||
@@ -135,6 +150,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
_statusNodesByName.Clear();
|
_statusNodesByName.Clear();
|
||||||
|
_productionNodesByName.Clear();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +183,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed-tree Production/ nodes — served from the per-device cached production
|
||||||
|
// snapshot refreshed on the probe tick (issue #258). No wire call here.
|
||||||
|
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
|
||||||
|
{
|
||||||
|
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
@@ -305,6 +329,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
WriteIdempotent: false));
|
WriteIdempotent: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
|
||||||
|
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
|
||||||
|
// + served from DeviceState.LastProduction.
|
||||||
|
var productionFolder = deviceFolder.Folder("Production", "Production");
|
||||||
|
foreach (var field in ProductionFieldNames)
|
||||||
|
{
|
||||||
|
var fullRef = ProductionReferenceFor(device.HostAddress, field);
|
||||||
|
productionFolder.Variable(field, field, new DriverAttributeInfo(
|
||||||
|
FullName: fullRef,
|
||||||
|
DriverDataType: DriverDataType.Int32,
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.ViewOnly,
|
||||||
|
IsHistorized: false,
|
||||||
|
IsAlarm: false,
|
||||||
|
WriteIdempotent: false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -312,6 +354,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private static string StatusReferenceFor(string hostAddress, string field) =>
|
private static string StatusReferenceFor(string hostAddress, string field) =>
|
||||||
$"{hostAddress}::Status/{field}";
|
$"{hostAddress}::Status/{field}";
|
||||||
|
|
||||||
|
private static string ProductionReferenceFor(string hostAddress, string field) =>
|
||||||
|
$"{hostAddress}::Production/{field}";
|
||||||
|
|
||||||
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
||||||
{
|
{
|
||||||
"Tmmode" => s.Tmmode,
|
"Tmmode" => s.Tmmode,
|
||||||
@@ -326,6 +371,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
|
||||||
|
{
|
||||||
|
"PartsProduced" => p.PartsProduced,
|
||||||
|
"PartsRequired" => p.PartsRequired,
|
||||||
|
"PartsTotal" => p.PartsTotal,
|
||||||
|
"CycleTimeSeconds" => p.CycleTimeSeconds,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||||
|
|
||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
@@ -364,6 +418,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
state.LastStatus = snapshot;
|
state.LastStatus = snapshot;
|
||||||
state.LastStatusUtc = DateTime.UtcNow;
|
state.LastStatusUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
// Refresh the cached production snapshot too — same best-effort policy
|
||||||
|
// as Status/: a null result leaves the previous good snapshot in place
|
||||||
|
// so reads keep serving until the next successful refresh (issue #258).
|
||||||
|
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
|
||||||
|
if (production is not null)
|
||||||
|
{
|
||||||
|
state.LastProduction = production;
|
||||||
|
state.LastProductionUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
@@ -389,6 +452,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
device.LastStatusUtc, now);
|
device.LastStatusUtc, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||||
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
if (device.LastProduction is not { } snap)
|
||||||
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||||
|
var value = PickProductionField(snap, field);
|
||||||
|
if (value is null)
|
||||||
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
|
||||||
|
device.LastProductionUtc, now);
|
||||||
|
}
|
||||||
|
|
||||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||||
{
|
{
|
||||||
HostState old;
|
HostState old;
|
||||||
@@ -451,6 +527,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public FocasStatusInfo? LastStatus { get; set; }
|
public FocasStatusInfo? LastStatus { get; set; }
|
||||||
public DateTime LastStatusUtc { get; set; }
|
public DateTime LastStatusUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
|
||||||
|
/// every probe tick. Reads of the per-device <c>Production/<field></c>
|
||||||
|
/// fixed-tree nodes serve from this cache so they don't pile extra wire traffic
|
||||||
|
/// on top of the user-driven tag reads (issue #258).
|
||||||
|
/// </summary>
|
||||||
|
public FocasProductionInfo? LastProduction { get; set; }
|
||||||
|
public DateTime LastProductionUtc { get; set; }
|
||||||
|
|
||||||
public void DisposeClient()
|
public void DisposeClient()
|
||||||
{
|
{
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
|
|||||||
@@ -155,6 +155,38 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
Edit: buf.Edit));
|
Edit: buf.Edit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
|
||||||
|
if (!TryReadInt32Param(6711, out var produced) ||
|
||||||
|
!TryReadInt32Param(6712, out var required) ||
|
||||||
|
!TryReadInt32Param(6713, out var total))
|
||||||
|
{
|
||||||
|
return Task.FromResult<FocasProductionInfo?>(null);
|
||||||
|
}
|
||||||
|
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
|
||||||
|
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
|
||||||
|
// — the parts counters are still useful even when cycle-time isn't supported.
|
||||||
|
var cycleSeconds = 0;
|
||||||
|
var tmrBuf = new FwlibNative.IODBTMR();
|
||||||
|
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
|
||||||
|
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
|
||||||
|
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
|
||||||
|
PartsProduced: produced,
|
||||||
|
PartsRequired: required,
|
||||||
|
PartsTotal: total,
|
||||||
|
CycleTimeSeconds: cycleSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReadInt32Param(ushort number, out int value)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||||
|
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
|
||||||
|
if (ret != 0) { value = 0; return false; }
|
||||||
|
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- PMC ----
|
// ---- PMC ----
|
||||||
|
|
||||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||||
|
|||||||
@@ -88,6 +88,17 @@ internal static class FwlibNative
|
|||||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||||
|
|
||||||
|
// ---- Timers ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
|
||||||
|
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
|
||||||
|
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
|
||||||
|
/// so the surface can grow without another P/Invoke.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
|
||||||
|
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
|
||||||
|
|
||||||
// ---- Structs ----
|
// ---- Structs ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -129,6 +140,17 @@ internal static class FwlibNative
|
|||||||
public short DecVal; // decimal-point count
|
public short DecVal; // decimal-point count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
|
||||||
|
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct IODBTMR
|
||||||
|
{
|
||||||
|
public int Minute;
|
||||||
|
public int Msec;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
public struct ODBST
|
public struct ODBST
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ public interface IFocasClient : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
=> Task.FromResult<FocasStatusInfo?>(null);
|
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the per-CNC production counters (parts produced / required / total via
|
||||||
|
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
|
||||||
|
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
|
||||||
|
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
|
||||||
|
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
|
||||||
|
/// the cache untouched and the per-field nodes report Bad until the first refresh.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasProductionInfo?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -79,6 +90,18 @@ public sealed record FocasStatusInfo(
|
|||||||
short Alarm,
|
short Alarm,
|
||||||
short Edit);
|
short Edit);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
|
||||||
|
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
|
||||||
|
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
|
||||||
|
/// <c>Int32</c> in the OPC UA address space.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasProductionInfo(
|
||||||
|
int PartsProduced,
|
||||||
|
int PartsRequired,
|
||||||
|
int PartsTotal,
|
||||||
|
int CycleTimeSeconds);
|
||||||
|
|
||||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||||
public interface IFocasClientFactory
|
public interface IFocasClientFactory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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 FocasProductionFixedTreeTests
|
||||||
|
{
|
||||||
|
private const string Host = "focas://10.0.0.5:8193";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||||
|
/// <see cref="FocasProductionInfo"/> snapshot from <c>GetProductionAsync</c>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||||
|
{
|
||||||
|
public FocasProductionInfo? Production { get; set; }
|
||||||
|
|
||||||
|
Task<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
|
||||||
|
Task.FromResult(Production);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_nodes_per_device()
|
||||||
|
{
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", new FakeFocasClientFactory());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production");
|
||||||
|
var prodVars = builder.Variables.Where(v =>
|
||||||
|
v.Info.FullName.Contains("::Production/")).ToList();
|
||||||
|
prodVars.Count.ShouldBe(4);
|
||||||
|
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
|
||||||
|
foreach (var name in expected)
|
||||||
|
{
|
||||||
|
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
|
||||||
|
node.BrowseName.ShouldBe(name);
|
||||||
|
node.Info.DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||||
|
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||||
|
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot()
|
||||||
|
{
|
||||||
|
var fake = new ProductionAwareFakeFocasClient
|
||||||
|
{
|
||||||
|
Production = new FocasProductionInfo(
|
||||||
|
PartsProduced: 17,
|
||||||
|
PartsRequired: 100,
|
||||||
|
PartsTotal: 4242,
|
||||||
|
CycleTimeSeconds: 73),
|
||||||
|
};
|
||||||
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host)],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Wait for at least one probe tick to populate the cache.
|
||||||
|
await WaitForAsync(async () =>
|
||||||
|
{
|
||||||
|
var snap = (await drv.ReadAsync(
|
||||||
|
[$"{Host}::Production/PartsProduced"], CancellationToken.None)).Single();
|
||||||
|
return snap.StatusCode == FocasStatusMapper.Good;
|
||||||
|
}, TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
var refs = new[]
|
||||||
|
{
|
||||||
|
$"{Host}::Production/PartsProduced",
|
||||||
|
$"{Host}::Production/PartsRequired",
|
||||||
|
$"{Host}::Production/PartsTotal",
|
||||||
|
$"{Host}::Production/CycleTimeSeconds",
|
||||||
|
};
|
||||||
|
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
snaps[0].Value.ShouldBe(17);
|
||||||
|
snaps[1].Value.ShouldBe(100);
|
||||||
|
snaps[2].Value.ShouldBe(4242);
|
||||||
|
snaps[3].Value.ShouldBe(73);
|
||||||
|
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty()
|
||||||
|
{
|
||||||
|
// Probe disabled — cache never populates; the production nodes still resolve as
|
||||||
|
// known references but report Bad until the first successful poll lands.
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host)],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", new FakeFocasClientFactory());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(
|
||||||
|
[$"{Host}::Production/PartsProduced"], CancellationToken.None);
|
||||||
|
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected()
|
||||||
|
{
|
||||||
|
// Construction is licence-safe (no DLL load); calling GetProductionAsync on the
|
||||||
|
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||||
|
// in its current state.
|
||||||
|
var client = new FwlibFocasClient();
|
||||||
|
var result = await client.GetProductionAsync(CancellationToken.None);
|
||||||
|
result.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