FOCAS — retire Tier-C split, inline managed wire client, make read-only
Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// fail fast.
|
||||
/// </remarks>
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -24,10 +24,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private FocasAlarmProjection? _alarmProjection;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null)
|
||||
@@ -35,7 +37,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
|
||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
@@ -85,6 +87,30 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.HandleRecycle.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.RecycleCts = new CancellationTokenSource();
|
||||
var ct = state.RecycleCts.Token;
|
||||
_ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.AlarmProjection.Enabled)
|
||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval);
|
||||
|
||||
if (_options.FixedTree.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.FixedTreeCts = new CancellationTokenSource();
|
||||
var ct = state.FixedTreeCts.Token;
|
||||
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -104,11 +130,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
try { state.RecycleCts?.Cancel(); } catch { }
|
||||
state.RecycleCts?.Dispose();
|
||||
state.RecycleCts = null;
|
||||
try { state.FixedTreeCts?.Cancel(); } catch { }
|
||||
state.FixedTreeCts?.Dispose();
|
||||
state.FixedTreeCts = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
@@ -136,6 +173,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
|
||||
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
|
||||
// already fires them on cadence.
|
||||
if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
|
||||
{
|
||||
results[i] = fx;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -241,6 +288,80 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
|
||||
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
|
||||
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
|
||||
if (_options.FixedTree.Enabled
|
||||
&& _devices.TryGetValue(device.HostAddress, out var state)
|
||||
&& state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var identity = deviceFolder.Folder("Identity", "Identity");
|
||||
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
|
||||
|
||||
var axesFolder = deviceFolder.Folder("Axes", "Axes");
|
||||
foreach (var axis in cache.Axes)
|
||||
{
|
||||
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
|
||||
if (cache.Capabilities.ServoLoad)
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
|
||||
}
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
|
||||
|
||||
// Spindle subtree — one folder per discovered spindle, suppressed
|
||||
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
||||
// Load + MaxRpm each gated on their own capability probe.
|
||||
if (cache.Capabilities.Spindles)
|
||||
{
|
||||
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
|
||||
for (var i = 0; i < cache.Spindles.Count; i++)
|
||||
{
|
||||
var s = cache.Spindles[i];
|
||||
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
var spindleFolder = spindleRoot.Folder(name, name);
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
|
||||
if (cache.Capabilities.ProgramInfo)
|
||||
{
|
||||
var program = deviceFolder.Folder("Program", "Program");
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
|
||||
|
||||
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
||||
if (cache.Capabilities.Timers)
|
||||
{
|
||||
var timers = deviceFolder.Folder("Timers", "Timers");
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
|
||||
}
|
||||
}
|
||||
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
@@ -261,6 +382,72 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private enum FocasDriverDataType { String, Int32, Float64 }
|
||||
|
||||
private static void EmitIdentityVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type switch
|
||||
{
|
||||
FocasDriverDataType.Int32 => DriverDataType.Int32,
|
||||
FocasDriverDataType.Float64 => DriverDataType.Float64,
|
||||
_ => DriverDataType.String,
|
||||
},
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
private static void EmitAxisVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: DriverDataType.Float64,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
|
||||
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
|
||||
/// </summary>
|
||||
private static void EmitFixedVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
|
||||
string field, DriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
|
||||
/// host as a prefix so multi-device configs don't collide, and the rest is
|
||||
/// the path inside the tree. Matches what poll-loop snapshots publish +
|
||||
/// what <see cref="ReadAsync"/> looks up.
|
||||
/// </summary>
|
||||
internal static string FixedTreeReference(string deviceHost, string path) =>
|
||||
$"{deviceHost}/{path}";
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -298,6 +485,310 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
|
||||
/// (once) so <see cref="DiscoverAsync"/> can render the subtree on its next
|
||||
/// invocation; every tick thereafter fires a <c>cnc_rddynamic2</c> per axis
|
||||
/// and publishes OnDataChange for the axis positions + feed rate + spindle
|
||||
/// speed.
|
||||
/// </summary>
|
||||
private async Task FixedTreeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
// Bootstrap: identity + axis names + per-optional-API capability probe.
|
||||
// Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION)
|
||||
// record the capability as unsupported and suppress the corresponding nodes
|
||||
// in DiscoverAsync + the poll loop.
|
||||
while (!ct.IsCancellationRequested && state.FixedTreeCache is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
|
||||
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Optional-API probes — each returns empty / throws when unsupported.
|
||||
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
|
||||
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
|
||||
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
|
||||
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
|
||||
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
|
||||
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
|
||||
|
||||
var caps = new FocasFixedTreeCapabilities(
|
||||
Spindles: spindles.Count > 0,
|
||||
SpindleLoad: spindleLoad.Count > 0,
|
||||
SpindleMaxRpm: spindleMaxRpms.Count > 0,
|
||||
ServoLoad: servoLoads.Count > 0,
|
||||
ProgramInfo: programInfo is not null,
|
||||
Timers: timer is not null);
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(
|
||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the spindle-loads cache from bootstrap if supported — avoids a
|
||||
// "tree is there but reads say BadNodeIdUnknown" window on startup.
|
||||
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
|
||||
{
|
||||
try
|
||||
{
|
||||
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
||||
}
|
||||
catch { /* first-tick poll will retry */ }
|
||||
}
|
||||
|
||||
var programPollDue = DateTime.MinValue;
|
||||
var timerPollDue = DateTime.MinValue;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var cache = state.FixedTreeCache;
|
||||
if (cache is null) break;
|
||||
FocasDynamicSnapshot? firstAxisSnap = null;
|
||||
for (var i = 0; i < cache.Axes.Count; i++)
|
||||
{
|
||||
var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
|
||||
var axis = cache.Axes[i];
|
||||
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
|
||||
PublishAxisSnapshot(state, axis, snap);
|
||||
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
|
||||
}
|
||||
|
||||
// Servo loads + spindle loads — both return bulk arrays, so folding
|
||||
// into the axis cadence is cheap. Each is gated by the bootstrap
|
||||
// capability probe — unsupported on this series = silent skip.
|
||||
if (cache.Capabilities.ServoLoad)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
|
||||
PublishServoLoads(state, loads);
|
||||
}
|
||||
catch { /* transient — next tick retries */ }
|
||||
}
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
||||
}
|
||||
catch { /* transient */ }
|
||||
}
|
||||
|
||||
// Program-info poll runs on its own cadence — much slower than the axis
|
||||
// poll because program / mode transitions are operator-driven.
|
||||
var programInterval = _options.FixedTree.ProgramPollInterval;
|
||||
if (cache.Capabilities.ProgramInfo
|
||||
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= programPollDue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var program = await client.GetProgramInfoAsync(ct).ConfigureAwait(false);
|
||||
state.LastProgramInfo = program;
|
||||
if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
|
||||
}
|
||||
catch { /* transient — next tick retries */ }
|
||||
programPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
|
||||
var timerInterval = _options.FixedTree.TimerPollInterval;
|
||||
if (cache.Capabilities.Timers
|
||||
&& timerInterval > TimeSpan.Zero && DateTime.UtcNow >= timerPollDue)
|
||||
{
|
||||
foreach (FocasTimerKind kind in Enum.GetValues<FocasTimerKind>())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
|
||||
state.LastTimers[kind] = t;
|
||||
}
|
||||
catch { /* per-kind failures are non-fatal */ }
|
||||
}
|
||||
timerPollDue = DateTime.UtcNow + timerInterval;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* next tick retries — transient blips are expected */ }
|
||||
|
||||
try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache a fresh axis snapshot. The poll loop doesn't fire <c>OnDataChange</c>
|
||||
/// directly — subscribers go through the normal <c>SubscribeAsync</c> →
|
||||
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
|
||||
/// <see cref="TryReadFixedTree"/> and returns these cached values.
|
||||
/// </summary>
|
||||
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
|
||||
{
|
||||
var host = state.Options.HostAddress;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
|
||||
}
|
||||
|
||||
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
|
||||
{
|
||||
var host = state.Options.HostAddress;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, "Axes/FeedRate/Actual")] = snap.ActualFeedRate;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, "Axes/SpindleSpeed/Actual")] = snap.ActualSpindleSpeed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache servo-load percentages keyed by axis name. Stored separately from
|
||||
/// <c>LastFixedSnapshots</c> (which is int-typed) so the double-valued load
|
||||
/// values don't need casting on every read.
|
||||
/// </summary>
|
||||
private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> loads)
|
||||
{
|
||||
foreach (var load in loads)
|
||||
state.LastServoLoads[load.AxisName] = load.LoadPercent;
|
||||
}
|
||||
|
||||
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
|
||||
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
|
||||
|
||||
/// <summary>
|
||||
/// Call an optional probe that returns a collection; swallow any exception
|
||||
/// and return <paramref name="fallback"/>. Used by bootstrap to capture
|
||||
/// per-series capability without letting one failed probe take down the
|
||||
/// entire bootstrap sequence.
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<T>> SafeProbe<T>(
|
||||
Func<Task<IReadOnlyList<T>>> probe, IReadOnlyList<T> fallback)
|
||||
{
|
||||
try { return await probe().ConfigureAwait(false); }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nullable variant — probe returns a single object or null on failure.
|
||||
/// </summary>
|
||||
private static async Task<T?> SafeTryProbe<T>(Func<Task<T>> probe) where T : class
|
||||
{
|
||||
try { return await probe().ConfigureAwait(false); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read cached last-fixed-tree snapshots. Returns the projected value when
|
||||
/// the reference looks like a fixed-tree FullName; null when it doesn't
|
||||
/// (callers fall through to the user-authored tag path).
|
||||
/// </summary>
|
||||
private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
|
||||
return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now);
|
||||
|
||||
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
|
||||
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
||||
{
|
||||
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
||||
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
||||
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
|
||||
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
|
||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
{
|
||||
var tail = suffixFull["Spindle/".Length..];
|
||||
var slash = tail.IndexOf('/');
|
||||
if (slash > 0)
|
||||
{
|
||||
var spindleName = tail[..slash];
|
||||
var field = tail[(slash + 1)..];
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
||||
{
|
||||
var s = spindleCache.Spindles[i];
|
||||
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
{
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Identity strings + program / op-mode fields aren't cached as doubles —
|
||||
// re-derive from the struct caches.
|
||||
if (state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
var value = suffix switch
|
||||
{
|
||||
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
||||
"Identity/Version" => cache.SysInfo.Version,
|
||||
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
||||
"Identity/CncType" => cache.SysInfo.CncType,
|
||||
"Identity/MtType" => cache.SysInfo.MtType,
|
||||
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
||||
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
||||
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
||||
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
||||
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
||||
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
||||
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
||||
? FocasOpMode.ToText(pi.Mode) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
// Close the current handle — the next Read / Write / Probe call triggers
|
||||
// EnsureConnectedAsync, which reopens a fresh one. We don't block here on
|
||||
// reconnect because the goal is just to release the FWLIB handle slot; a
|
||||
// readable tick one probe cycle later is an acceptable cost.
|
||||
try { state.DisposeClient(); }
|
||||
catch { /* already disposed or race — next EnsureConnected recovers */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -312,6 +803,50 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_alarmProjection is null)
|
||||
throw new NotSupportedException(
|
||||
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
/// <summary>
|
||||
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
|
||||
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
|
||||
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
|
||||
/// </summary>
|
||||
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
|
||||
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
|
||||
{
|
||||
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||
result.Add((state.Options.HostAddress, alarms));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* surface a device-local fault on the next tick */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
@@ -341,6 +876,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Per-device fixed-tree cache populated once at first successful connect and
|
||||
/// read-only thereafter. Used by <see cref="DiscoverAsync"/> to render the
|
||||
/// tree + by <see cref="TryReadFixedTree"/> for synchronous Identity/* reads.
|
||||
/// </summary>
|
||||
internal sealed record FocasFixedTreeCache(
|
||||
FocasSysInfo SysInfo,
|
||||
IReadOnlyList<FocasAxisName> Axes,
|
||||
IReadOnlyList<FocasSpindleName> Spindles,
|
||||
IReadOnlyList<int> SpindleMaxRpms,
|
||||
FocasFixedTreeCapabilities Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Per-device optional-API capability flags — which of the "this may or may not
|
||||
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
|
||||
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
|
||||
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
|
||||
/// <c>BadDeviceFailure</c> on every read).
|
||||
/// </summary>
|
||||
internal sealed record FocasFixedTreeCapabilities(
|
||||
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
|
||||
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
|
||||
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
|
||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||
bool Timers); // cnc_rdtimer works for at least PowerOn
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
@@ -351,6 +913,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public CancellationTokenSource? RecycleCts { get; set; }
|
||||
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public FocasProgramInfo? LastProgramInfo { get; set; }
|
||||
/// <summary>Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
|
||||
public Dictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = [];
|
||||
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<int, int> LastSpindleLoads { get; } = [];
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's
|
||||
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper
|
||||
/// then materialises FOCAS DriverInstance rows from the central config DB
|
||||
/// into live driver instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||
/// <c>SharedSecret</c>.</item>
|
||||
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
|
||||
/// <item><c>"Backend": "wire"</c> (default) — pure-managed FOCAS2 wire
|
||||
/// client (<see cref="WireFocasClientFactory"/>) speaking directly to
|
||||
/// the CNC on TCP:8193.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> / <c>"none"</c> / <c>"stub"</c>
|
||||
/// — returns the no-op factory; useful for scaffolding DriverInstance
|
||||
/// rows before the CNC endpoint is reachable.</item>
|
||||
/// </list>
|
||||
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
|
||||
/// into <see cref="FocasDriverOptions"/>.
|
||||
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
|
||||
/// feed directly into <see cref="FocasDriverOptions"/>.
|
||||
/// </remarks>
|
||||
public static class FocasDriverFactoryExtensions
|
||||
{
|
||||
@@ -92,45 +88,19 @@ public static class FocasDriverFactoryExtensions
|
||||
internal static IFocasClientFactory BuildClientFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||
var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
|
||||
return backend switch
|
||||
{
|
||||
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||
"wire" => new WireFocasClientFactory(),
|
||||
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||
"Expected one of: ipc, fwlib, unimplemented."),
|
||||
"Expected one of: wire, unimplemented. " +
|
||||
"(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " +
|
||||
"see docs/drivers/FOCAS.md.)"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IpcFocasClientFactory BuildIpcFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var pipeName = dto.PipeName
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||
var sharedSecret = dto.SharedSecret
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||
var series = ParseSeries(dto.Series);
|
||||
|
||||
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||
// latency is identical to a fully-async factory.
|
||||
return new IpcFocasClientFactory(
|
||||
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||
pipeName: pipeName,
|
||||
sharedSecret: sharedSecret,
|
||||
connectTimeout: connectTimeout,
|
||||
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||
series: series);
|
||||
}
|
||||
|
||||
private static FocasCncSeries ParseSeries(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||
@@ -162,9 +132,6 @@ public static class FocasDriverFactoryExtensions
|
||||
internal sealed class FocasDriverConfigDto
|
||||
{
|
||||
public string? Backend { get; init; }
|
||||
public string? PipeName { get; init; }
|
||||
public string? SharedSecret { get; init; }
|
||||
public int? ConnectTimeoutMs { get; init; }
|
||||
public string? Series { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
|
||||
@@ -11,6 +11,77 @@ public sealed class FocasDriverOptions
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
|
||||
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
|
||||
/// <c>cnc_sysinfo</c> / <c>cnc_rdaxisname</c> / <c>cnc_rddynamic2</c>. Disabled by
|
||||
/// default so existing configs that only use user-authored tags don't grow new
|
||||
/// nodes on upgrade.
|
||||
/// </summary>
|
||||
public sealed class FocasFixedTreeOptions
|
||||
{
|
||||
/// <summary>Enable the fixed-node tree for every configured device.</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Poll cadence for <c>cnc_rddynamic2</c>. Each tick calls the API once per
|
||||
/// configured axis + publishes OnDataChange for the axis subtree. Real CNCs
|
||||
/// serve ~100ms loops comfortably; the default is conservative.
|
||||
/// </summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Poll cadence for program + operation-mode info. Slower than the axis
|
||||
/// poll because program / mode transitions happen on operator timescales.
|
||||
/// Zero / negative disables the program poll entirely.
|
||||
/// </summary>
|
||||
public TimeSpan ProgramPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Poll cadence for timers (power-on / operating / cutting / cycle).
|
||||
/// These change at human timescales — default is 30s. Zero / negative
|
||||
/// disables the timer poll entirely.
|
||||
/// </summary>
|
||||
public TimeSpan TimerPollInterval { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proactive session-recycle cadence. Fanuc CNCs have a finite FWLIB handle pool
|
||||
/// (~5–10 concurrent connections) and certain series have documented handle-leak bugs
|
||||
/// that manifest after long uptime. When <see cref="Enabled"/> is <c>true</c> the
|
||||
/// driver closes + reopens each device's session on the <see cref="Interval"/> cadence,
|
||||
/// forcing FWLIB to release its handle slot back to the pool. Reads / writes during
|
||||
/// recycle wait for the reconnect rather than failing — worst case an operator sees a
|
||||
/// brief read latency spike once per cadence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disabled by default because a healthy CNC + driver doesn't need it. Enable when
|
||||
/// field experience shows handle exhaustion against a specific series / firmware.
|
||||
/// Typical tuning: 30 min for sites running multiple OtOpcUa instances against the
|
||||
/// same CNC (they share the pool); 6 h for a single-client deployment.
|
||||
/// </remarks>
|
||||
public sealed class FocasHandleRecycleOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
|
||||
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
|
||||
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
|
||||
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
|
||||
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
|
||||
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
|
||||
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
|
||||
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
|
||||
/// mapping.</para>
|
||||
///
|
||||
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
|
||||
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
|
||||
/// calls <c>cnc_freelibhndl</c>.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FwlibFocasClient : IFocasClient
|
||||
{
|
||||
private ushort _handle;
|
||||
private bool _connected;
|
||||
|
||||
// Per-PMC-byte RMW lock registry. Bit writes to the same byte get serialised so two
|
||||
// concurrent bit updates don't lose one another's modification. Key = "{addrType}:{byteAddr}".
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
private SemaphoreSlim GetRmwLock(short addrType, int byteAddr) =>
|
||||
_rmwLocks.GetOrAdd($"{addrType}:{byteAddr}", _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connected) return Task.CompletedTask;
|
||||
|
||||
var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds);
|
||||
var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}.");
|
||||
_handle = handle;
|
||||
_connected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
|
||||
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Pmc => WritePmc(address, type, value),
|
||||
FocasAreaKind.Parameter => WriteParameter(address, type, value),
|
||||
FocasAreaKind.Macro => WriteMacro(address, value),
|
||||
_ => FocasStatusMapper.BadNotSupported,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
|
||||
/// concurrent bit writes against the same byte serialise and neither loses its update.
|
||||
/// </summary>
|
||||
private async Task<uint> WritePmcBitAsync(
|
||||
FocasAddress address, bool newValue, CancellationToken cancellationToken)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var bit = address.BitIndex ?? 0;
|
||||
if (bit is < 0 or > 7)
|
||||
throw new InvalidOperationException(
|
||||
$"PMC bit index {bit} out of range (0-7) for {address.Canonical}.");
|
||||
|
||||
var rmwLock = GetRmwLock(addrType, address.Number);
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Read the parent byte.
|
||||
var readBuf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var readRet = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, FocasPmcDataType.Byte,
|
||||
(ushort)address.Number, (ushort)address.Number, 8 + 1, ref readBuf);
|
||||
if (readRet != 0) return FocasStatusMapper.MapFocasReturn(readRet);
|
||||
|
||||
var current = readBuf.Data[0];
|
||||
var updated = newValue
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
|
||||
// Write the updated byte.
|
||||
var writeBuf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = FocasPmcDataType.Byte,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
writeBuf.Data[0] = updated;
|
||||
|
||||
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
|
||||
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "")
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'.");
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcReadLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var ret = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, dataType,
|
||||
(ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||
_ => (object)buf.Data[0],
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcWriteLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = dataType,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
EncodePmcValue(buf.Data, type, value, address.BitIndex);
|
||||
|
||||
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var length = ParamReadLength(type);
|
||||
var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteParameter(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD
|
||||
{
|
||||
Datano = (short)address.Number,
|
||||
Type = 0,
|
||||
Data = new byte[32],
|
||||
};
|
||||
var length = ParamReadLength(type);
|
||||
EncodeParamValue(buf.Data, type, value);
|
||||
var ret = FwlibNative.WrParam(_handle, (short)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||
{
|
||||
var buf = new FwlibNative.ODBM();
|
||||
var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
// Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct
|
||||
// scaled value regardless of the decimal-point count the CNC reports.
|
||||
var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal);
|
||||
return (scaled, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteMacro(FocasAddress address, object? value)
|
||||
{
|
||||
// Write as integer + 0 decimal places — callers that need decimal precision can extend
|
||||
// this via a future WriteMacroScaled overload. Consistent with what most HMIs do today.
|
||||
var intValue = Convert.ToInt32(value);
|
||||
var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
try { FwlibNative.FreeLibHndl(_handle); } catch { }
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int PmcReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload
|
||||
FocasDataType.Int16 => 8 + 2,
|
||||
FocasDataType.Int32 => 8 + 4,
|
||||
FocasDataType.Float32 => 8 + 4,
|
||||
FocasDataType.Float64 => 8 + 8,
|
||||
_ => 8 + 1,
|
||||
};
|
||||
|
||||
private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type);
|
||||
private static int ParamReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||
FocasDataType.Int16 => 4 + 2,
|
||||
FocasDataType.Int32 => 4 + 4,
|
||||
_ => 4 + 4,
|
||||
};
|
||||
|
||||
private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0;
|
||||
|
||||
internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Bit:
|
||||
// PMC Bit writes with a non-null bitIndex go through WritePmcBitAsync's RMW path
|
||||
// upstream. This branch only fires when a caller passes Bit with no bitIndex —
|
||||
// treat the value as a whole-byte boolean (non-zero / zero).
|
||||
data[0] = Convert.ToBoolean(value) ? (byte)1 : (byte)0;
|
||||
break;
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
case FocasDataType.Float32:
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value));
|
||||
break;
|
||||
case FocasDataType.Float64:
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"FocasDataType {type} not writable via PMC.");
|
||||
}
|
||||
_ = bitIndex; // bit-in-byte handled above
|
||||
}
|
||||
|
||||
internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
|
||||
public sealed class FwlibFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new FwlibFocasClient();
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
|
||||
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
|
||||
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
|
||||
/// or on <c>PATH</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
|
||||
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
|
||||
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
|
||||
/// are added.
|
||||
/// </remarks>
|
||||
internal static class FwlibNative
|
||||
{
|
||||
private const string Library = "Fwlib32.dll";
|
||||
|
||||
// ---- Handle lifetime ----
|
||||
|
||||
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short AllcLibHndl3(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
|
||||
ushort port,
|
||||
int timeout,
|
||||
out ushort handle);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)]
|
||||
public static extern short FreeLibHndl(ushort handle);
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
|
||||
[DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcRdPmcRng(
|
||||
ushort handle,
|
||||
short addrType,
|
||||
short dataType,
|
||||
ushort startNumber,
|
||||
ushort endNumber,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcWrPmcRng(
|
||||
ushort handle,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
// ---- Parameters ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)]
|
||||
public static extern short RdParam(
|
||||
ushort handle,
|
||||
ushort number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)]
|
||||
public static extern short WrParam(
|
||||
ushort handle,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
// ---- Macro variables ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||
public static extern short RdMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
ref ODBM buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)]
|
||||
public static extern short WrMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
int macroValue,
|
||||
short decimalPointCount);
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
|
||||
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPMC
|
||||
{
|
||||
public short TypeA;
|
||||
public short TypeD;
|
||||
public ushort DatanoS;
|
||||
public ushort DatanoE;
|
||||
// 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0.
|
||||
/// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPSD
|
||||
{
|
||||
public short Datano;
|
||||
public short Type; // axis index (0 for non-axis)
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBM
|
||||
{
|
||||
public short Datano;
|
||||
public short Dummy;
|
||||
public int McrVal; // long in C; 32-bit signed
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
{
|
||||
public short Dummy;
|
||||
public short TmMode;
|
||||
public short Aut;
|
||||
public short Run;
|
||||
public short Motion;
|
||||
public short Mstb;
|
||||
public short Emergency;
|
||||
public short Alarm;
|
||||
public short Edit;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Per Fanuc FOCAS/2 spec the codes
|
||||
/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally +
|
||||
/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded.
|
||||
/// </summary>
|
||||
internal static class FocasPmcAddrType
|
||||
{
|
||||
public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => 0,
|
||||
"F" => 1,
|
||||
"Y" => 2,
|
||||
"X" => 3,
|
||||
"A" => 4,
|
||||
"R" => 5,
|
||||
"T" => 6,
|
||||
"K" => 7,
|
||||
"C" => 8,
|
||||
"D" => 9,
|
||||
"E" => 10,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
|
||||
internal static class FocasPmcDataType
|
||||
{
|
||||
public const short Byte = 0;
|
||||
public const short Word = 1;
|
||||
public const short Long = 2;
|
||||
public const short Float = 4;
|
||||
public const short Double = 5;
|
||||
|
||||
public static short FromFocasDataType(FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => Byte,
|
||||
FocasDataType.Int16 => Word,
|
||||
FocasDataType.Int32 => Long,
|
||||
FocasDataType.Float32 => Float,
|
||||
FocasDataType.Float64 => Double,
|
||||
_ => Byte,
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// configured device; lifetime matches the device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
|
||||
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
|
||||
/// cannot legally be redistributed. The deployment team supplies an
|
||||
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
|
||||
/// P/Invoke and registers it at server startup.</para>
|
||||
/// <para>The default implementation is <see cref="Wire.WireFocasClient"/> — a pure-managed
|
||||
/// FOCAS/2 Ethernet client that speaks the wire protocol directly on TCP:8193. No
|
||||
/// P/Invoke, no native DLLs, no out-of-process isolation.</para>
|
||||
///
|
||||
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
|
||||
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
|
||||
/// mysteriously hanging.</para>
|
||||
/// <para><see cref="UnimplementedFocasClientFactory"/> is a scaffolding backend that
|
||||
/// throws on <see cref="IFocasClientFactory.Create"/> — selected by
|
||||
/// <c>"Backend": "unimplemented"</c> so a DriverInstance row can be seeded before the CNC
|
||||
/// endpoint is reachable without silently reading stale data.</para>
|
||||
/// </remarks>
|
||||
public interface IFocasClient : IDisposable
|
||||
{
|
||||
@@ -48,8 +47,208 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
|
||||
/// zero-or-more active alarms. Null / empty list means "no alarms currently
|
||||
/// active". IAlarmSource projection polls this at a configurable interval +
|
||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read CNC identity via <c>cnc_sysinfo</c>. Populates the <c>Identity/*</c>
|
||||
/// subtree of the fixed-node surface. Callable once at session open; the
|
||||
/// values don't change across the session.
|
||||
/// </summary>
|
||||
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC's configured axis names via <c>cnc_rdaxisname</c>. The driver
|
||||
/// uses these to build the <c>Axes/{name}/</c> subtree and to index
|
||||
/// <see cref="ReadDynamicAsync"/> calls.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC's configured spindle names via <c>cnc_rdspdlname</c>. Drives
|
||||
/// the <c>Spindle/{name}/</c> subtree.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the fast-poll dynamic bundle for one axis via <c>cnc_rddynamic2</c>.
|
||||
/// Returns the current position quadruple (absolute / machine / relative /
|
||||
/// distance-to-go) plus actual feed rate + actual spindle speed + alarm
|
||||
/// flags + program / sequence numbers — one network round-trip per call.
|
||||
/// </summary>
|
||||
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2 (program + operation mode) ----
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate program + operation-mode snapshot. One wire round-trip per
|
||||
/// underlying FWLIB call — <c>cnc_rdblkcount</c>, <c>cnc_exeprgname2</c>,
|
||||
/// <c>cnc_rdopmode</c>. The driver polls this on a slower cadence than
|
||||
/// <see cref="ReadDynamicAsync"/> since program / mode transitions happen
|
||||
/// on human-operator timescales.
|
||||
/// </summary>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3 (timers) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read one CNC cumulative timer. Kind selects PowerOn / Operating / Cutting /
|
||||
/// Cycle. Values are seconds — the managed side already converted the native
|
||||
/// minute+msec representation so downstream nodes display uniform units.
|
||||
/// </summary>
|
||||
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.5 (servo meters) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the servo-load meter percentages across all configured axes.
|
||||
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
|
||||
/// disconnected session or unsupported CNC.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.6 (spindle meters) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle load percentages. Result list index corresponds to
|
||||
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
|
||||
/// disconnected session or when the CNC doesn't support the call (older
|
||||
/// series like 16i may return EW_FUNC).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
|
||||
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
|
||||
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
|
||||
|
||||
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
|
||||
public enum FocasTimerKind
|
||||
{
|
||||
/// <summary>Machine power-on hours — resets never.</summary>
|
||||
PowerOn = 0,
|
||||
/// <summary>Cycle operating time — resets when the operator clears the counter.</summary>
|
||||
Operating = 1,
|
||||
/// <summary>Cutting time — only counts while in cutting feed.</summary>
|
||||
Cutting = 2,
|
||||
/// <summary>Cycle time since the last program start.</summary>
|
||||
Cycle = 3,
|
||||
}
|
||||
|
||||
/// <summary>One cumulative timer reading. <see cref="TotalSeconds"/> is the canonical unit.</summary>
|
||||
public sealed record FocasTimer(FocasTimerKind Kind, int Minutes, int Milliseconds)
|
||||
{
|
||||
/// <summary>Cumulative time in seconds — <c>Minutes * 60 + Milliseconds / 1000</c>.</summary>
|
||||
public double TotalSeconds => Minutes * 60.0 + Milliseconds / 1000.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CNC identity snapshot from <c>cnc_sysinfo</c>. Strings are trimmed ASCII.
|
||||
/// </summary>
|
||||
public sealed record FocasSysInfo(
|
||||
int AddInfo,
|
||||
int MaxAxis,
|
||||
string CncType, // "M" (mill) / "T" (lathe)
|
||||
string MtType,
|
||||
string Series, // e.g. "30i"
|
||||
string Version, // e.g. "A1.0"
|
||||
int AxesCount);
|
||||
|
||||
/// <summary>One configured axis name (e.g. "X", "X1").</summary>
|
||||
public sealed record FocasAxisName(string Name, string Suffix)
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name — name + suffix concatenated, trimmed. Empty suffix yields
|
||||
/// just the name (the common case on single-channel CNCs).
|
||||
/// </summary>
|
||||
public string Display => string.IsNullOrEmpty(Suffix) ? Name : $"{Name}{Suffix}";
|
||||
}
|
||||
|
||||
/// <summary>One configured spindle name (e.g. "S1").</summary>
|
||||
public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix2, string Suffix3)
|
||||
{
|
||||
public string Display
|
||||
{
|
||||
get
|
||||
{
|
||||
var s = Name + Suffix1 + Suffix2 + Suffix3;
|
||||
return s.TrimEnd('\0', ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
|
||||
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
|
||||
/// currently left to the caller to supply (via device config or a future
|
||||
/// <c>cnc_getfigure</c> path once that export lands).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Program + operation-mode snapshot. Name is the currently-executing
|
||||
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
|
||||
/// Mode is the numeric code from <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
|
||||
/// </summary>
|
||||
public sealed record FocasProgramInfo(
|
||||
string Name,
|
||||
int ONumber,
|
||||
int BlockCount,
|
||||
int Mode);
|
||||
|
||||
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
|
||||
public static class FocasOpMode
|
||||
{
|
||||
public static string ToText(int mode) => mode switch
|
||||
{
|
||||
0 => "MDI",
|
||||
1 => "AUTO",
|
||||
2 => "TJOG",
|
||||
3 => "EDIT",
|
||||
4 => "HANDLE",
|
||||
5 => "JOG",
|
||||
6 => "TEACH_IN_HANDLE",
|
||||
7 => "REFERENCE",
|
||||
8 => "REMOTE",
|
||||
9 => "TEST",
|
||||
_ => $"Mode{mode}",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record FocasDynamicSnapshot(
|
||||
int AxisIndex,
|
||||
int AlarmFlags,
|
||||
int ProgramNumber,
|
||||
int MainProgramNumber,
|
||||
int SequenceNumber,
|
||||
int ActualFeedRate,
|
||||
int ActualSpindleSpeed,
|
||||
int AbsolutePosition,
|
||||
int MachinePosition,
|
||||
int RelativePosition,
|
||||
int DistanceToGo);
|
||||
|
||||
/// <summary>
|
||||
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
|
||||
/// mirrors <c>ODBALMMSG2</c> but normalises the message bytes to a .NET string.
|
||||
/// </summary>
|
||||
public sealed record FocasActiveAlarm(
|
||||
int AlarmNumber,
|
||||
short Type,
|
||||
short Axis,
|
||||
string Message);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
@@ -57,14 +256,32 @@ public interface IFocasClientFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory that throws at construction time — the deployment must register a real
|
||||
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
|
||||
/// compile + the abstraction tests to run.
|
||||
/// Scaffolding factory — throws on <see cref="Create"/> so a DriverInstance row can be
|
||||
/// seeded ahead of the CNC endpoint being reachable without silently reading stale data.
|
||||
/// Select via <c>"Backend": "unimplemented"</c> in driver config. Flip to
|
||||
/// <c>"Backend": "wire"</c> once the CNC is provisioned.
|
||||
/// </summary>
|
||||
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => throw new NotSupportedException(
|
||||
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
|
||||
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
|
||||
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
|
||||
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
|
||||
"once the CNC is provisioned — see docs/drivers/FOCAS.md.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
||||
public const int All = -1;
|
||||
public const int Parameter = 0; // ALM_P
|
||||
public const int PulseCode = 1; // ALM_Y (servo)
|
||||
public const int Overtravel = 2; // ALM_O
|
||||
public const int Overheat = 3; // ALM_H
|
||||
public const int Servo = 4; // ALM_S
|
||||
public const int DataIo = 5; // ALM_T
|
||||
public const int MemoryCheck = 6; // ALM_M
|
||||
public const int MacroAlarm = 13; // ALM_MC — used by #3006 etc.
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy-side IPC channel to a running <c>Driver.FOCAS.Host</c>. Owns the pipe connection
|
||||
/// and serializes request/response round-trips through a single call gate so
|
||||
/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
|
||||
/// </summary>
|
||||
public sealed class FocasIpcClient : IAsyncDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly FrameReader _reader;
|
||||
private readonly FrameWriter _writer;
|
||||
private readonly SemaphoreSlim _callGate = new(1, 1);
|
||||
|
||||
private FocasIpcClient(Stream stream)
|
||||
{
|
||||
_stream = stream;
|
||||
_reader = new FrameReader(stream, leaveOpen: true);
|
||||
_writer = new FrameWriter(stream, leaveOpen: true);
|
||||
}
|
||||
|
||||
/// <summary>Named-pipe factory: connects, sends Hello, awaits HelloAck.</summary>
|
||||
public static async Task<FocasIpcClient> ConnectAsync(
|
||||
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
|
||||
{
|
||||
var stream = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
|
||||
return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stream factory — used by tests that wire the Proxy against an in-memory stream
|
||||
/// pair instead of a real pipe. <paramref name="stream"/> is owned by the caller
|
||||
/// until <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
public static Task<FocasIpcClient> ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
|
||||
=> HandshakeAsync(stream, sharedSecret, ct);
|
||||
|
||||
private static async Task<FocasIpcClient> HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
|
||||
{
|
||||
var client = new FocasIpcClient(stream);
|
||||
try
|
||||
{
|
||||
await client._writer.WriteAsync(FocasMessageKind.Hello,
|
||||
new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
|
||||
|
||||
var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
|
||||
throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
|
||||
|
||||
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
|
||||
if (!ackMsg.Accepted)
|
||||
throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
|
||||
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResp> CallAsync<TReq, TResp>(
|
||||
FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
|
||||
{
|
||||
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
|
||||
|
||||
var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
|
||||
|
||||
if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
|
||||
{
|
||||
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Value.Body);
|
||||
throw new FocasIpcException(err.Code, err.Message);
|
||||
}
|
||||
|
||||
if (frame.Value.Kind != expectedResponseKind)
|
||||
throw new InvalidOperationException(
|
||||
$"Expected {expectedResponseKind}, got {frame.Value.Kind}");
|
||||
|
||||
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
public async Task SendOneWayAsync<TReq>(FocasMessageKind requestKind, TReq request, CancellationToken ct)
|
||||
{
|
||||
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_callGate.Dispose();
|
||||
_reader.Dispose();
|
||||
_writer.Dispose();
|
||||
await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation that forwards every operation over a
|
||||
/// <see cref="FocasIpcClient"/> to a <c>Driver.FOCAS.Host</c> process. Keeps the
|
||||
/// <c>Fwlib32.dll</c> P/Invoke out of the main server process so a native crash
|
||||
/// blast-radius stops at the Host boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Session lifecycle: <see cref="ConnectAsync"/> sends <c>OpenSessionRequest</c> and
|
||||
/// caches the returned <c>SessionId</c>. Subsequent <see cref="ReadAsync"/> /
|
||||
/// <see cref="WriteAsync"/> / <see cref="ProbeAsync"/> calls thread that session id
|
||||
/// onto each request DTO. <see cref="Dispose"/> sends <c>CloseSessionRequest</c> +
|
||||
/// disposes the underlying pipe.
|
||||
/// </remarks>
|
||||
public sealed class IpcFocasClient : IFocasClient
|
||||
{
|
||||
private readonly FocasIpcClient _ipc;
|
||||
private readonly FocasCncSeries _series;
|
||||
private long _sessionId;
|
||||
private bool _connected;
|
||||
|
||||
public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown)
|
||||
{
|
||||
_ipc = ipc ?? throw new ArgumentNullException(nameof(ipc));
|
||||
_series = series;
|
||||
}
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connected) return;
|
||||
|
||||
var resp = await _ipc.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
FocasMessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest
|
||||
{
|
||||
HostAddress = $"{address.Host}:{address.Port}",
|
||||
TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds),
|
||||
CncSeries = (int)_series,
|
||||
},
|
||||
FocasMessageKind.OpenSessionResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}");
|
||||
|
||||
_sessionId = resp.SessionId;
|
||||
_connected = true;
|
||||
}
|
||||
|
||||
public async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return (null, FocasStatusMapper.BadCommunicationError);
|
||||
|
||||
var resp = await _ipc.CallAsync<ReadRequest, ReadResponse>(
|
||||
FocasMessageKind.ReadRequest,
|
||||
new ReadRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Address = ToDto(address),
|
||||
DataType = (int)type,
|
||||
},
|
||||
FocasMessageKind.ReadResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!resp.Success) return (null, resp.StatusCode);
|
||||
|
||||
var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode);
|
||||
return (value, resp.StatusCode);
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||
|
||||
// PMC bit writes get the first-class RMW frame so the critical section stays on the Host.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var bitResp = await _ipc.CallAsync<PmcBitWriteRequest, PmcBitWriteResponse>(
|
||||
FocasMessageKind.PmcBitWriteRequest,
|
||||
new PmcBitWriteRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Address = ToDto(address),
|
||||
BitIndex = bit,
|
||||
Value = Convert.ToBoolean(value),
|
||||
},
|
||||
FocasMessageKind.PmcBitWriteResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return bitResp.StatusCode;
|
||||
}
|
||||
|
||||
var resp = await _ipc.CallAsync<WriteRequest, WriteResponse>(
|
||||
FocasMessageKind.WriteRequest,
|
||||
new WriteRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Address = ToDto(address),
|
||||
DataType = (int)type,
|
||||
ValueTypeCode = (int)type,
|
||||
ValueBytes = EncodeValue(value, type),
|
||||
},
|
||||
FocasMessageKind.WriteResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return resp.StatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return false;
|
||||
try
|
||||
{
|
||||
var resp = await _ipc.CallAsync<ProbeRequest, ProbeResponse>(
|
||||
FocasMessageKind.ProbeRequest,
|
||||
new ProbeRequest { SessionId = _sessionId },
|
||||
FocasMessageKind.ProbeResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return resp.Healthy;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest,
|
||||
new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
_connected = false;
|
||||
}
|
||||
_ipc.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private static FocasAddressDto ToDto(FocasAddress addr) => new()
|
||||
{
|
||||
Kind = (int)addr.Kind,
|
||||
PmcLetter = addr.PmcLetter,
|
||||
Number = addr.Number,
|
||||
BitIndex = addr.BitIndex,
|
||||
};
|
||||
|
||||
private static byte[]? EncodeValue(object? value, FocasDataType type)
|
||||
{
|
||||
if (value is null) return null;
|
||||
return type switch
|
||||
{
|
||||
FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)),
|
||||
FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)),
|
||||
FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)),
|
||||
FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
|
||||
FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)),
|
||||
FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)),
|
||||
FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty),
|
||||
_ => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
|
||||
};
|
||||
}
|
||||
|
||||
private static object? DecodeValue(byte[]? bytes, int typeCode)
|
||||
{
|
||||
if (bytes is null) return null;
|
||||
return typeCode switch
|
||||
{
|
||||
FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize<bool>(bytes),
|
||||
FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize<byte>(bytes),
|
||||
FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize<short>(bytes),
|
||||
FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize<int>(bytes),
|
||||
FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize<float>(bytes),
|
||||
FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize<double>(bytes),
|
||||
FocasDataTypeCode.String => MessagePackSerializer.Deserialize<string>(bytes),
|
||||
_ => MessagePackSerializer.Deserialize<int>(bytes),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory producing <see cref="IpcFocasClient"/>s. One pipe connection per
|
||||
/// <c>IFocasClient</c> — matches the driver's one-client-per-device invariant. The
|
||||
/// deployment wires this into the DI container in place of
|
||||
/// <see cref="UnimplementedFocasClientFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class IpcFocasClientFactory(Func<FocasIpcClient> ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
|
||||
: IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Respawn-with-backoff schedule for the FOCAS Host process. Matches Galaxy Tier-C:
|
||||
/// 5s → 15s → 60s cap. A sustained stable run (default 2 min) resets the index so a
|
||||
/// one-off crash after hours of steady-state doesn't start from the top of the ladder.
|
||||
/// </summary>
|
||||
public sealed class Backoff
|
||||
{
|
||||
public static TimeSpan[] DefaultSequence { get; } =
|
||||
[TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
|
||||
|
||||
public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly TimeSpan[] _sequence;
|
||||
private int _index;
|
||||
|
||||
public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
|
||||
_index++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
public void RecordStableRun() => _index = 0;
|
||||
|
||||
public int AttemptIndex => _index;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Crash-loop circuit breaker for the FOCAS Host. Matches Galaxy Tier-C defaults:
|
||||
/// 3 crashes within 5 minutes opens the breaker; cooldown escalates 1h → 4h → manual
|
||||
/// reset. A sticky alert stays live until the operator explicitly clears it so
|
||||
/// recurring crashes can't silently burn through the cooldown ladder overnight.
|
||||
/// </summary>
|
||||
public sealed class CircuitBreaker
|
||||
{
|
||||
public int CrashesAllowedPerWindow { get; init; } = 3;
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan[] CooldownEscalation { get; init; } =
|
||||
[TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
|
||||
|
||||
private readonly List<DateTime> _crashesUtc = [];
|
||||
private DateTime? _openSinceUtc;
|
||||
private int _escalationLevel;
|
||||
public bool StickyAlertActive { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Records a crash + returns <c>true</c> if the supervisor may respawn. On
|
||||
/// <c>false</c>, <paramref name="cooldownRemaining"/> is how long to wait before
|
||||
/// trying again (<c>TimeSpan.MaxValue</c> means manual reset required).
|
||||
/// </summary>
|
||||
public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
|
||||
{
|
||||
if (_openSinceUtc is { } openedAt)
|
||||
{
|
||||
var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
if (cooldown == TimeSpan.MaxValue)
|
||||
{
|
||||
cooldownRemaining = TimeSpan.MaxValue;
|
||||
return false;
|
||||
}
|
||||
if (utcNow - openedAt < cooldown)
|
||||
{
|
||||
cooldownRemaining = cooldown - (utcNow - openedAt);
|
||||
return false;
|
||||
}
|
||||
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel++;
|
||||
}
|
||||
|
||||
_crashesUtc.RemoveAll(t => utcNow - t > Window);
|
||||
_crashesUtc.Add(utcNow);
|
||||
|
||||
if (_crashesUtc.Count > CrashesAllowedPerWindow)
|
||||
{
|
||||
_openSinceUtc = utcNow;
|
||||
StickyAlertActive = true;
|
||||
cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
return false;
|
||||
}
|
||||
|
||||
cooldownRemaining = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ManualReset()
|
||||
{
|
||||
_crashesUtc.Clear();
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel = 0;
|
||||
StickyAlertActive = false;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Ties <see cref="IHostProcessLauncher"/> + <see cref="Backoff"/> +
|
||||
/// <see cref="CircuitBreaker"/> + <see cref="HeartbeatMonitor"/> into one object the
|
||||
/// driver asks for <c>IFocasClient</c>s. On a detected crash (process exit or
|
||||
/// heartbeat loss) the supervisor fans out <c>BadCommunicationError</c> to all
|
||||
/// subscribers via the <see cref="OnUnavailable"/> callback, then respawns with
|
||||
/// backoff unless the breaker is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
|
||||
/// pipes, or send heartbeats. Production wires the concrete
|
||||
/// <see cref="IHostProcessLauncher"/> over <c>FocasIpcClient</c> + <c>Process</c>;
|
||||
/// tests drive the same state machine with a deterministic launcher stub.
|
||||
/// </remarks>
|
||||
public sealed class FocasHostSupervisor : IDisposable
|
||||
{
|
||||
private readonly IHostProcessLauncher _launcher;
|
||||
private readonly Backoff _backoff;
|
||||
private readonly CircuitBreaker _breaker;
|
||||
private readonly Func<DateTime> _clock;
|
||||
private IFocasClient? _current;
|
||||
private DateTime _currentStartedUtc;
|
||||
private bool _disposed;
|
||||
|
||||
public FocasHostSupervisor(
|
||||
IHostProcessLauncher launcher,
|
||||
Backoff? backoff = null,
|
||||
CircuitBreaker? breaker = null,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
_launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
|
||||
_backoff = backoff ?? new Backoff();
|
||||
_breaker = breaker ?? new CircuitBreaker();
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).</summary>
|
||||
public event Action<string>? OnUnavailable;
|
||||
|
||||
/// <summary>Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.</summary>
|
||||
public int ObservedCrashes { get; private set; }
|
||||
|
||||
/// <summary><c>true</c> if the crash-loop breaker has latched a sticky alert that needs operator reset.</summary>
|
||||
public bool StickyAlertActive => _breaker.StickyAlertActive;
|
||||
|
||||
public int BackoffAttempt => _backoff.AttemptIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current live client. If none, tries to launch — applying the
|
||||
/// backoff schedule between attempts and stopping once the breaker opens.
|
||||
/// </summary>
|
||||
public async Task<IFocasClient> GetOrLaunchAsync(CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_current is not null && _launcher.IsProcessAlive) return _current;
|
||||
|
||||
return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the heartbeat task each time a miss threshold is crossed.
|
||||
/// Treated as a crash: fan out Bad status + attempt respawn.
|
||||
/// </summary>
|
||||
public async Task NotifyHostDeadAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
OnUnavailable?.Invoke(reason);
|
||||
ObservedCrashes++;
|
||||
try { await _launcher.TerminateAsync(ct).ConfigureAwait(false); }
|
||||
catch { /* best effort */ }
|
||||
_current?.Dispose();
|
||||
_current = null;
|
||||
|
||||
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
|
||||
{
|
||||
OnUnavailable?.Invoke(cooldown == TimeSpan.MaxValue
|
||||
? "circuit-breaker-open-manual-reset-required"
|
||||
: $"circuit-breaker-open-cooldown-{cooldown:g}");
|
||||
return;
|
||||
}
|
||||
// Successful crash recording — do not respawn synchronously; GetOrLaunchAsync will
|
||||
// pick up the attempt on the next call. Keeps the fan-out fast.
|
||||
}
|
||||
|
||||
/// <summary>Operator action — clear the sticky alert + reset the breaker.</summary>
|
||||
public void AcknowledgeAndReset()
|
||||
{
|
||||
_breaker.ManualReset();
|
||||
_backoff.RecordStableRun();
|
||||
}
|
||||
|
||||
private async Task<IFocasClient> LaunchWithBackoffAsync(CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_breaker.StickyAlertActive)
|
||||
{
|
||||
if (!_breaker.TryRecordCrash(_clock(), out var cooldown) && cooldown == TimeSpan.MaxValue)
|
||||
throw new InvalidOperationException(
|
||||
"FOCAS Host circuit breaker is open and awaiting manual reset. " +
|
||||
"See Admin /hosts; call AcknowledgeAndReset after investigating the Host log.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_current = await _launcher.LaunchAsync(ct).ConfigureAwait(false);
|
||||
_currentStartedUtc = _clock();
|
||||
|
||||
// If the launch sequence itself takes long enough to count as a stable run,
|
||||
// reset the backoff ladder immediately.
|
||||
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
|
||||
_backoff.RecordStableRun();
|
||||
|
||||
return _current;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
OnUnavailable?.Invoke($"launch-failed: {ex.Message}");
|
||||
ObservedCrashes++;
|
||||
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
|
||||
{
|
||||
var hint = cooldown == TimeSpan.MaxValue
|
||||
? "manual reset required"
|
||||
: $"cooldown {cooldown:g}";
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS Host circuit breaker opened after {ObservedCrashes} crashes — {hint}.", ex);
|
||||
}
|
||||
|
||||
var delay = _backoff.Next();
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.</summary>
|
||||
public void NotifyStableRun()
|
||||
{
|
||||
if (_current is null) return;
|
||||
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
|
||||
_backoff.RecordStableRun();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { _launcher.TerminateAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch { /* best effort */ }
|
||||
_current?.Dispose();
|
||||
_current = null;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(FocasHostSupervisor));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks missed heartbeats from the FOCAS Host. 2s cadence + 3 consecutive misses =
|
||||
/// host declared dead (~6s detection). Same defaults as Galaxy Tier-C so operators
|
||||
/// see the same cadence across hosts on the /hosts Admin page.
|
||||
/// </summary>
|
||||
public sealed class HeartbeatMonitor
|
||||
{
|
||||
public int MissesUntilDead { get; init; } = 3;
|
||||
|
||||
public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
public int ConsecutiveMisses { get; private set; }
|
||||
public DateTime? LastAckUtc { get; private set; }
|
||||
|
||||
public void RecordAck(DateTime utcNow)
|
||||
{
|
||||
ConsecutiveMisses = 0;
|
||||
LastAckUtc = utcNow;
|
||||
}
|
||||
|
||||
/// <summary>Records a missed heartbeat; returns <c>true</c> when the death threshold is crossed.</summary>
|
||||
public bool RecordMiss()
|
||||
{
|
||||
ConsecutiveMisses++;
|
||||
return ConsecutiveMisses >= MissesUntilDead;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the act of spawning a FOCAS Host process and obtaining an
|
||||
/// <see cref="IFocasClient"/> connected to it. Production wires this to a real
|
||||
/// <c>Process.Start</c> + <c>FocasIpcClient.ConnectAsync</c>; tests use a fake that
|
||||
/// exposes deterministic failure modes so the supervisor logic can be stressed
|
||||
/// without spawning actual exes.
|
||||
/// </summary>
|
||||
public interface IHostProcessLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawn a new Host process (if one isn't already running) and return a live
|
||||
/// client session. Throws on unrecoverable errors; transient errors (e.g. Host
|
||||
/// not ready yet) should throw <see cref="TimeoutException"/> so the supervisor
|
||||
/// applies the backoff ladder.
|
||||
/// </summary>
|
||||
Task<IFocasClient> LaunchAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Terminate the Host process if one is running. Called on Dispose and after a
|
||||
/// heartbeat loss is detected.
|
||||
/// </summary>
|
||||
Task TerminateAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when the most recently spawned Host process is still alive.
|
||||
/// Supervisor polls this at heartbeat cadence; going <c>false</c> without a
|
||||
/// clean shutdown counts as a crash.
|
||||
/// </summary>
|
||||
bool IsProcessAlive { get; }
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor
|
||||
/// opens the file (which persists beyond the process lifetime) and enumerates the last
|
||||
/// few thousand IPC operations that were in flight. Format matches
|
||||
/// <c>Driver.FOCAS.Host.Stability.PostMortemMmf</c> — magic 'OFPC' / 256-byte entries.
|
||||
/// </summary>
|
||||
public sealed class PostMortemReader
|
||||
{
|
||||
private const int Magic = 0x4F465043; // 'OFPC'
|
||||
private const int HeaderBytes = 16;
|
||||
private const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public PostMortemReader(string path) => Path = path;
|
||||
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
if (!File.Exists(Path)) return [];
|
||||
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
|
||||
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
|
||||
|
||||
if (accessor.ReadInt32(0) != Magic) return [];
|
||||
|
||||
var capacity = accessor.ReadInt32(8);
|
||||
var writeIndex = accessor.ReadInt32(12);
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
var ts = accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue;
|
||||
var op = accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
|
||||
@@ -1,113 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IHostProcessLauncher"/>. Spawns <c>OtOpcUa.Driver.FOCAS.Host.exe</c>
|
||||
/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for
|
||||
/// the pipe to come up, then connects a <see cref="FocasIpcClient"/> and wraps it in an
|
||||
/// <see cref="IpcFocasClient"/>. On <see cref="TerminateAsync"/> best-effort kills the
|
||||
/// process and closes the IPC stream.
|
||||
/// </summary>
|
||||
public sealed class ProcessHostLauncher : IHostProcessLauncher
|
||||
{
|
||||
private readonly ProcessHostLauncherOptions _options;
|
||||
private Process? _process;
|
||||
private FocasIpcClient? _ipc;
|
||||
|
||||
public ProcessHostLauncher(ProcessHostLauncherOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool IsProcessAlive => _process is { HasExited: false };
|
||||
|
||||
public async Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
||||
{
|
||||
await TerminateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var secret = _options.SharedSecret ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _options.HostExePath,
|
||||
Arguments = _options.Arguments ?? string.Empty,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.Environment["OTOPCUA_FOCAS_PIPE"] = _options.PipeName;
|
||||
psi.Environment["OTOPCUA_ALLOWED_SID"] = _options.AllowedSid;
|
||||
psi.Environment["OTOPCUA_FOCAS_SECRET"] = secret;
|
||||
psi.Environment["OTOPCUA_FOCAS_BACKEND"] = _options.Backend;
|
||||
|
||||
_process = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"Failed to start {_options.HostExePath}");
|
||||
|
||||
// Poll for pipe readiness up to the configured connect timeout.
|
||||
var deadline = DateTime.UtcNow + _options.ConnectTimeout;
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_process.HasExited)
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS Host exited before pipe was ready (ExitCode={_process.ExitCode}).");
|
||||
|
||||
try
|
||||
{
|
||||
_ipc = await FocasIpcClient.ConnectAsync(
|
||||
_options.PipeName, secret, TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
throw new TimeoutException(
|
||||
$"FOCAS Host pipe {_options.PipeName} did not come up within {_options.ConnectTimeout:g}.");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new IpcFocasClient(_ipc, _options.Series);
|
||||
}
|
||||
|
||||
public async Task TerminateAsync(CancellationToken ct)
|
||||
{
|
||||
if (_ipc is not null)
|
||||
{
|
||||
try { await _ipc.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best effort */ }
|
||||
_ipc = null;
|
||||
}
|
||||
|
||||
if (_process is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
await _process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ProcessHostLauncherOptions(
|
||||
string HostExePath,
|
||||
string PipeName,
|
||||
string AllowedSid)
|
||||
{
|
||||
public string? SharedSecret { get; init; }
|
||||
public string? Arguments { get; init; }
|
||||
public string Backend { get; init; } = "fwlib32";
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
|
||||
}
|
||||
120
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasConstants.cs
Normal file
120
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasConstants.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Values are the FOCAS/2 wire
|
||||
/// constants passed as the <c>area</c> argument on <c>pmc_rdpmcrng</c>
|
||||
/// (G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10).
|
||||
/// </summary>
|
||||
public enum FocasPmcArea : short
|
||||
{
|
||||
G = 0,
|
||||
F = 1,
|
||||
Y = 2,
|
||||
X = 3,
|
||||
A = 4,
|
||||
R = 5,
|
||||
T = 6,
|
||||
K = 7,
|
||||
C = 8,
|
||||
D = 9,
|
||||
E = 10,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC data-type numeric codes per FOCAS/2: <c>Byte=0</c>, <c>Word=1</c>, <c>Long=2</c>,
|
||||
/// <c>Real=4</c>, <c>Double=5</c>. Passed as the <c>data_type</c> argument on
|
||||
/// <c>pmc_rdpmcrng</c>.
|
||||
/// </summary>
|
||||
public enum FocasPmcDataType : short
|
||||
{
|
||||
Byte = 0,
|
||||
Word = 1,
|
||||
Long = 2,
|
||||
Real = 4,
|
||||
Double = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CNC operation mode as reported by <c>cnc_rdopmode</c>. Values are the FOCAS-defined
|
||||
/// mode codes; see <see cref="FocasOperationModeExtensions.ToText"/> for the canonical
|
||||
/// operator-facing labels.
|
||||
/// </summary>
|
||||
public enum FocasOperationMode : short
|
||||
{
|
||||
Mdi = 0,
|
||||
Auto = 1,
|
||||
TJog = 2,
|
||||
Edit = 3,
|
||||
Handle = 4,
|
||||
Jog = 5,
|
||||
TeachInHandle = 6,
|
||||
Reference = 7,
|
||||
Remote = 8,
|
||||
Test = 9,
|
||||
}
|
||||
|
||||
/// <summary>Extension helpers over <see cref="FocasOperationMode"/>.</summary>
|
||||
public static class FocasOperationModeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
|
||||
/// <c>"EDIT"</c>). Unknown codes fall back to the raw numeric value as a string
|
||||
/// so the UI still shows something interpretable.
|
||||
/// </summary>
|
||||
public static string ToText(this FocasOperationMode mode) => mode switch
|
||||
{
|
||||
FocasOperationMode.Mdi => "MDI",
|
||||
FocasOperationMode.Auto => "AUTO",
|
||||
FocasOperationMode.TJog => "T-JOG",
|
||||
FocasOperationMode.Edit => "EDIT",
|
||||
FocasOperationMode.Handle => "HANDLE",
|
||||
FocasOperationMode.Jog => "JOG",
|
||||
FocasOperationMode.TeachInHandle => "TEACH-IN-HANDLE",
|
||||
FocasOperationMode.Reference => "REFERENCE",
|
||||
FocasOperationMode.Remote => "REMOTE",
|
||||
FocasOperationMode.Test => "TEST",
|
||||
_ => ((short)mode).ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Letter → <see cref="FocasPmcArea"/> lookup. Used by <see cref="WireFocasClient"/> to
|
||||
/// translate a parsed <see cref="FocasAddress.PmcLetter"/> into the wire code expected by
|
||||
/// <c>pmc_rdpmcrng</c>.
|
||||
/// </summary>
|
||||
internal static class FocasPmcAreaLookup
|
||||
{
|
||||
public static FocasPmcArea? FromLetter(string letter) => letter.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => FocasPmcArea.G,
|
||||
"F" => FocasPmcArea.F,
|
||||
"Y" => FocasPmcArea.Y,
|
||||
"X" => FocasPmcArea.X,
|
||||
"A" => FocasPmcArea.A,
|
||||
"R" => FocasPmcArea.R,
|
||||
"T" => FocasPmcArea.T,
|
||||
"K" => FocasPmcArea.K,
|
||||
"C" => FocasPmcArea.C,
|
||||
"D" => FocasPmcArea.D,
|
||||
"E" => FocasPmcArea.E,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="FocasDataType"/> → <see cref="FocasPmcDataType"/> mapping for wire PMC
|
||||
/// reads. Bit reads collapse to byte — the caller extracts the bit from the returned
|
||||
/// value.
|
||||
/// </summary>
|
||||
internal static class FocasPmcDataTypeLookup
|
||||
{
|
||||
public static FocasPmcDataType FromFocasDataType(FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => FocasPmcDataType.Byte,
|
||||
FocasDataType.Int16 => FocasPmcDataType.Word,
|
||||
FocasDataType.Int32 => FocasPmcDataType.Long,
|
||||
FocasDataType.Float32 => FocasPmcDataType.Real,
|
||||
FocasDataType.Float64 => FocasPmcDataType.Double,
|
||||
_ => FocasPmcDataType.Byte,
|
||||
};
|
||||
}
|
||||
883
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
Normal file
883
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
Normal file
@@ -0,0 +1,883 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-managed read-only FOCAS/2 Ethernet wire client. Speaks the proprietary Fanuc
|
||||
/// binary protocol on TCP:8193 directly — no P/Invoke, no <c>Fwlib64.dll</c>, no
|
||||
/// out-of-process Host. One instance owns two TCP sockets for the duration of a CNC
|
||||
/// session; <see cref="ConnectAsync(string, int, int, CancellationToken)"/> runs the
|
||||
/// two-socket initiate handshake and a setup request, subsequent reads reuse
|
||||
/// <c>socket 2</c> serialised through an internal semaphore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Read surface.</b> Covers every FOCAS call OtOpcUa's managed driver issues:
|
||||
/// sysinfo, status, axis + spindle names, the <c>cnc_rddynamic2</c> fast-poll bundle,
|
||||
/// parameters (typed + raw-bytes overloads), macros, PMC ranges, alarms, operation mode,
|
||||
/// executing program, block count, timers, and servo / spindle meters. Writes are
|
||||
/// intentionally out of scope.</para>
|
||||
/// <para><b>Concurrency.</b> Callers may issue reads concurrently from multiple threads
|
||||
/// — <c>socket 2</c> is guarded by a <see cref="SemaphoreSlim"/> so at most one
|
||||
/// request/response pair is in flight at a time. <see cref="ConnectAsync(string, int, int, CancellationToken)"/>
|
||||
/// and <see cref="DisposeAsync"/> share a second semaphore to stop the two racing.</para>
|
||||
/// <para><b>Transient failures.</b> When cancellation or a socket-level error happens
|
||||
/// mid-request the client closes both sockets and throws
|
||||
/// <see cref="FocasWireException"/> with <see cref="FocasWireException.IsTransient"/>
|
||||
/// set — the caller must reconnect before issuing the next request. The transport is
|
||||
/// left deliberately torn down rather than half-open so a truncated response never
|
||||
/// desynchronises the next caller's read.</para>
|
||||
/// </remarks>
|
||||
public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private readonly ILogger<FocasWireClient>? _logger;
|
||||
private readonly SemaphoreSlim _requestGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _lifetimeGate = new(1, 1);
|
||||
private TcpClient? _socket1;
|
||||
private TcpClient? _socket2;
|
||||
private NetworkStream? _stream1;
|
||||
private NetworkStream? _stream2;
|
||||
private bool _connected;
|
||||
private bool _disposed;
|
||||
private FocasResult<WireSysInfo>? _sysInfo;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a disconnected client. Optional <paramref name="logger"/> receives
|
||||
/// <c>Debug</c>-level entries per response block (command ID, RC, payload length).
|
||||
/// </summary>
|
||||
public FocasWireClient(ILogger<FocasWireClient>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <c>PathId</c> applied when no per-call override is supplied. Relevant for
|
||||
/// multi-path CNCs; single-path controllers leave this at the default of <c>1</c>.
|
||||
/// </summary>
|
||||
public ushort PathId { get; set; } = 1;
|
||||
|
||||
/// <summary>True when the two-socket handshake has completed and the transport is live.</summary>
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
/// <summary>
|
||||
/// Open the FOCAS session using an integer-seconds timeout. Idempotent — a second
|
||||
/// call while already connected is a no-op. Sub-second timeouts require the
|
||||
/// <see cref="ConnectAsync(string, int, TimeSpan, CancellationToken)"/> overload.
|
||||
/// </summary>
|
||||
public Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
int timeoutSeconds = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ConnectCoreAsync(
|
||||
host,
|
||||
port,
|
||||
timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open the FOCAS session with a <see cref="TimeSpan"/> timeout. Pass
|
||||
/// <see cref="TimeSpan.Zero"/> to disable the timeout entirely (rely on the caller's
|
||||
/// <paramref name="cancellationToken"/> instead). Idempotent.
|
||||
/// </summary>
|
||||
public Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ConnectCoreAsync(host, port, timeout == TimeSpan.Zero ? null : timeout, cancellationToken);
|
||||
|
||||
private async Task ConnectCoreAsync(
|
||||
string host,
|
||||
int port,
|
||||
TimeSpan? timeoutValue,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _lifetimeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_connected) return;
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if (timeoutValue is { } value) timeout.CancelAfter(value);
|
||||
|
||||
try
|
||||
{
|
||||
_socket1 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
|
||||
_stream1 = _socket1.GetStream();
|
||||
await SendPduAsync(_stream1, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(1), timeout.Token).ConfigureAwait(false);
|
||||
await ReadExpectedPduAsync(_stream1, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
|
||||
|
||||
_socket2 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
|
||||
_stream2 = _socket2.GetStream();
|
||||
await SendPduAsync(_stream2, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(2), timeout.Token).ConfigureAwait(false);
|
||||
await ReadExpectedPduAsync(_stream2, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
|
||||
|
||||
_connected = true;
|
||||
// Cache the sysinfo payload from the setup exchange so later
|
||||
// ReadSysInfoAsync calls are a lookup rather than a wire hit.
|
||||
var sysInfoBlock = await SendSingleRequestAsync(timeout.Token, new RequestBlock(0x0018, PathId: PathId)).ConfigureAwait(false);
|
||||
_sysInfo = ToResult(sysInfoBlock, ParseSysInfo);
|
||||
// Kick the cached path/session metadata request the DLL sends
|
||||
// right after initiate. The result is ignored; the CNC uses it to
|
||||
// populate internal state the subsequent reads depend on.
|
||||
await SendRequestAsync(timeout.Token, new RequestBlock(0x000e, 0x26f0, 0x26f0, PathId: PathId)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (IsTransientException(ex))
|
||||
{
|
||||
CloseTransport();
|
||||
throw new FocasWireException("FOCAS wire connect failed.", ex, isTransient: true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifetimeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous dispose — sends the close PDU when connected and tears down both
|
||||
/// sockets. Idempotent. Callers on an async context should prefer
|
||||
/// <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_lifetimeGate.Wait();
|
||||
try
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_disposed = true;
|
||||
if (_stream2 is not null && _connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendPdu(_stream2, FocasWireProtocol.TypeClose, ReadOnlySpan<byte>.Empty);
|
||||
_ = FocasWireProtocol.ReadPdu(_stream2);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Close best-effort — don't let teardown failure hide a caller's real error.
|
||||
}
|
||||
}
|
||||
CloseTransport();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifetimeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async dispose — sends the close PDU when connected and tears down both sockets.
|
||||
/// Idempotent.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _lifetimeGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_disposed = true;
|
||||
if (_stream2 is not null && _connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendPduAsync(_stream2, FocasWireProtocol.TypeClose, ReadOnlyMemory<byte>.Empty, CancellationToken.None).ConfigureAwait(false);
|
||||
await FocasWireProtocol.ReadPduAsync(_stream2, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Close best-effort — don't let teardown failure hide a caller's real error.
|
||||
}
|
||||
}
|
||||
CloseTransport();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifetimeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read CNC identity via <c>cnc_sysinfo</c>. Cached from the connect-time exchange
|
||||
/// unless a per-call <paramref name="pathId"/> override is supplied.
|
||||
/// </summary>
|
||||
public async Task<FocasResult<WireSysInfo>> ReadSysInfoAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
if (pathId is null && _sysInfo is { } cached) return cached;
|
||||
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
return await ReadSingleAsync(0x0018, ParseSysInfo, EffectivePathId(pathId), cancellationToken: callTimeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Read CNC status bits via <c>cnc_statinfo</c> (3 command blocks aggregated into one <see cref="WireStatus"/>).</summary>
|
||||
public async Task<FocasResult<WireStatus>> ReadStatusAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var requestPathId = EffectivePathId(pathId);
|
||||
var blocks = await SendRequestAsync(
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x0019, PathId: requestPathId),
|
||||
new RequestBlock(0x00e1, PathId: requestPathId),
|
||||
new RequestBlock(0x0098, PathId: requestPathId)).ConfigureAwait(false);
|
||||
|
||||
var rc = AggregateRc(blocks);
|
||||
if (rc != 0) return new FocasResult<WireStatus>(rc, null);
|
||||
|
||||
var primary = FindPayload(blocks, 0x0019);
|
||||
RequireLength(primary, 14, "cnc_statinfo");
|
||||
var tmModePayload = FindPayload(blocks, 0x0098);
|
||||
var tmMode = tmModePayload.Length >= 2 ? ReadInt16(tmModePayload, 0) : (short)0;
|
||||
|
||||
return new FocasResult<WireStatus>(
|
||||
rc,
|
||||
new WireStatus(
|
||||
Auto: ReadInt16(primary, 0),
|
||||
Run: ReadInt16(primary, 2),
|
||||
Motion: ReadInt16(primary, 4),
|
||||
Mstb: ReadInt16(primary, 6),
|
||||
Emergency: ReadInt16(primary, 8),
|
||||
Alarm: ReadInt16(primary, 10),
|
||||
Edit: ReadInt16(primary, 12),
|
||||
TmMode: tmMode));
|
||||
}
|
||||
|
||||
/// <summary>Read configured axis names via <c>cnc_rdaxisname</c> (command <c>0x0089</c>).</summary>
|
||||
public async Task<FocasResult<IReadOnlyList<WireAxisRecord>>> ReadAxisNamesAsync(
|
||||
short maxCount = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x0089, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireAxisRecord(index, name)));
|
||||
}
|
||||
|
||||
/// <summary>Read configured spindle names via <c>cnc_rdspdlname</c> (command <c>0x008a</c>).</summary>
|
||||
public async Task<FocasResult<IReadOnlyList<WireSpindleRecord>>> ReadSpindleNamesAsync(
|
||||
short maxCount = 8,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x008a, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireSpindleRecord(index, name)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis via <c>cnc_rddynamic2</c>. Sends 9 request blocks in
|
||||
/// one PDU and aggregates the replies — alarm flags, program/sequence numbers, feed
|
||||
/// and spindle actuals, plus the four-slot position quadruple.
|
||||
/// </summary>
|
||||
public async Task<FocasResult<WireDynamic>> ReadDynamic2Async(
|
||||
short axis = 1,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var requestPathId = EffectivePathId(pathId);
|
||||
var blocks = await SendRequestAsync(
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x001a, PathId: requestPathId),
|
||||
new RequestBlock(0x001c, PathId: requestPathId),
|
||||
new RequestBlock(0x001d, PathId: requestPathId),
|
||||
new RequestBlock(0x0024, PathId: requestPathId),
|
||||
new RequestBlock(0x0025, PathId: requestPathId),
|
||||
new RequestBlock(0x0026, 4, axis, PathId: requestPathId),
|
||||
new RequestBlock(0x0026, 1, axis, PathId: requestPathId),
|
||||
new RequestBlock(0x0026, 6, axis, PathId: requestPathId),
|
||||
new RequestBlock(0x0026, 7, axis, PathId: requestPathId)).ConfigureAwait(false);
|
||||
|
||||
var rc = AggregateRc(blocks);
|
||||
if (rc != 0) return new FocasResult<WireDynamic>(rc, null);
|
||||
|
||||
var programPayload = FindPayload(blocks, 0x001c);
|
||||
return new FocasResult<WireDynamic>(
|
||||
rc,
|
||||
new WireDynamic(
|
||||
ReadFirstInt32(blocks, 0x001a),
|
||||
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
|
||||
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
|
||||
ReadFirstInt32(blocks, 0x001d),
|
||||
ReadFirstInt32(blocks, 0x0024),
|
||||
ReadFirstInt32(blocks, 0x0025),
|
||||
new WireAxisPosition(
|
||||
ReadSelectorPosition(blocks, 0x0026, 0),
|
||||
ReadSelectorPosition(blocks, 0x0026, 1),
|
||||
ReadSelectorPosition(blocks, 0x0026, 2),
|
||||
ReadSelectorPosition(blocks, 0x0026, 3))));
|
||||
}
|
||||
|
||||
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
|
||||
public async Task<FocasResult<IReadOnlyList<WireServoMeter>>> ReadServoMeterAsync(
|
||||
short maxCount = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var requestPathId = EffectivePathId(pathId);
|
||||
var blocks = await SendRequestAsync(
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x0056, 1, PathId: requestPathId),
|
||||
new RequestBlock(0x0089, PathId: requestPathId)).ConfigureAwait(false);
|
||||
|
||||
var rc = AggregateRc(blocks);
|
||||
if (rc != 0) return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, null);
|
||||
|
||||
var payload = FindPayload(blocks, 0x0056);
|
||||
var result = new List<WireServoMeter>();
|
||||
for (var offset = 0; offset + 12 <= payload.Length && result.Count < maxCount; offset += 12)
|
||||
{
|
||||
var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset + 8, 4));
|
||||
result.Add(new WireServoMeter(
|
||||
(short)(result.Count + 1),
|
||||
name,
|
||||
ReadInt32(payload, offset),
|
||||
ReadInt16(payload, offset + 4),
|
||||
ReadInt16(payload, offset + 6)));
|
||||
}
|
||||
|
||||
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
|
||||
}
|
||||
|
||||
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
|
||||
|
||||
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSpindleMetricAsync(1, spindleSelector, cancellationToken, timeout, pathId);
|
||||
|
||||
/// <summary>
|
||||
/// Raw-bytes parameter read via <c>cnc_rdparam</c>. Caller marshals the returned
|
||||
/// payload to the type declared in the per-series parameter catalog. <paramref name="axis"/>
|
||||
/// selects an axis-scoped parameter; <c>0</c> means global.
|
||||
/// </summary>
|
||||
public async Task<FocasResult<byte[]>> ReadParameterBytesAsync(
|
||||
short dataNumber,
|
||||
short axis = 0,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var secondArg = axis == 0 ? dataNumber : axis;
|
||||
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
return ToResult(block, payload => payload);
|
||||
}
|
||||
|
||||
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
|
||||
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
|
||||
short dataNumber,
|
||||
short type = 0,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, cancellationToken: cancellationToken, timeout: timeout, pathId: pathId).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return new FocasResult<WireParameter>(result.Rc, null);
|
||||
return new FocasResult<WireParameter>(
|
||||
result.Rc,
|
||||
new WireParameter(dataNumber, type, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : 0));
|
||||
}
|
||||
|
||||
/// <summary>Typed 8-bit parameter read.</summary>
|
||||
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
return !result.IsOk || result.Value is null
|
||||
? new FocasResult<byte>(result.Rc, default)
|
||||
: new FocasResult<byte>(result.Rc, result.Value.Length >= 1 ? result.Value[0] : default);
|
||||
}
|
||||
|
||||
/// <summary>Typed 16-bit parameter read.</summary>
|
||||
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
return !result.IsOk || result.Value is null
|
||||
? new FocasResult<short>(result.Rc, default)
|
||||
: new FocasResult<short>(result.Rc, result.Value.Length >= 2 ? ReadInt16(result.Value, 0) : default);
|
||||
}
|
||||
|
||||
/// <summary>Typed 32-bit parameter read.</summary>
|
||||
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
return !result.IsOk || result.Value is null
|
||||
? new FocasResult<int>(result.Rc, default)
|
||||
: new FocasResult<int>(result.Rc, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : default);
|
||||
}
|
||||
|
||||
/// <summary>Typed IEEE-754 single-precision parameter read.</summary>
|
||||
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
return !result.IsOk || result.Value is null || result.Value.Length < 4
|
||||
? new FocasResult<float>(result.Rc, default)
|
||||
: new FocasResult<float>(result.Rc, BitConverter.Int32BitsToSingle(ReadInt32(result.Value, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Typed IEEE-754 double-precision parameter read.</summary>
|
||||
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
return !result.IsOk || result.Value is null || result.Value.Length < 8
|
||||
? new FocasResult<double>(result.Rc, default)
|
||||
: new FocasResult<double>(result.Rc, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(result.Value.AsSpan(0, 8))));
|
||||
}
|
||||
|
||||
/// <summary>Read a single macro variable via <c>cnc_rdmacro</c> (command <c>0x0015</c>).</summary>
|
||||
public Task<FocasResult<WireMacro>> ReadMacroAsync(
|
||||
short number,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(
|
||||
0x0015,
|
||||
payload => new WireMacro(number, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 6 ? ReadInt16(payload, 4) : (short)0),
|
||||
cancellationToken, timeout, EffectivePathId(pathId), number, number);
|
||||
|
||||
/// <summary>
|
||||
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
|
||||
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
|
||||
/// is the width code (see <see cref="FocasPmcDataType"/>). Payload is decoded into
|
||||
/// <see cref="WirePmcRange.Values"/> — one entry per slot of the requested width.
|
||||
/// </summary>
|
||||
public async Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||||
short area,
|
||||
short dataType,
|
||||
ushort start,
|
||||
ushort end,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
if (end < start)
|
||||
throw new ArgumentOutOfRangeException(nameof(end), "PMC end address must be greater than or equal to start.");
|
||||
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var block = await SendSingleRequestAsync(
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
|
||||
return ToResult(block, payload =>
|
||||
{
|
||||
var width = dataType switch
|
||||
{
|
||||
1 => 2,
|
||||
2 or 4 => 4,
|
||||
5 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
var values = new List<long>();
|
||||
for (var offset = 0; offset + width <= payload.Length; offset += width)
|
||||
{
|
||||
values.Add(width switch
|
||||
{
|
||||
1 => payload[offset],
|
||||
2 => ReadInt16(payload, offset),
|
||||
4 => ReadInt32(payload, offset),
|
||||
8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)),
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
|
||||
return new WirePmcRange(area, dataType, start, end, values);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
|
||||
public Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||||
FocasPmcArea area,
|
||||
FocasPmcDataType dataType,
|
||||
ushort start,
|
||||
ushort end,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadPmcRangeAsync((short)area, (short)dataType, start, end, cancellationToken, timeout, pathId);
|
||||
|
||||
/// <summary>
|
||||
/// Read active alarms via <c>cnc_rdalmmsg2</c> (command <c>0x0023</c>). Parses both
|
||||
/// the 76-byte vendor <c>ODBALMMSG2_data</c> layout and the 80-byte legacy wire
|
||||
/// shape so the same managed surface works across firmware revisions.
|
||||
/// </summary>
|
||||
public async Task<FocasResult<IReadOnlyList<WireAlarm>>> ReadAlarmsAsync(
|
||||
short type = -1,
|
||||
short count = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var block = await SendSingleRequestAsync(
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x0023, type, count, 2, 0x40, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
|
||||
return ToResult(block, payload => ParseAlarms(payload, count));
|
||||
}
|
||||
|
||||
/// <summary>Read operation mode via <c>cnc_rdopmode</c>, returned as the typed <see cref="FocasOperationMode"/>.</summary>
|
||||
public Task<FocasResult<FocasOperationMode>> ReadOperationModeAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(
|
||||
0x0057,
|
||||
payload => (FocasOperationMode)(payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0),
|
||||
cancellationToken, timeout, EffectivePathId(pathId));
|
||||
|
||||
/// <summary>
|
||||
/// Raw-code variant of <see cref="ReadOperationModeAsync"/> — returns the underlying
|
||||
/// FOCAS <c>short</c> so callers storing the raw mode code (e.g. OtOpcUa's
|
||||
/// <c>FocasProgramInfo.Mode</c> int field) don't have to cast the enum.
|
||||
/// </summary>
|
||||
public Task<FocasResult<short>> ReadOperationModeCodeAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(
|
||||
0x0057,
|
||||
payload => payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0,
|
||||
cancellationToken, timeout, EffectivePathId(pathId));
|
||||
|
||||
/// <summary>Read the currently-executing program name + O-number via <c>cnc_exeprgname2</c> (command <c>0x00fc</c>).</summary>
|
||||
public Task<FocasResult<WireProgramName>> ReadExecutingProgramNameAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId));
|
||||
|
||||
/// <summary>Read the executed block count via <c>cnc_rdblkcount</c>.</summary>
|
||||
public Task<FocasResult<int>> ReadBlockCountAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(
|
||||
0x0035,
|
||||
payload => payload.Length >= 4 ? ReadInt32(payload, 0) : 0,
|
||||
cancellationToken, timeout, EffectivePathId(pathId));
|
||||
|
||||
/// <summary>
|
||||
/// Read one cumulative timer via <c>cnc_rdtimer</c>. <paramref name="type"/> selects
|
||||
/// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3).
|
||||
/// </summary>
|
||||
public Task<FocasResult<WireTimer>> ReadTimerAsync(
|
||||
short type,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(
|
||||
0x0120,
|
||||
payload => new WireTimer(type, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 8 ? ReadInt32(payload, 4) : 0),
|
||||
cancellationToken, timeout, EffectivePathId(pathId), type);
|
||||
|
||||
// ---- internal plumbing ------------------------------------------------------------
|
||||
|
||||
private async Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMetricAsync(
|
||||
int metric, short spindleSelector, CancellationToken cancellationToken, TimeSpan? timeout, ushort? pathId)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
var block = await SendSingleRequestAsync(
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
|
||||
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
|
||||
{
|
||||
var values = new List<WireSpindleMetric>();
|
||||
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
|
||||
values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<FocasResult<T>> ReadSingleAsync<T>(
|
||||
ushort command,
|
||||
Func<byte[], T> parser,
|
||||
ushort? pathId = null,
|
||||
int arg1 = 0,
|
||||
int arg2 = 0,
|
||||
int arg3 = 0,
|
||||
int arg4 = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var block = await SendSingleRequestAsync(cancellationToken, new RequestBlock(command, arg1, arg2, arg3, arg4, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
return ToResult(block, parser);
|
||||
}
|
||||
|
||||
private async Task<FocasResult<T>> ReadSingleWithTimeoutAsync<T>(
|
||||
ushort command,
|
||||
Func<byte[], T> parser,
|
||||
CancellationToken cancellationToken,
|
||||
TimeSpan? timeout,
|
||||
ushort pathId,
|
||||
int arg1 = 0,
|
||||
int arg2 = 0,
|
||||
int arg3 = 0,
|
||||
int arg4 = 0)
|
||||
{
|
||||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||||
return await ReadSingleAsync(command, parser, pathId, arg1, arg2, arg3, arg4, callTimeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ResponseBlock> SendSingleRequestAsync(CancellationToken cancellationToken, RequestBlock block)
|
||||
{
|
||||
var blocks = await SendRequestAsync(cancellationToken, block).ConfigureAwait(false);
|
||||
return blocks.Count == 0 ? new ResponseBlock(block.Command, 0, Array.Empty<byte>()) : blocks[0];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ResponseBlock>> SendRequestAsync(CancellationToken cancellationToken, params RequestBlock[] blocks)
|
||||
{
|
||||
EnsureConnected();
|
||||
await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var requestStarted = false;
|
||||
try
|
||||
{
|
||||
var body = FocasWireProtocol.BuildRequestBody(blocks);
|
||||
requestStarted = true;
|
||||
await SendPduAsync(_stream2!, FocasWireProtocol.TypeData, body, cancellationToken).ConfigureAwait(false);
|
||||
var response = await ReadExpectedPduAsync(_stream2!, FocasWireProtocol.TypeData, cancellationToken).ConfigureAwait(false);
|
||||
var responseBlocks = FocasWireProtocol.ParseResponseBlocks(response.Body);
|
||||
foreach (var block in responseBlocks)
|
||||
_logger?.LogDebug("FOCAS response command=0x{Command:x4} rc={Rc} payloadLength={PayloadLength}", block.Command, block.Rc, block.Payload.Length);
|
||||
return responseBlocks;
|
||||
}
|
||||
catch (Exception ex) when (requestStarted && IsTransientException(ex))
|
||||
{
|
||||
// A cancelled or failed mid-request write leaves the wire in an undefined state —
|
||||
// tear the connection down so the next caller reconnects cleanly instead of
|
||||
// consuming a stale response.
|
||||
CloseTransport();
|
||||
throw new FocasWireException("FOCAS wire request failed; connection was closed to avoid response desynchronization.", ex, isTransient: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TcpClient> ConnectSocketAsync(string host, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new TcpClient { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await WithCancellation(socket.ConnectAsync(host, port), cancellationToken).ConfigureAwait(false);
|
||||
return socket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendPduAsync(NetworkStream stream, byte type, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
|
||||
{
|
||||
var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body.Span);
|
||||
await stream.WriteAsync(pdu, 0, pdu.Length, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void SendPdu(NetworkStream stream, byte type, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body);
|
||||
stream.Write(pdu, 0, pdu.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(FocasWireClient));
|
||||
}
|
||||
|
||||
private static async Task WithCancellation(Task task, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!cancellationToken.CanBeCanceled)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellation = new TaskCompletionSource<bool>();
|
||||
using var registration = cancellationToken.Register(static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancellation);
|
||||
if (task != await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false))
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static CancellationTokenSource CreateCallTimeout(CancellationToken cancellationToken, TimeSpan? timeout)
|
||||
{
|
||||
var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if (timeout is { } value) source.CancelAfter(value);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<Pdu> ReadExpectedPduAsync(NetworkStream stream, byte expectedType, CancellationToken cancellationToken)
|
||||
{
|
||||
var pdu = await FocasWireProtocol.ReadPduAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
if (pdu.Type != expectedType || pdu.Direction != FocasWireProtocol.DirectionResponse)
|
||||
throw new FocasWireException($"Unexpected FOCAS PDU type 0x{pdu.Type:x2}, direction 0x{pdu.Direction:x2}.", rc: null);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (!_connected || _stream2 is null)
|
||||
throw new FocasWireException("FOCAS wire client is not connected.", rc: null, isTransient: true);
|
||||
}
|
||||
|
||||
private void CloseTransport()
|
||||
{
|
||||
_connected = false;
|
||||
_sysInfo = null;
|
||||
_stream1?.Dispose();
|
||||
_stream2?.Dispose();
|
||||
_socket1?.Dispose();
|
||||
_socket2?.Dispose();
|
||||
_stream1 = null;
|
||||
_stream2 = null;
|
||||
_socket1 = null;
|
||||
_socket2 = null;
|
||||
}
|
||||
|
||||
private ushort EffectivePathId(ushort? pathId) => pathId ?? PathId;
|
||||
|
||||
private static FocasResult<T> ToResult<T>(ResponseBlock block, Func<byte[], T> parser)
|
||||
=> block.Rc != 0
|
||||
? new FocasResult<T>(block.Rc, default)
|
||||
: new FocasResult<T>(block.Rc, parser(block.Payload));
|
||||
|
||||
private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks)
|
||||
=> blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
|
||||
|
||||
private static byte[] FindPayload(IReadOnlyList<ResponseBlock> blocks, ushort command)
|
||||
=> blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>();
|
||||
|
||||
private static int ReadFirstInt32(IReadOnlyList<ResponseBlock> blocks, ushort command)
|
||||
{
|
||||
var payload = FindPayload(blocks, command);
|
||||
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
|
||||
}
|
||||
|
||||
private static int ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
|
||||
{
|
||||
var seen = 0;
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
if (block.Command != command) continue;
|
||||
if (seen == selectorIndex)
|
||||
return block.Payload.Length >= 4 ? ReadInt32(block.Payload, 0) : 0;
|
||||
seen++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static WireSysInfo ParseSysInfo(byte[] payload)
|
||||
{
|
||||
RequireLength(payload, 16, "cnc_sysinfo");
|
||||
return new WireSysInfo(
|
||||
ReadInt16(payload, 0),
|
||||
ReadInt16(payload, 2),
|
||||
FocasWireProtocol.ReadAscii(payload.AsSpan(4, 2)),
|
||||
FocasWireProtocol.ReadAscii(payload.AsSpan(6, 2)),
|
||||
FocasWireProtocol.ReadAscii(payload.AsSpan(8, 4)),
|
||||
FocasWireProtocol.ReadAscii(payload.AsSpan(12, 4)),
|
||||
payload.Length >= 18 ? FocasWireProtocol.ReadAscii(payload.AsSpan(16, 2)) : string.Empty);
|
||||
}
|
||||
|
||||
private static WireProgramName ParseProgramName(byte[] payload)
|
||||
{
|
||||
var nameLength = payload.Length >= 40 ? 36 : payload.Length;
|
||||
var name = FocasWireProtocol.ReadAscii(payload.AsSpan(0, nameLength));
|
||||
var number = payload.Length >= 40 ? ReadInt32(payload, 36) : (int?)null;
|
||||
return new WireProgramName(name, number);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
|
||||
=> payload.Length % 76 == 0
|
||||
? ParseVendorAlarms(payload, count)
|
||||
: ParseLegacyWireAlarms(payload, count);
|
||||
|
||||
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
|
||||
{
|
||||
var alarms = new List<WireAlarm>();
|
||||
for (var offset = 0; offset + 76 <= payload.Length && alarms.Count < count; offset += 76)
|
||||
{
|
||||
var messageLength = ReadInt16(payload, offset + 10);
|
||||
alarms.Add(new WireAlarm(
|
||||
ReadInt32(payload, offset),
|
||||
ReadInt16(payload, offset + 4),
|
||||
ReadInt16(payload, offset + 6),
|
||||
messageLength,
|
||||
FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 12, 64))));
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WireAlarm> ParseLegacyWireAlarms(byte[] payload, short count)
|
||||
{
|
||||
var alarms = new List<WireAlarm>();
|
||||
for (var offset = 0; offset + 80 <= payload.Length && alarms.Count < count; offset += 80)
|
||||
{
|
||||
alarms.Add(new WireAlarm(
|
||||
ReadInt32(payload, offset),
|
||||
(short)ReadInt32(payload, offset + 4),
|
||||
(short)ReadInt32(payload, offset + 8),
|
||||
(short)ReadInt32(payload, offset + 12),
|
||||
FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 16, 64))));
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
|
||||
{
|
||||
var names = new List<T>();
|
||||
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
|
||||
{
|
||||
var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset, 4));
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
names.Add(factory((short)((offset / 4) + 1), name));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static void RequireLength(byte[] payload, int length, string call)
|
||||
{
|
||||
if (payload.Length < length)
|
||||
throw new FocasWireException($"{call} returned {payload.Length} bytes; expected at least {length}.", rc: null);
|
||||
}
|
||||
|
||||
private static bool IsTransientException(Exception exception)
|
||||
=> exception is IOException or SocketException or TimeoutException or OperationCanceledException
|
||||
|| exception.InnerException is IOException or SocketException or TimeoutException or OperationCanceledException;
|
||||
|
||||
private static short ReadInt16(byte[] bytes, int offset)
|
||||
=> BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(offset, 2));
|
||||
|
||||
private static int ReadInt32(byte[] bytes, int offset)
|
||||
=> BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(offset, 4));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by the wire client when a FOCAS request fails — either at the protocol layer
|
||||
/// (invalid PDU magic, desynchronised response framing, connection dropped mid-request)
|
||||
/// or when the CNC returns a non-zero <c>EW_*</c> return code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Callers distinguish the two classes via <see cref="IsTransient"/>: <c>true</c>
|
||||
/// when the transport is gone (socket closed, timeout, cancellation mid-write) and the
|
||||
/// wire client has already torn the sockets down, so a reconnect is required before any
|
||||
/// further call. <c>false</c> for protocol-level errors where the connection is still
|
||||
/// usable.</para>
|
||||
/// <para><see cref="Rc"/> carries the wire-level FOCAS return code when the exception
|
||||
/// came from a parsed response block. Null when the failure happened before a response
|
||||
/// was received (e.g. connect-time handshake errors).</para>
|
||||
/// </remarks>
|
||||
public class FocasWireException : Exception
|
||||
{
|
||||
/// <summary>FOCAS <c>EW_*</c> return code from the response block, when available.</summary>
|
||||
public short? Rc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the transport was closed as a side effect of this failure — the caller
|
||||
/// must reconnect before issuing the next request.
|
||||
/// </summary>
|
||||
public bool IsTransient { get; }
|
||||
|
||||
public FocasWireException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public FocasWireException(string message, short? rc, bool isTransient = false)
|
||||
: base(message)
|
||||
{
|
||||
Rc = rc;
|
||||
IsTransient = isTransient;
|
||||
}
|
||||
|
||||
public FocasWireException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
public FocasWireException(string message, Exception innerException, bool isTransient)
|
||||
: base(message, innerException)
|
||||
{
|
||||
IsTransient = isTransient;
|
||||
}
|
||||
}
|
||||
131
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
Normal file
131
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Return envelope over a parsed wire response. <see cref="Rc"/> carries the FOCAS
|
||||
/// <c>EW_*</c> code from the response block — <c>0</c> / <see cref="IsOk"/> means the
|
||||
/// call succeeded and <see cref="Value"/> is populated; non-zero means the CNC rejected
|
||||
/// the call and <see cref="Value"/> is <c>default</c>. Callers use the RC to distinguish
|
||||
/// "feature missing on this series" (<c>EW_FUNC</c> / <c>EW_NOOPT</c>) from genuine
|
||||
/// failures.
|
||||
/// </summary>
|
||||
public readonly record struct FocasResult<T>(short Rc, T? Value)
|
||||
{
|
||||
/// <summary>True when <see cref="Rc"/> is zero (<c>EW_OK</c>).</summary>
|
||||
public bool IsOk => Rc == 0;
|
||||
}
|
||||
|
||||
/// <summary>CNC identity payload returned by <c>cnc_sysinfo</c>.</summary>
|
||||
public sealed record WireSysInfo(
|
||||
short AddInfo,
|
||||
short MaxAxis,
|
||||
string CncType,
|
||||
string MachineType,
|
||||
string Series,
|
||||
string Version,
|
||||
string Axes);
|
||||
|
||||
/// <summary>Coarse CNC state bits returned by <c>cnc_statinfo</c> — the seven-word status block plus TM mode.</summary>
|
||||
public sealed record WireStatus(
|
||||
short Auto,
|
||||
short Run,
|
||||
short Motion,
|
||||
short Mstb,
|
||||
short Emergency,
|
||||
short Alarm,
|
||||
short Edit,
|
||||
short TmMode);
|
||||
|
||||
/// <summary>Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.</summary>
|
||||
public sealed record WireAxisPosition(
|
||||
int Absolute,
|
||||
int Machine,
|
||||
int Relative,
|
||||
int Distance);
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
|
||||
/// numbers, sequence number, actual feed rate, actual spindle speed, and the position
|
||||
/// quadruple.
|
||||
/// </summary>
|
||||
public sealed record WireDynamic(
|
||||
int Alarm,
|
||||
int ProgramNumber,
|
||||
int MainProgramNumber,
|
||||
int SequenceNumber,
|
||||
int FeedRate,
|
||||
int SpindleSpeed,
|
||||
WireAxisPosition Axis);
|
||||
|
||||
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
|
||||
public sealed record WireServoMeter(
|
||||
short Index,
|
||||
string Name,
|
||||
int Value,
|
||||
short Decimal,
|
||||
short Unit);
|
||||
|
||||
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
|
||||
public sealed record WireSpindleMetric(
|
||||
short Index,
|
||||
int Value);
|
||||
|
||||
/// <summary>
|
||||
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
|
||||
/// axis index (preserved even when the name is empty so callers can pass it to
|
||||
/// <c>cnc_rddynamic2</c>).
|
||||
/// </summary>
|
||||
public readonly record struct WireAxisRecord(short Index, string Name);
|
||||
|
||||
/// <summary>One spindle-name slot from <c>cnc_rdspdlname</c>.</summary>
|
||||
public readonly record struct WireSpindleRecord(short Index, string Name);
|
||||
|
||||
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
|
||||
public sealed record WireParameter(
|
||||
short DataNumber,
|
||||
short Type,
|
||||
int Value);
|
||||
|
||||
/// <summary>
|
||||
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the callable value is
|
||||
/// <c>Value / 10^Decimal</c>.
|
||||
/// </summary>
|
||||
public sealed record WireMacro(
|
||||
short Number,
|
||||
int Value,
|
||||
short Decimal);
|
||||
|
||||
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
|
||||
public sealed record WirePmcRange(
|
||||
short Area,
|
||||
short DataType,
|
||||
ushort Start,
|
||||
ushort End,
|
||||
IReadOnlyList<long> Values);
|
||||
|
||||
/// <summary>
|
||||
/// One active alarm from <c>cnc_rdalmmsg2</c>. Mirrors the vendor <c>ODBALMMSG2</c>
|
||||
/// layout; <see cref="AlarmGroup"/> is populated when the wire responder carries it
|
||||
/// (currently <c>null</c> for both the 76-byte vendor shape and the 80-byte legacy
|
||||
/// shape).
|
||||
/// </summary>
|
||||
public sealed record WireAlarm(
|
||||
int AlarmNumber,
|
||||
short Type,
|
||||
short Axis,
|
||||
short MessageLength,
|
||||
string Message,
|
||||
int? AlarmGroup = null);
|
||||
|
||||
/// <summary>
|
||||
/// Executing-program identity from <c>cnc_exeprgname2</c>: the NUL-terminated name and
|
||||
/// the trailing 32-bit O-number (null when the wire responder omits the trailing int).
|
||||
/// </summary>
|
||||
public sealed record WireProgramName(
|
||||
string Name,
|
||||
int? ONumber);
|
||||
|
||||
/// <summary>One cumulative timer reading from <c>cnc_rdtimer</c> (minutes + fractional milliseconds).</summary>
|
||||
public sealed record WireTimer(
|
||||
short Type,
|
||||
int Minutes,
|
||||
int Milliseconds);
|
||||
250
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
Normal file
250
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Framing primitives for the FOCAS/2 Ethernet wire protocol — magic-prefixed PDU
|
||||
/// header + request/response block envelopes. Read-only subset: every call OtOpcUa
|
||||
/// issues maps to one of the command IDs documented in
|
||||
/// <c>docs/v2/implementation/focas-wire-protocol.md</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>All multi-byte integer fields are big-endian on the wire. The 10-byte header is
|
||||
/// <c>a0 a0 a0 a0</c> magic + 2-byte version + type byte + direction byte + 2-byte body
|
||||
/// length. Version 1 is the only version this implementation supports.</para>
|
||||
/// <para>Type <c>0x01</c> is the initiate handshake, <c>0x02</c> is the session close,
|
||||
/// <c>0x21</c> is a request/response data PDU carrying one or more request blocks.</para>
|
||||
/// </remarks>
|
||||
internal static class FocasWireProtocol
|
||||
{
|
||||
public const ushort Version = 1;
|
||||
public const byte DirectionRequest = 0x01;
|
||||
public const byte DirectionResponse = 0x02;
|
||||
public const byte TypeInitiate = 0x01;
|
||||
public const byte TypeClose = 0x02;
|
||||
public const byte TypeData = 0x21;
|
||||
|
||||
private static readonly byte[] Magic = [0xa0, 0xa0, 0xa0, 0xa0];
|
||||
|
||||
/// <summary>Assemble a full PDU (10-byte header + body) for transmission.</summary>
|
||||
public static byte[] BuildPdu(byte type, byte direction, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(body), "FOCAS PDU body is limited to 65535 bytes.");
|
||||
|
||||
var bytes = new byte[10 + body.Length];
|
||||
Magic.CopyTo(bytes, 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(4, 2), Version);
|
||||
bytes[6] = type;
|
||||
bytes[7] = direction;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(8, 2), (ushort)body.Length);
|
||||
body.CopyTo(bytes.AsSpan(10));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiate-body shape — just the 2-byte socket index (1 or 2). <c>cnc_allclibhndl3</c>
|
||||
/// opens two TCP sockets in sequence and each sends its own initiate PDU carrying its
|
||||
/// index.
|
||||
/// </summary>
|
||||
public static byte[] BuildInitiateBody(ushort socketIndex)
|
||||
{
|
||||
var body = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(body, socketIndex);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Assemble a type-<c>0x21</c> body carrying one or more request blocks.</summary>
|
||||
public static byte[] BuildRequestBody(IReadOnlyList<RequestBlock> blocks)
|
||||
{
|
||||
if (blocks.Count > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(blocks), "Too many request blocks.");
|
||||
|
||||
var blockBytes = blocks.Select(BuildRequestBlock).ToArray();
|
||||
var bodyLength = 2 + blockBytes.Sum(block => block.Length);
|
||||
if (bodyLength > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(blocks), "FOCAS request body is too large.");
|
||||
|
||||
var body = new byte[bodyLength];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)blocks.Count);
|
||||
var offset = 2;
|
||||
foreach (var block in blockBytes)
|
||||
{
|
||||
block.CopyTo(body.AsSpan(offset));
|
||||
offset += block.Length;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Async read of one full PDU off a stream. Throws <see cref="FocasWireException"/> on invalid magic / version / truncation.</summary>
|
||||
public static async Task<Pdu> ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var header = new byte[10];
|
||||
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!header.AsSpan(0, 4).SequenceEqual(Magic))
|
||||
throw new FocasWireException("Invalid FOCAS PDU magic.");
|
||||
|
||||
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
|
||||
if (version != Version)
|
||||
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
|
||||
|
||||
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
|
||||
var body = new byte[bodyLength];
|
||||
if (bodyLength > 0)
|
||||
await ReadExactlyAsync(stream, body, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new Pdu(header[6], header[7], body);
|
||||
}
|
||||
|
||||
/// <summary>Synchronous counterpart to <see cref="ReadPduAsync"/> — used by <see cref="FocasWireClient"/>'s sync dispose.</summary>
|
||||
public static Pdu ReadPdu(NetworkStream stream)
|
||||
{
|
||||
var header = new byte[10];
|
||||
ReadExactly(stream, header);
|
||||
|
||||
if (!header.AsSpan(0, 4).SequenceEqual(Magic))
|
||||
throw new FocasWireException("Invalid FOCAS PDU magic.");
|
||||
|
||||
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
|
||||
if (version != Version)
|
||||
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
|
||||
|
||||
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
|
||||
var body = new byte[bodyLength];
|
||||
if (bodyLength > 0)
|
||||
ReadExactly(stream, body);
|
||||
|
||||
return new Pdu(header[6], header[7], body);
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadExactly(NetworkStream stream, byte[] buffer)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = stream.Read(buffer, offset, buffer.Length - offset);
|
||||
if (read == 0)
|
||||
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpack a type-<c>0x21</c> response body into its constituent response blocks. Each
|
||||
/// block carries the command ID, the FOCAS <c>EW_*</c> return code, and the payload
|
||||
/// bytes.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ResponseBlock> ParseResponseBlocks(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 2)
|
||||
return Array.Empty<ResponseBlock>();
|
||||
|
||||
var count = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(0, 2));
|
||||
var blocks = new List<ResponseBlock>(count);
|
||||
var offset = 2;
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
if (offset + 2 > body.Length)
|
||||
throw new FocasWireException("Truncated FOCAS response block length.");
|
||||
|
||||
var blockLength = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(offset, 2));
|
||||
if (blockLength < 0x10 || offset + blockLength > body.Length)
|
||||
throw new FocasWireException($"Invalid FOCAS response block length {blockLength}.");
|
||||
|
||||
var block = body.Slice(offset, blockLength);
|
||||
var command = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(6, 2));
|
||||
var payloadLength = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(14, 2));
|
||||
if (0x10 + payloadLength > blockLength)
|
||||
throw new FocasWireException("Invalid FOCAS response payload length.");
|
||||
|
||||
var rc = BinaryPrimitives.ReadInt16BigEndian(block.Slice(8, 2));
|
||||
blocks.Add(new ResponseBlock(command, rc, block.Slice(16, payloadLength).ToArray()));
|
||||
offset += blockLength;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/// <summary>Read an ASCII string out of a payload span, stopping at the first NUL and trimming trailing spaces.</summary>
|
||||
public static string ReadAscii(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var end = bytes.IndexOf((byte)0);
|
||||
if (end >= 0) bytes = bytes.Slice(0, end);
|
||||
return Encoding.ASCII.GetString(bytes.ToArray()).TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
|
||||
/// (spindle) slot. Trailing spaces and NULs are stripped so <c>"X "</c> becomes
|
||||
/// <c>"X"</c>.
|
||||
/// </summary>
|
||||
public static string ReadNameRecord(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 2) return string.Empty;
|
||||
var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
|
||||
return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
private static byte[] BuildRequestBlock(RequestBlock request)
|
||||
{
|
||||
var extra = request.ExtraPayload ?? Array.Empty<byte>();
|
||||
if (extra.Length > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request extra payload is too large.");
|
||||
|
||||
var blockLength = 0x1c + extra.Length;
|
||||
if (blockLength > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request block is too large.");
|
||||
|
||||
var block = new byte[blockLength];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLength);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(2, 2), request.RequestClass);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(4, 2), request.PathId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), request.Command);
|
||||
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(8, 4), request.Arg1);
|
||||
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(12, 4), request.Arg2);
|
||||
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(16, 4), request.Arg3);
|
||||
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(20, 4), request.Arg4);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(24, 2), request.Arg5);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(26, 2), (ushort)extra.Length);
|
||||
extra.CopyTo(block.AsSpan(28));
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One raw PDU off the wire — header bytes plus the body.</summary>
|
||||
internal sealed record Pdu(byte Type, byte Direction, byte[] Body);
|
||||
|
||||
/// <summary>
|
||||
/// One request block within a type-<c>0x21</c> PDU body. <see cref="Command"/> is the
|
||||
/// FOCAS command ID (e.g. <c>0x0018</c> for sysinfo); <see cref="Arg1"/>..<see cref="Arg5"/>
|
||||
/// are the command-specific scalar arguments; <see cref="ExtraPayload"/> carries the
|
||||
/// optional extra bytes for writes.
|
||||
/// </summary>
|
||||
internal sealed record RequestBlock(
|
||||
ushort Command,
|
||||
int Arg1 = 0,
|
||||
int Arg2 = 0,
|
||||
int Arg3 = 0,
|
||||
int Arg4 = 0,
|
||||
ushort Arg5 = 0,
|
||||
ushort RequestClass = 1,
|
||||
ushort PathId = 1,
|
||||
byte[]? ExtraPayload = null);
|
||||
|
||||
/// <summary>One response block — command ID + FOCAS return code + payload bytes.</summary>
|
||||
internal sealed record ResponseBlock(ushort Command, short Rc, byte[] Payload);
|
||||
333
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
Normal file
333
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
Normal file
@@ -0,0 +1,333 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation backed by the in-tree managed
|
||||
/// <see cref="FocasWireClient"/>. No P/Invoke, no <c>Fwlib64.dll</c>, no out-of-process
|
||||
/// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2
|
||||
/// Ethernet binary protocol.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OtOpcUa is read-only against FOCAS. <see cref="WriteAsync"/> returns
|
||||
/// <see cref="FocasStatusMapper.BadNotWritable"/> for every address — the managed wire
|
||||
/// client intentionally does not expose <c>cnc_wrparam</c> / <c>pmc_wrpmcrng</c> /
|
||||
/// <c>cnc_wrmacro</c>.
|
||||
/// </remarks>
|
||||
public sealed class WireFocasClient : IFocasClient
|
||||
{
|
||||
private readonly FocasWireClient _wire = new();
|
||||
private FocasHostAddress? _address;
|
||||
|
||||
public bool IsConnected => _wire.IsConnected;
|
||||
|
||||
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_wire.IsConnected) return;
|
||||
_address = address;
|
||||
// FocasWireClient.ConnectAsync interprets TimeSpan.Zero as "no timeout" — clamp the
|
||||
// driver's default TimeSpan to at least 1s so a caller passing TimeSpan.Zero gets a
|
||||
// sane fail-fast instead of hanging indefinitely.
|
||||
var effective = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : timeout;
|
||||
await _wire.ConnectAsync(address.Host, address.Port, effective, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return (null, FocasStatusMapper.BadCommunicationError);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false),
|
||||
_ => (null, FocasStatusMapper.BadNotSupported),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return false;
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result.IsOk;
|
||||
}
|
||||
catch (FocasWireException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value.Select(Map).ToList();
|
||||
}
|
||||
catch (FocasWireException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
static FocasActiveAlarm Map(WireAlarm a) => new(
|
||||
AlarmNumber: a.AlarmNumber,
|
||||
Type: a.Type,
|
||||
Axis: a.Axis,
|
||||
Message: a.Message ?? string.Empty);
|
||||
}
|
||||
|
||||
public async Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadSysInfoAsync(cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_sysinfo", result.IsOk);
|
||||
var info = result.Value!;
|
||||
// Fanuc right-pads the ASCII axis count with spaces; fall back to MaxAxis if the
|
||||
// text field isn't interpretable as an integer.
|
||||
var axesCount = int.TryParse(info.Axes?.Trim(), out var parsed) ? parsed : info.MaxAxis;
|
||||
return new FocasSysInfo(
|
||||
AddInfo: info.AddInfo,
|
||||
MaxAxis: info.MaxAxis,
|
||||
CncType: info.CncType ?? string.Empty,
|
||||
MtType: info.MachineType ?? string.Empty,
|
||||
Series: info.Series ?? string.Empty,
|
||||
Version: info.Version ?? string.Empty,
|
||||
AxesCount: axesCount);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
var result = await _wire.ReadAxisNamesAsync(32, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value.Select(SplitAxis).Where(n => n.Name.Length > 0).ToList();
|
||||
|
||||
// FocasWireClient returns axis records as a single Name string (e.g. "X" or "X1").
|
||||
// IFocasClient wants Name + Suffix split — the first char is the axis letter, the
|
||||
// rest is the multi-channel suffix.
|
||||
static FocasAxisName SplitAxis(WireAxisRecord r)
|
||||
{
|
||||
var n = r.Name ?? string.Empty;
|
||||
return n.Length == 0
|
||||
? new FocasAxisName(string.Empty, string.Empty)
|
||||
: new FocasAxisName(n[..1], n.Length > 1 ? n[1..] : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
var result = await _wire.ReadSpindleNamesAsync(8, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value.Select(SplitSpindle).Where(n => n.Name.Length > 0).ToList();
|
||||
|
||||
static FocasSpindleName SplitSpindle(WireSpindleRecord r)
|
||||
{
|
||||
var n = r.Name ?? string.Empty;
|
||||
return new FocasSpindleName(
|
||||
Name: n.Length > 0 ? n[..1] : string.Empty,
|
||||
Suffix1: n.Length > 1 ? n[1..2] : string.Empty,
|
||||
Suffix2: n.Length > 2 ? n[2..3] : string.Empty,
|
||||
Suffix3: n.Length > 3 ? n[3..4] : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
|
||||
var d = result.Value!;
|
||||
var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
|
||||
return new FocasDynamicSnapshot(
|
||||
AxisIndex: axisIndex,
|
||||
AlarmFlags: d.Alarm,
|
||||
ProgramNumber: d.ProgramNumber,
|
||||
MainProgramNumber: d.MainProgramNumber,
|
||||
SequenceNumber: d.SequenceNumber,
|
||||
ActualFeedRate: d.FeedRate,
|
||||
ActualSpindleSpeed: d.SpindleSpeed,
|
||||
AbsolutePosition: pos.Absolute,
|
||||
MachinePosition: pos.Machine,
|
||||
RelativePosition: pos.Relative,
|
||||
DistanceToGo: pos.Distance);
|
||||
}
|
||||
|
||||
public async Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
var nameResult = await _wire.ReadExecutingProgramNameAsync(cancellationToken).ConfigureAwait(false);
|
||||
var blkResult = await _wire.ReadBlockCountAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Use the raw short variant — FocasProgramInfo.Mode stores the integer code so the
|
||||
// managed ToText path in FocasOpMode can map it for display.
|
||||
var modeResult = await _wire.ReadOperationModeCodeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var wireName = nameResult.Value;
|
||||
return new FocasProgramInfo(
|
||||
Name: wireName?.Name ?? string.Empty,
|
||||
ONumber: wireName?.ONumber ?? 0,
|
||||
BlockCount: blkResult.IsOk ? blkResult.Value : 0,
|
||||
Mode: modeResult.IsOk ? modeResult.Value : 0);
|
||||
}
|
||||
|
||||
public async Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadTimerAsync((short)kind, cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, $"cnc_rdtimer kind={kind}", result.IsOk);
|
||||
var t = result.Value!;
|
||||
return new FocasTimer(kind, t.Minutes, t.Milliseconds);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value
|
||||
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
|
||||
.Where(s => s.AxisName.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
||||
|
||||
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
|
||||
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await call(-1, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
var list = new List<int>();
|
||||
foreach (var m in result.Value)
|
||||
{
|
||||
// Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
|
||||
// list length matches the configured spindle count.
|
||||
if (m.Value == 0 && list.Count > 0) break;
|
||||
list.Add(m.Value);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public void Dispose() => _wire.Dispose();
|
||||
|
||||
// ---- PMC / Parameter / Macro read paths ------------------------------------------
|
||||
|
||||
private async Task<(object? value, uint status)> ReadPmcAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty);
|
||||
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
|
||||
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
|
||||
var start = (ushort)address.Number;
|
||||
var end = start;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, start, end, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null)
|
||||
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
||||
var values = result.Value.Values;
|
||||
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
|
||||
var raw = values[0];
|
||||
var mapped = type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
|
||||
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
|
||||
FocasDataType.Int16 => (object)(short)raw,
|
||||
FocasDataType.Int32 => (object)(int)raw,
|
||||
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
|
||||
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
|
||||
_ => (object)raw,
|
||||
};
|
||||
return (mapped, FocasStatusMapper.Good);
|
||||
}
|
||||
catch (FocasWireException ex)
|
||||
{
|
||||
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(object? value, uint status)> ReadParameterAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Byte:
|
||||
var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
|
||||
return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc));
|
||||
case FocasDataType.Int16:
|
||||
var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
|
||||
return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc));
|
||||
case FocasDataType.Float32:
|
||||
var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
|
||||
return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc));
|
||||
case FocasDataType.Float64:
|
||||
var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
|
||||
return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc));
|
||||
case FocasDataType.Bit when address.BitIndex is int bit:
|
||||
var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
|
||||
if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc));
|
||||
return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good);
|
||||
default:
|
||||
var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
|
||||
return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc));
|
||||
}
|
||||
}
|
||||
catch (FocasWireException ex)
|
||||
{
|
||||
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(object? value, uint status)> ReadMacroAsync(
|
||||
FocasAddress address, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null)
|
||||
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
||||
var m = result.Value;
|
||||
// Macro value is scaled-decimal: the real value is Value / 10^Decimal.
|
||||
var scaled = m.Value / Math.Pow(10.0, m.Decimal);
|
||||
return ((object)scaled, FocasStatusMapper.Good);
|
||||
}
|
||||
catch (FocasWireException ex)
|
||||
{
|
||||
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
private void RequireConnected()
|
||||
{
|
||||
if (!_wire.IsConnected)
|
||||
throw new InvalidOperationException("FOCAS wire session not connected.");
|
||||
}
|
||||
|
||||
private static void ThrowIfRcNonZero(short rc, string call, bool isOk)
|
||||
{
|
||||
if (!isOk) throw new InvalidOperationException($"{call} failed EW_{rc}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
|
||||
public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new WireFocasClient();
|
||||
}
|
||||
@@ -15,20 +15,15 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
No NuGet reference to a FOCAS library — FWLIB is Fanuc-proprietary and the licensed
|
||||
Fwlib32.dll cannot be redistributed. The deployment side supplies an IFocasClient
|
||||
implementation that P/Invokes against whatever Fwlib32.dll the customer has licensed.
|
||||
Driver.FOCAS.IntegrationTests in a separate repo can wire in the real binary.
|
||||
Follow-up task #193 tracks the real-client reference implementation that customers may
|
||||
drop in privately.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user