5f0a52864c
First real FOCAS hardware contact (Makino Pro 5 / 31i-B @ 10.201.31.5). A full
v3 data-PDU capture corrected the initial diagnosis: the v3 block envelope is
identical to v1, so only specific payload structs / request math / one client
robustness gap were wrong — not "framing rewrites".
Fixes (all re-validated live through the fixed driver):
- version gate: accept inbound PDU {1,3}, keep emitting v1 (FocasWireProtocol).
- cnc_rdtimer: 8-byte {minute,msec} payload is little-endian (ParseTimer) — the
only decode with an in-range msec field.
- pmc_rdpmcrng: request range widened to the data-type byte width
(end = start + width - 1) so a Word/Long isn't truncated to 0 values
(was spurious BadOutOfRange); decode extracted to ParsePmcRange.
- cnc_rdsvmeter: per-axis LOADELM is 8 bytes (not 12) and names come from the
0x0089 block — ParseServoMeters fixes the misaligned 655360 garbage. Also the
"hang" was NetworkStream.ReadAsync not aborting a stalled socket: ReadExactlyAsync
now disposes the stream on cancellation so a stalled peer can't wedge a poll loop.
- cnc_rddynamic2: contract guard rejecting axis < 1 (driver poll already 1-based).
- FocasDriverProbe: run a real wire session (initiate + cnc_statinfo) instead of
degrading to Ok=true "TCP reachability only" when FWLIB is absent — a bare TCP
listener no longer reports HEALTHY.
cnc_rdparam (0x000e) is unsupported on this control — EW_FUNC across 14
request-framing variants x 4 known-present params; needs a reference FWLIB trace
or is restricted. Deferred (deployed config uses macros, not parameters).
Tests: FOCAS suite 234 green (+16), full solution builds 0 errors. Raw v3
captures checked in under tests/.../Fixtures/v3/. Capture tools under scripts/focas/.
Docs: docs/plans/2026-06-25-focas-pdu-v3-{30i-b-support,implementation-plan}.md,
docs/drivers/FOCAS.md, docs/v2/focas-version-matrix.md,
docs/deployments/wonder-app-vd03-makino-z-34184.md.
121 lines
5.6 KiB
C#
121 lines
5.6 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
/// <summary>
|
|
/// Two-phase Test-Connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
|
|
/// Phase 1: bare TCP connect to the first device's FOCAS Ethernet address + port to quickly
|
|
/// reject unreachable targets (preserves the original "Connect failed" / "timed out"
|
|
/// messages). Phase 2: a real FOCAS session via the managed <see cref="FocasWireClient"/> — the
|
|
/// two-socket initiate handshake plus one sample read (<c>cnc_statinfo</c>). A handshake +
|
|
/// read that succeeds confirms the remote endpoint is a real FOCAS CNC, not just a TCP
|
|
/// listener.
|
|
/// <para>
|
|
/// <b>Why a wire-client probe (not FWLIB).</b> The pure-managed wire client is the driver's
|
|
/// only read backend (the FWLIB / out-of-process paths were retired in the Wire migration), so
|
|
/// the probe must exercise the same path the driver actually uses. The previous probe issued
|
|
/// the <c>cnc_allclibhndl3</c> FWLIB P/Invoke and, on any host without the native library (the
|
|
/// normal case — macOS dev boxes, Linux CI, and the Windows hosts that run the managed client),
|
|
/// degraded to <c>Ok=true</c> "TCP reachability only". That made every bare TCP listener look
|
|
/// HEALTHY — exactly how a Makino 31i-B looked "healthy" while no FOCAS data flowed. The wire
|
|
/// probe reports HEALTHY only on a genuine FOCAS session + read. See
|
|
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c> (Phase 8).
|
|
/// </para>
|
|
/// <para>
|
|
/// The wire client honours the linked CTS (<c>ct</c> + <c>CancelAfter(timeout)</c>) and its
|
|
/// reads are abort-bounded (see <see cref="FocasWireProtocol"/>), so the probe always returns
|
|
/// within the timeout budget even against a host that accepts TCP then stalls.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class FocasDriverProbe : IDriverProbe
|
|
{
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "FOCAS";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
FocasDriverOptions? opts;
|
|
try { opts = JsonSerializer.Deserialize<FocasDriverOptions>(configJson, _opts); }
|
|
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
|
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
|
|
|
var (host, port) = ExtractTarget(opts);
|
|
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
|
return new(false, "Config has no host/port to probe.", null);
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
|
|
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts. Messages unchanged.
|
|
try
|
|
{
|
|
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await socket.ConnectAsync(host, port, ct);
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, ex.Message, null);
|
|
}
|
|
|
|
// Phase 2: real FOCAS session via the managed wire client — initiate handshake + one
|
|
// sample read. Bounded by a linked CTS = ct + CancelAfter(budget); the wire reads are
|
|
// abort-bounded so a TCP-accept-then-stall host can't hold the probe past the budget.
|
|
using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1);
|
|
sessionCts.CancelAfter(budget);
|
|
|
|
try
|
|
{
|
|
await using var wire = new FocasWireClient();
|
|
await wire.ConnectAsync(host, port, budget, sessionCts.Token).ConfigureAwait(false);
|
|
var status = await wire.ReadStatusAsync(sessionCts.Token, budget).ConfigureAwait(false);
|
|
sw.Stop();
|
|
|
|
return status.IsOk
|
|
? new(true, $"FOCAS session OK at {host}:{port} (cnc_statinfo)", sw.Elapsed)
|
|
: new(false, $"Reachable at {host}:{port} but FOCAS read failed: EW_{status.Rc}", null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (FocasWireException ex)
|
|
{
|
|
// TCP-reachable but the FOCAS initiate/read failed — a listener that is not a CNC.
|
|
return new(false, $"Reachable at {host}:{port} but FOCAS session failed: {ex.Message}", null);
|
|
}
|
|
}
|
|
|
|
private static (string host, int port) ExtractTarget(FocasDriverOptions opts)
|
|
{
|
|
// Parse the first device's focas:// address to extract host + port.
|
|
var firstDevice = opts.Devices.FirstOrDefault();
|
|
if (firstDevice is null) return (string.Empty, 0);
|
|
|
|
var parsed = FocasHostAddress.TryParse(firstDevice.HostAddress);
|
|
if (parsed is null) return (string.Empty, 0);
|
|
|
|
return (parsed.Host, parsed.Port);
|
|
}
|
|
}
|