Auto: focas-f1c — Modal codes + overrides
Closes #259 Adds Modal/ + Override/ fixed-tree subfolders per FOCAS device, mirroring the pattern established by Status/ (#257) and Production/ (#258): cached snapshots refreshed on the probe tick, served from cache on read, no extra wire traffic on top of user-driven tag reads. Modal/ surfaces the four universally-present aux modal codes M/S/T/B from cnc_modal(type=100..103) as Int16. **G-group decoding (groups 1..21) is deferred to a follow-up** — the FWLIB ODBMDL union differs per series + group and the issue body explicitly permits this scoping. Adds the cnc_modal P/Invoke + ODBMDL struct + a generic int16 cnc_rdparam helper so the follow-up can add G-groups without further wire-level scaffolding. Override/ surfaces Feed/Rapid/Spindle/Jog from cnc_rdparam at MTB-specific parameter numbers (FocasDeviceOptions.OverrideParameters; defaults to 30i: 6010/6011/6014/6015). Per-field nullable params let a deployment hide overrides their MTB doesn't wire up; passing OverrideParameters=null suppresses the entire Override/ subfolder for that device. 6 unit tests cover discovery shape, omitted Override folder when unconfigured, partial Override field selection, cached-snapshot reads (Modal + Override), BadCommunicationError before first refresh, and the FwlibFocasClient disconnected short-circuit.
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();
|
||||
|
||||
Reference in New Issue
Block a user