using System.Buffers.Binary; using System.Collections.Concurrent; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// implementation backed by Fanuc's licensed /// Fwlib32.dll via P/Invoke. The DLL is NOT shipped with /// OtOpcUa; the deployment places it next to the server executable or on PATH /// (per Fanuc licensing — see docs/v2/focas-deployment.md). /// /// /// Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class /// does NOT load Fwlib32.dll. The DLL only loads on the first wire call (Connect / /// Read / Write / Probe). When missing, those calls throw /// which the driver surfaces as BadCommunicationError through the normal exception /// mapping. /// /// Session-scoped handle — cnc_allclibhndl3 opens one FWLIB handle per CNC; /// all PMC / parameter / macro reads on that device go through the same handle. Dispose /// calls cnc_freelibhndl. /// 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 _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)), FocasAreaKind.Diagnostic => Task.FromResult( ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)), _ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)), }; } public Task<(object? value, uint status)> ReadDiagnosticAsync( int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken) { if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError)); cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type)); } public async Task 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, }; } /// /// 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. /// private async Task 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 GetPathCountAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(1); var buf = new FwlibNative.ODBPATH(); var ret = FwlibNative.RdPathNum(_handle, ref buf); // EW_FUNC / EW_NOOPT on single-path controllers — fall back to 1 rather than failing. if (ret != 0 || buf.MaxPath < 1) return Task.FromResult(1); return Task.FromResult((int)buf.MaxPath); } public Task SetPathAsync(int pathId, CancellationToken cancellationToken) { if (!_connected) return Task.CompletedTask; var ret = FwlibNative.SetPath(_handle, (short)pathId); if (ret != 0) throw new InvalidOperationException( $"FWLIB cnc_setpath failed with EW_{ret} switching to path {pathId}."); return Task.CompletedTask; } public Task 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); } public Task GetStatusAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); var buf = new FwlibNative.ODBST(); var ret = FwlibNative.StatInfo(_handle, ref buf); if (ret != 0) return Task.FromResult(null); return Task.FromResult(new FocasStatusInfo( Dummy: buf.Dummy, Tmmode: buf.TmMode, Aut: buf.Aut, Run: buf.Run, Motion: buf.Motion, Mstb: buf.Mstb, EmergencyStop: buf.Emergency, Alarm: buf.Alarm, Edit: buf.Edit)); } public Task GetProductionAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); if (!TryReadInt32Param(6711, out var produced) || !TryReadInt32Param(6712, out var required) || !TryReadInt32Param(6713, out var total)) { return Task.FromResult(null); } // Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort: // a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot // — the parts counters are still useful even when cycle-time isn't supported. var cycleSeconds = 0; var tmrBuf = new FwlibNative.IODBTMR(); if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0) cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000); return Task.FromResult(new FocasProductionInfo( PartsProduced: produced, PartsRequired: required, PartsTotal: total, CycleTimeSeconds: cycleSeconds)); } private bool TryReadInt32Param(ushort number, out int value) { var buf = new FwlibNative.IODBPSD { Data = new byte[32] }; var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf); if (ret != 0) { value = 0; return false; } value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data); return true; } private bool TryReadInt16Param(ushort number, out short value) { var buf = new FwlibNative.IODBPSD { Data = new byte[32] }; var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf); if (ret != 0) { value = 0; return false; } value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data); return true; } public Task GetModalAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); // type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0). // Best-effort — if any single read fails we still surface the others as 0; the // probe loop only updates the cache on a non-null return so a partial snapshot // is preferable to throwing away every successful field. return Task.FromResult(new FocasModalInfo( MCode: ReadModalAux(type: 100), SCode: ReadModalAux(type: 101), TCode: ReadModalAux(type: 102), BCode: ReadModalAux(type: 103))); } private short ReadModalAux(short type) { var buf = new FwlibNative.ODBMDL { Data = new byte[8] }; var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf); if (ret != 0) return 0; // For aux types (100..103) the union holds the code at offset 0 as a 2-byte // value (aux_data). Reading as Int16 keeps the surface identical to the // record contract; oversized values would have been truncated by FWLIB anyway. return BinaryPrimitives.ReadInt16LittleEndian(buf.Data); } public Task GetOverrideAsync( FocasOverrideParameters parameters, CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); // Each parameter is independently nullable — a null parameter number keeps the // corresponding field at null + skips the wire call. A successful read on at // least one parameter is enough to publish a snapshot; this matches the // best-effort policy used by GetProductionAsync (issue #259). var feed = TryReadOverride(parameters.FeedParam); var rapid = TryReadOverride(parameters.RapidParam); var spindle = TryReadOverride(parameters.SpindleParam); var jog = TryReadOverride(parameters.JogParam); return Task.FromResult(new FocasOverrideInfo(feed, rapid, spindle, jog)); } private short? TryReadOverride(ushort? param) { if (param is null) return null; return TryReadInt16Param(param.Value, out var v) ? v : null; } public Task GetToolingAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); var buf = new FwlibNative.IODBTNUM(); var ret = FwlibNative.RdToolNumber(_handle, ref buf); if (ret != 0) return Task.FromResult(null); // FWLIB returns long; clamp to short for the surfaced Int16 (T-codes // overflowing 32767 are vanishingly rare on Fanuc tool tables). var t = buf.Data; if (t > short.MaxValue) t = short.MaxValue; else if (t < short.MinValue) t = short.MinValue; return Task.FromResult(new FocasToolingInfo((short)t)); } public Task GetWorkOffsetsAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); // 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred. // Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z). // Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84 // (the buffer ceiling) so a CNC with more axes still completes the call. var slots = new List(6); string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"]; for (short n = 1; n <= 6; n++) { var buf = new FwlibNative.IODBZOFS { Data = new byte[80] }; var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf); if (ret != 0) { // Best-effort — a single-slot failure leaves the slot at 0.0; the cache // still publishes so reads on the other offsets serve Good. The probe // loop will retry on the next tick. slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0)); continue; } slots.Add(new FocasWorkOffset( Name: names[n - 1], X: DecodeOfsbAxis(buf.Data, axisIndex: 0), Y: DecodeOfsbAxis(buf.Data, axisIndex: 1), Z: DecodeOfsbAxis(buf.Data, axisIndex: 2))); } return Task.FromResult(new FocasWorkOffsetsInfo(slots)); } public Task GetOperatorMessagesAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); // type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read // (length 4 + 256 = 260) returns the most-recent message in each class — best- // effort: a single-class failure leaves that class out of the snapshot rather // than failing the whole call, mirroring GetProductionAsync's policy. var list = new List(4); string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"]; for (short t = 0; t < 4; t++) { var buf = new FwlibNative.OPMSG3 { Data = new byte[256] }; var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf); if (ret != 0) continue; var text = TrimAnsiPadding(buf.Data); if (string.IsNullOrEmpty(text)) continue; list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text)); } return Task.FromResult(new FocasOperatorMessagesInfo(list)); } public Task GetCurrentBlockAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(null); var buf = new FwlibNative.ODBACTPT { Data = new byte[256] }; var ret = FwlibNative.RdActPt(_handle, ref buf); if (ret != 0) return Task.FromResult(null); return Task.FromResult( new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data))); } public Task?> GetFigureScalingAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult?>(null); // kind=0 → position figures (absolute/relative/machine/distance share the same // increment system per axis). cnc_rdaxisname is deferred — the wire impl keys // by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name // discovery in a follow-up. Issue #262, plan PR F1-f. short count = 0; var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] }; var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf); if (ret != 0) return Task.FromResult?>(null); return Task.FromResult?>(DecodeFigureScaling(buf.Data, count)); } /// /// Decode the per-axis decimal-place counts from a cnc_getfigure reply /// buffer. Each axis entry per fwlib32.h is 8 bytes laid out as /// short dec + short unit + 4 reserved bytes; we read only /// dec. Keys are 1-based "axis{n}" placeholders — a follow-up /// PR can rewire to cnc_rdaxisname once that surface lands without /// changing the cache contract (issue #262). /// internal static IReadOnlyDictionary DecodeFigureScaling(byte[] data, short count) { var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS)); var result = new Dictionary(clamped, StringComparer.OrdinalIgnoreCase); for (var i = 0; i < clamped; i++) { var offset = i * 8; if (offset + 2 > data.Length) break; var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2)); if (dec < 0 || dec > 9) dec = 0; result[$"axis{i + 1}"] = dec; } return result; } /// /// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg /// bodies with nulls or spaces; trim them so the round-trip through the OPC UA /// address space stays stable (issue #261). Stops at the first NUL so any wire /// buffer that gets reused doesn't leak old bytes. /// internal static string TrimAnsiPadding(byte[] data) { if (data is null) return string.Empty; var len = 0; for (; len < data.Length; len++) if (data[len] == 0) break; return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0'); } /// /// Decode one OFSB axis block from a cnc_rdzofs data buffer. Each axis /// occupies 10 bytes per fwlib32.h: int data + short dec + /// short unit + short disp. The user-facing offset is /// data / 10^dec — same convention as cnc_rdmacro. /// internal static double DecodeOfsbAxis(byte[] data, int axisIndex) { const int blockSize = 10; var offset = axisIndex * blockSize; if (offset + blockSize > data.Length) return 0; var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4)); var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2)); if (dec < 0 || dec > 9) dec = 0; return raw / Math.Pow(10.0, dec); } // ---- 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); } /// /// Range read for the PMC coalescer (issue #266). FWLIB's pmc_rdpmcrng /// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested /// ranges larger than that are chunked into 32-byte sub-calls internally — /// callers still see one logical range, which matches the /// 's "one wire call per group" semantics. /// public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync( string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken) { if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError)); cancellationToken.ThrowIfCancellationRequested(); if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty(), FocasStatusMapper.Good)); var addrType = FocasPmcAddrType.FromLetter(letter) ?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'."); var result = new byte[byteCount]; const int chunkBytes = 32; var offset = 0; while (offset < byteCount) { cancellationToken.ThrowIfCancellationRequested(); var thisChunk = Math.Min(chunkBytes, byteCount - offset); var buf = new FwlibNative.IODBPMC { Data = new byte[40] }; var ret = FwlibNative.PmcRdPmcRng( _handle, addrType, FocasPmcDataType.Byte, (ushort)(startByte + offset), (ushort)(startByte + offset + thisChunk - 1), (ushort)(8 + thisChunk), ref buf); if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret))); Array.Copy(buf.Data, 0, result, offset, thisChunk); offset += thisChunk; } return Task.FromResult<(byte[]?, uint)>((result, 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) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type) { var buf = new FwlibNative.IODBPSD { Data = new byte[32] }; var length = DiagnosticReadLength(type); var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf); if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret)); var value = type switch { FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 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)BinaryPrimitives.ReadInt32LittleEndian(buf.Data), }; return (value, FocasStatusMapper.Good); } private static int DiagnosticReadLength(FocasDataType type) => type switch { FocasDataType.Bit or FocasDataType.Byte => 4 + 1, FocasDataType.Int16 => 4 + 2, FocasDataType.Int32 => 4 + 4, FocasDataType.Float32 => 4 + 4, FocasDataType.Float64 => 4 + 8, _ => 4 + 4, }; 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; } } } /// Default — produces a fresh per device. public sealed class FwlibFocasClientFactory : IFocasClientFactory { public IFocasClient Create() => new FwlibFocasClient(); }