Auto: focas-f1b — parts count + cycle time

Closes #258
This commit is contained in:
Joseph Doherty
2026-04-25 14:14:54 -04:00
parent 329e222aa2
commit 3d9697b918
5 changed files with 318 additions and 0 deletions

View File

@@ -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, (string Host, string Field)> _statusNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
@@ -38,6 +40,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"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<HostStatusChangedEventArgs>? 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<ISubscriptionHandle> 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; }
/// <summary>
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
/// every probe tick. Reads of the per-device <c>Production/&lt;field&gt;</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()
{
Client?.Dispose();