diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 253e325..b022300 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -8,6 +8,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs new file mode 100644 index 0000000..cd979f3 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Abstraction over the Modbus TCP socket. Takes a PDU (function code + data, excluding +/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id +/// pairing, framing, and socket I/O. Tests supply in-memory fakes. +/// +public interface IModbusTransport : IAsyncDisposable +{ + Task ConnectAsync(CancellationToken ct); + + /// + /// Send a Modbus PDU (function code + function-specific data) and read the response PDU. + /// Throws when the server returns an exception PDU + /// (function code + 0x80 + exception code). + /// + Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct); +} + +public sealed class ModbusException(byte functionCode, byte exceptionCode, string message) + : Exception(message) +{ + public byte FunctionCode { get; } = functionCode; + public byte ExceptionCode { get; } = exceptionCode; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs new file mode 100644 index 0000000..783ec76 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -0,0 +1,308 @@ +using System.Buffers.Binary; +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Modbus TCP implementation of + + +/// + . First native-protocol greenfield +/// driver for the v2 stack — validates the driver-agnostic IAddressSpaceBuilder + +/// IReadable/IWritable abstractions generalize beyond Galaxy. +/// +/// +/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model; +/// subscriptions would need a polling loop over the declared tags — additive PR). Historian +/// + alarm capabilities are out of scope (the protocol doesn't express them). +/// +public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId, + Func? transportFactory = null) + : IDriver, ITagDiscovery, IReadable, IWritable, IDisposable, IAsyncDisposable +{ + private readonly ModbusDriverOptions _options = options; + private readonly Func _transportFactory = + transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout)); + + private IModbusTransport? _transport; + private DriverHealth _health = new(DriverState.Unknown, null, null); + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + + public string DriverInstanceId => driverInstanceId; + public string DriverType => "Modbus"; + + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + _health = new DriverHealth(DriverState.Initializing, null, null); + try + { + _transport = _transportFactory(_options); + await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + foreach (var t in _options.Tags) _tagsByName[t.Name] = t; + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); + } + catch (Exception ex) + { + _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + throw; + } + } + + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await ShutdownAsync(cancellationToken); + await InitializeAsync(driverConfigJson, cancellationToken); + } + + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false); + _transport = null; + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } + + public DriverHealth GetHealth() => _health; + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // ---- ITagDiscovery ---- + + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + var folder = builder.Folder("Modbus", "Modbus"); + foreach (var t in _options.Tags) + { + folder.Variable(t.Name, t.Name, new DriverAttributeInfo( + FullName: t.Name, + DriverDataType: MapDataType(t.DataType), + IsArray: false, + ArrayDim: null, + SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false)); + } + return Task.CompletedTask; + } + + // ---- IReadable ---- + + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + var transport = RequireTransport(); + var now = DateTime.UtcNow; + var results = new DataValueSnapshot[fullReferences.Count]; + for (var i = 0; i < fullReferences.Count; i++) + { + if (!_tagsByName.TryGetValue(fullReferences[i], out var tag)) + { + results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now); + continue; + } + try + { + var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false); + results[i] = new DataValueSnapshot(value, 0u, now, now); + _health = new DriverHealth(DriverState.Healthy, now, null); + } + catch (Exception ex) + { + results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + return results; + } + + private async Task ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct) + { + switch (tag.Region) + { + case ModbusRegion.Coils: + { + var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 }; + var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + return (resp[2] & 0x01) == 1; + } + case ModbusRegion.DiscreteInputs: + { + var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 }; + var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + return (resp[2] & 0x01) == 1; + } + case ModbusRegion.HoldingRegisters: + case ModbusRegion.InputRegisters: + { + var quantity = RegisterCount(tag.DataType); + var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04; + var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), + (byte)(quantity >> 8), (byte)(quantity & 0xFF) }; + var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + // resp = [fc][byte-count][data...] + var data = new ReadOnlySpan(resp, 2, resp[1]); + return DecodeRegister(data, tag.DataType); + } + default: + throw new InvalidOperationException($"Unknown region {tag.Region}"); + } + } + + // ---- IWritable ---- + + public async Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + var transport = RequireTransport(); + var results = new WriteResult[writes.Count]; + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (!_tagsByName.TryGetValue(w.FullReference, out var tag)) + { + results[i] = new WriteResult(StatusBadNodeIdUnknown); + continue; + } + if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters) + { + results[i] = new WriteResult(StatusBadNotWritable); + continue; + } + try + { + await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false); + results[i] = new WriteResult(0u); + } + catch (Exception) + { + results[i] = new WriteResult(StatusBadInternalError); + } + } + return results; + } + + private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct) + { + switch (tag.Region) + { + case ModbusRegion.Coils: + { + var on = Convert.ToBoolean(value); + var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), + on ? (byte)0xFF : (byte)0x00, 0x00 }; + await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + return; + } + case ModbusRegion.HoldingRegisters: + { + var bytes = EncodeRegister(value, tag.DataType); + if (bytes.Length == 2) + { + var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), + bytes[0], bytes[1] }; + await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + } + else + { + // FC 16 (Write Multiple Registers) for 32-bit types + var qty = (ushort)(bytes.Length / 2); + var pdu = new byte[6 + 1 + bytes.Length]; + pdu[0] = 0x10; + pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF); + pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); + pdu[5] = (byte)bytes.Length; + Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length); + await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + } + return; + } + default: + throw new InvalidOperationException($"Writes not supported for region {tag.Region}"); + } + } + + // ---- codec ---- + + internal static ushort RegisterCount(ModbusDataType t) => t switch + { + ModbusDataType.Int16 or ModbusDataType.UInt16 => 1, + ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2, + _ => throw new InvalidOperationException($"Non-register data type {t}"), + }; + + internal static object DecodeRegister(ReadOnlySpan data, ModbusDataType t) => t switch + { + ModbusDataType.Int16 => BinaryPrimitives.ReadInt16BigEndian(data), + ModbusDataType.UInt16 => BinaryPrimitives.ReadUInt16BigEndian(data), + ModbusDataType.Int32 => BinaryPrimitives.ReadInt32BigEndian(data), + ModbusDataType.UInt32 => BinaryPrimitives.ReadUInt32BigEndian(data), + ModbusDataType.Float32 => BinaryPrimitives.ReadSingleBigEndian(data), + _ => throw new InvalidOperationException($"Non-register data type {t}"), + }; + + internal static byte[] EncodeRegister(object? value, ModbusDataType t) + { + switch (t) + { + case ModbusDataType.Int16: + { + var v = Convert.ToInt16(value); + var b = new byte[2]; + BinaryPrimitives.WriteInt16BigEndian(b, v); + return b; + } + case ModbusDataType.UInt16: + { + var v = Convert.ToUInt16(value); + var b = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(b, v); + return b; + } + case ModbusDataType.Int32: + { + var v = Convert.ToInt32(value); + var b = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(b, v); + return b; + } + case ModbusDataType.UInt32: + { + var v = Convert.ToUInt32(value); + var b = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(b, v); + return b; + } + case ModbusDataType.Float32: + { + var v = Convert.ToSingle(value); + var b = new byte[4]; + BinaryPrimitives.WriteSingleBigEndian(b, v); + return b; + } + default: + throw new InvalidOperationException($"Non-register data type {t}"); + } + } + + private static DriverDataType MapDataType(ModbusDataType t) => t switch + { + ModbusDataType.Bool => DriverDataType.Boolean, + ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32, + ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32, + ModbusDataType.Float32 => DriverDataType.Float32, + _ => DriverDataType.Int32, + }; + + private IModbusTransport RequireTransport() => + _transport ?? throw new InvalidOperationException("ModbusDriver not initialized"); + + private const uint StatusBadInternalError = 0x80020000u; + private const uint StatusBadNodeIdUnknown = 0x80340000u; + private const uint StatusBadNotWritable = 0x803B0000u; + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + public async ValueTask DisposeAsync() + { + if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false); + _transport = null; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs new file mode 100644 index 0000000..5a1b302 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Modbus TCP driver configuration. Bound from the driver's DriverConfig JSON at +/// DriverHost.RegisterAsync. Every register the driver exposes appears in +/// ; names become the OPC UA browse name + full reference. +/// +public sealed class ModbusDriverOptions +{ + public string Host { get; init; } = "127.0.0.1"; + public int Port { get; init; } = 502; + public byte UnitId { get; init; } = 1; + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); + + /// Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these. + public IReadOnlyList Tags { get; init; } = []; +} + +/// +/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not +/// the documentation's 1-based coil/register conventions). +/// +/// +/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be +/// unique within the driver. +/// +/// Coils / DiscreteInputs / InputRegisters / HoldingRegisters. +/// Zero-based address within the region. +/// Logical data type. Int16/UInt16 = single register; Int32/UInt32/Float32 = two registers big-endian. +/// When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here. +public sealed record ModbusTagDefinition( + string Name, + ModbusRegion Region, + ushort Address, + ModbusDataType DataType, + bool Writable = true); + +public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters } +public enum ModbusDataType { Bool, Int16, UInt16, Int32, UInt32, Float32 } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs new file mode 100644 index 0000000..5e79e90 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs @@ -0,0 +1,113 @@ +using System.Net.Sockets; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Concrete Modbus TCP transport. Wraps a single and serializes +/// requests so at most one transaction is in-flight at a time — Modbus servers typically +/// support concurrent transactions, but the single-flight model keeps the wire trace +/// easy to diagnose and avoids interleaved-response correlation bugs. +/// +public sealed class ModbusTcpTransport : IModbusTransport +{ + private readonly string _host; + private readonly int _port; + private readonly TimeSpan _timeout; + private readonly SemaphoreSlim _gate = new(1, 1); + private TcpClient? _client; + private NetworkStream? _stream; + private ushort _nextTx; + private bool _disposed; + + public ModbusTcpTransport(string host, int port, TimeSpan timeout) + { + _host = host; + _port = port; + _timeout = timeout; + } + + public async Task ConnectAsync(CancellationToken ct) + { + _client = new TcpClient(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_timeout); + await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false); + _stream = _client.GetStream(); + } + + public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + { + if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport)); + if (_stream is null) throw new InvalidOperationException("Transport not connected"); + + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var txId = ++_nextTx; + + // MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU + var adu = new byte[7 + pdu.Length]; + adu[0] = (byte)(txId >> 8); + adu[1] = (byte)(txId & 0xFF); + // protocol id already zero + var len = (ushort)(1 + pdu.Length); // unit id + pdu + adu[4] = (byte)(len >> 8); + adu[5] = (byte)(len & 0xFF); + adu[6] = unitId; + Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_timeout); + await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false); + await _stream.FlushAsync(cts.Token).ConfigureAwait(false); + + var header = new byte[7]; + await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false); + var respTxId = (ushort)((header[0] << 8) | header[1]); + if (respTxId != txId) + throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}"); + var respLen = (ushort)((header[4] << 8) | header[5]); + if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}"); + var respPdu = new byte[respLen - 1]; + await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false); + + // Exception PDU: function code has high bit set. + if ((respPdu[0] & 0x80) != 0) + { + var fc = (byte)(respPdu[0] & 0x7F); + var ex = respPdu[1]; + throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}"); + } + + return respPdu; + } + finally + { + _gate.Release(); + } + } + + private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct) + { + var read = 0; + while (read < buf.Length) + { + var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false); + if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response"); + read += n; + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try + { + if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); + } + catch { /* best-effort */ } + _client?.Dispose(); + _gate.Dispose(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj new file mode 100644 index 0000000..4d72868 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Modbus + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs new file mode 100644 index 0000000..0b31fd2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs @@ -0,0 +1,244 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +[Trait("Category", "Unit")] +public sealed class ModbusDriverTests +{ + /// + /// In-memory Modbus TCP server impl that speaks the function codes the driver uses. + /// Maintains a register/coil bank so Read/Write round-trips work. + /// + private sealed class FakeTransport : IModbusTransport + { + public readonly ushort[] HoldingRegisters = new ushort[256]; + public readonly ushort[] InputRegisters = new ushort[256]; + public readonly bool[] Coils = new bool[256]; + public readonly bool[] DiscreteInputs = new bool[256]; + public bool ForceConnectFail { get; set; } + + public Task ConnectAsync(CancellationToken ct) + => ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask; + + public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + { + var fc = pdu[0]; + return fc switch + { + 0x01 => Task.FromResult(ReadBits(pdu, Coils)), + 0x02 => Task.FromResult(ReadBits(pdu, DiscreteInputs)), + 0x03 => Task.FromResult(ReadRegs(pdu, HoldingRegisters)), + 0x04 => Task.FromResult(ReadRegs(pdu, InputRegisters)), + 0x05 => Task.FromResult(WriteCoil(pdu)), + 0x06 => Task.FromResult(WriteSingleReg(pdu)), + 0x10 => Task.FromResult(WriteMultipleRegs(pdu)), + _ => Task.FromException(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")), + }; + } + + private byte[] ReadBits(byte[] pdu, bool[] bank) + { + var addr = (ushort)((pdu[1] << 8) | pdu[2]); + var qty = (ushort)((pdu[3] << 8) | pdu[4]); + var byteCount = (byte)((qty + 7) / 8); + var resp = new byte[2 + byteCount]; + resp[0] = pdu[0]; + resp[1] = byteCount; + for (var i = 0; i < qty; i++) + if (bank[addr + i]) resp[2 + (i / 8)] |= (byte)(1 << (i % 8)); + return resp; + } + + private byte[] ReadRegs(byte[] pdu, ushort[] bank) + { + var addr = (ushort)((pdu[1] << 8) | pdu[2]); + var qty = (ushort)((pdu[3] << 8) | pdu[4]); + var byteCount = (byte)(qty * 2); + var resp = new byte[2 + byteCount]; + resp[0] = pdu[0]; + resp[1] = byteCount; + for (var i = 0; i < qty; i++) + { + resp[2 + i * 2] = (byte)(bank[addr + i] >> 8); + resp[3 + i * 2] = (byte)(bank[addr + i] & 0xFF); + } + return resp; + } + + private byte[] WriteCoil(byte[] pdu) + { + var addr = (ushort)((pdu[1] << 8) | pdu[2]); + Coils[addr] = pdu[3] == 0xFF; + return pdu; // Modbus echoes the request on write success + } + + private byte[] WriteSingleReg(byte[] pdu) + { + var addr = (ushort)((pdu[1] << 8) | pdu[2]); + HoldingRegisters[addr] = (ushort)((pdu[3] << 8) | pdu[4]); + return pdu; + } + + private byte[] WriteMultipleRegs(byte[] pdu) + { + var addr = (ushort)((pdu[1] << 8) | pdu[2]); + var qty = (ushort)((pdu[3] << 8) | pdu[4]); + for (var i = 0; i < qty; i++) + HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]); + return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] }; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private static (ModbusDriver driver, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags) + { + var fake = new FakeTransport(); + var opts = new ModbusDriverOptions { Host = "fake", Tags = tags }; + var drv = new ModbusDriver(opts, "modbus-1", _ => fake); + return (drv, fake); + } + + [Fact] + public async Task Initialize_connects_and_populates_tag_map() + { + var (drv, _) = NewDriver( + new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16), + new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool)); + await drv.InitializeAsync("{}", CancellationToken.None); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Read_Int16_holding_register_returns_BigEndian_value() + { + var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Int16)); + await drv.InitializeAsync("{}", CancellationToken.None); + fake.HoldingRegisters[10] = 12345; + + var r = await drv.ReadAsync(["Level"], CancellationToken.None); + r[0].Value.ShouldBe((short)12345); + r[0].StatusCode.ShouldBe(0u); + } + + [Fact] + public async Task Read_Float32_spans_two_registers_BigEndian() + { + var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32)); + await drv.InitializeAsync("{}", CancellationToken.None); + + // IEEE 754 single for 25.5f is 0x41CC0000 — [41 CC][00 00] big-endian across two regs. + var bytes = new byte[4]; + BinaryPrimitives.WriteSingleBigEndian(bytes, 25.5f); + fake.HoldingRegisters[4] = (ushort)((bytes[0] << 8) | bytes[1]); + fake.HoldingRegisters[5] = (ushort)((bytes[2] << 8) | bytes[3]); + + var r = await drv.ReadAsync(["Temp"], CancellationToken.None); + r[0].Value.ShouldBe(25.5f); + } + + [Fact] + public async Task Read_Coil_returns_boolean() + { + var (drv, fake) = NewDriver(new ModbusTagDefinition("Run", ModbusRegion.Coils, 3, ModbusDataType.Bool)); + await drv.InitializeAsync("{}", CancellationToken.None); + fake.Coils[3] = true; + + var r = await drv.ReadAsync(["Run"], CancellationToken.None); + r[0].Value.ShouldBe(true); + } + + [Fact] + public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception() + { + var (drv, _) = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["DoesNotExist"], CancellationToken.None); + r[0].StatusCode.ShouldBe(0x80340000u); + } + + [Fact] + public async Task Write_UInt16_holding_register_roundtrips() + { + var (drv, fake) = NewDriver(new ModbusTagDefinition("Setpoint", ModbusRegion.HoldingRegisters, 20, ModbusDataType.UInt16)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync([new WriteRequest("Setpoint", (ushort)42000)], CancellationToken.None); + results[0].StatusCode.ShouldBe(0u); + fake.HoldingRegisters[20].ShouldBe((ushort)42000); + } + + [Fact] + public async Task Write_Float32_uses_FC16_WriteMultipleRegisters() + { + var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32)); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.WriteAsync([new WriteRequest("Temp", 25.5f)], CancellationToken.None); + + // Decode back through the fake bank to check the two-register shape. + var raw = new byte[4]; + raw[0] = (byte)(fake.HoldingRegisters[4] >> 8); + raw[1] = (byte)(fake.HoldingRegisters[4] & 0xFF); + raw[2] = (byte)(fake.HoldingRegisters[5] >> 8); + raw[3] = (byte)(fake.HoldingRegisters[5] & 0xFF); + BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f); + } + + [Fact] + public async Task Write_to_InputRegister_returns_BadNotWritable() + { + var (drv, _) = NewDriver(new ModbusTagDefinition("Ro", ModbusRegion.InputRegisters, 0, ModbusDataType.UInt16, Writable: false)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.WriteAsync([new WriteRequest("Ro", (ushort)7)], CancellationToken.None); + r[0].StatusCode.ShouldBe(0x803B0000u); + } + + [Fact] + public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag() + { + var (drv, _) = NewDriver( + new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16), + new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32), + new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.Count.ShouldBe(1); + builder.Folders[0].BrowseName.ShouldBe("Modbus"); + builder.Variables.Count.ShouldBe(3); + builder.Variables.ShouldContain(v => v.BrowseName == "Level" && v.Info.DriverDataType == DriverDataType.Int32); + builder.Variables.ShouldContain(v => v.BrowseName == "Temp" && v.Info.DriverDataType == DriverDataType.Float32); + builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean); + } + + // --- helpers --- + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + public void AddProperty(string _, DriverDataType __, object? ___) { } + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj new file mode 100644 index 0000000..e7d4750 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +