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; } }