From 3d9697b9189bf707d781984038946bcaede83f51 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 14:14:54 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20focas-f1b=20=E2=80=94=20parts=20count?= =?UTF-8?q?=20+=20cycle=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #258 --- .../FocasDriver.cs | 85 ++++++++++ .../FwlibFocasClient.cs | 32 ++++ .../FwlibNative.cs | 22 +++ .../IFocasClient.cs | 23 +++ .../FocasProductionFixedTreeTests.cs | 156 ++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 680d18a..3067388 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -26,6 +26,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _statusNodesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _productionNodesByName = + new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); /// @@ -38,6 +40,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, "Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy", ]; + /// + /// Names of the 4 fixed-tree Production/ child nodes per device — parts + /// produced/required/total via cnc_rdparam(6711/6712/6713) + cycle-time + /// seconds (issue #258). Order matters for deterministic discovery output. + /// + private static readonly string[] ProductionFieldNames = + [ + "PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds", + ]; + public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; @@ -95,6 +107,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, foreach (var field in StatusFieldNames) _statusNodesByName[StatusReferenceFor(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) @@ -135,6 +150,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _devices.Clear(); _tagsByName.Clear(); _statusNodesByName.Clear(); + _productionNodesByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } @@ -167,6 +183,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, 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)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); @@ -305,6 +329,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IsAlarm: 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; } @@ -312,6 +354,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private static string StatusReferenceFor(string hostAddress, string 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 { "Tmmode" => s.Tmmode, @@ -326,6 +371,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _ => 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) ---- public Task SubscribeAsync( @@ -364,6 +418,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.LastStatus = snapshot; 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; } @@ -389,6 +452,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, 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) { HostState old; @@ -451,6 +527,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public FocasStatusInfo? LastStatus { get; set; } public DateTime LastStatusUtc { get; set; } + /// + /// Cached cnc_rdparam(6711/6712/6713) + cycle-time snapshot, refreshed on + /// every probe tick. Reads of the per-device Production/<field> + /// 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). + /// + public FocasProductionInfo? LastProduction { get; set; } + public DateTime LastProductionUtc { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index af0a24e..737e41b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -155,6 +155,38 @@ internal sealed class FwlibFocasClient : IFocasClient Edit: buf.Edit)); } + public Task GetProductionAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(null); + if (!TryReadInt32Param(6711, out var produced) || + !TryReadInt32Param(6712, out var required) || + !TryReadInt32Param(6713, out var total)) + { + return Task.FromResult(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(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 ---- private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index 08c2761..afea72d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -88,6 +88,17 @@ internal static class FwlibNative [DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)] public static extern short StatInfo(ushort handle, ref ODBST buffer); + // ---- Timers ---- + + /// + /// cnc_rdtimer — read CNC running timers. : 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. + /// + [DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)] + public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer); + // ---- Structs ---- /// @@ -129,6 +140,17 @@ internal static class FwlibNative public short DecVal; // decimal-point count } + /// + /// IODBTMR — running-timer read buffer per fwlib32.h. Minute portion in + /// ; sub-minute remainder in milliseconds in . + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct IODBTMR + { + public int Minute; + public int Msec; + } + /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode. [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct ODBST diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 38f55ba..b90a374 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -59,6 +59,17 @@ public interface IFocasClient : IDisposable /// Task GetStatusAsync(CancellationToken cancellationToken) => Task.FromResult(null); + + /// + /// Read the per-CNC production counters (parts produced / required / total via + /// cnc_rdparam(6711/6712/6713)) plus the current cycle-time seconds counter + /// (cnc_rdtimer(2)). Surfaced on the FOCAS driver's Production/ + /// fixed-tree per device (issue #258). Returns null 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. + /// + Task GetProductionAsync(CancellationToken cancellationToken) + => Task.FromResult(null); } /// @@ -79,6 +90,18 @@ public sealed record FocasStatusInfo( short Alarm, short Edit); +/// +/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258). +/// Sourced from cnc_rdparam(6711/6712/6713) for the parts counts + the cycle-time +/// timer counter (FWLIB cnc_rdtimer when available). All values surfaced as +/// Int32 in the OPC UA address space. +/// +public sealed record FocasProductionInfo( + int PartsProduced, + int PartsRequired, + int PartsTotal, + int CycleTimeSeconds); + /// Factory for s. One client per configured device. public interface IFocasClientFactory { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs new file mode 100644 index 0000000..a831b51 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs @@ -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"; + + /// + /// Variant of that returns a configurable + /// snapshot from GetProductionAsync. + /// + private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient + { + public FocasProductionInfo? Production { get; set; } + + Task 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> 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) { } } + } +}