diff --git a/src/Meadow.Modbus.Unit.Tests/ModbusSerialTStatTests.cs b/src/Meadow.Modbus.Unit.Tests/ModbusSerialTStatTests.cs index fb071ba..e1a6f8e 100644 --- a/src/Meadow.Modbus.Unit.Tests/ModbusSerialTStatTests.cs +++ b/src/Meadow.Modbus.Unit.Tests/ModbusSerialTStatTests.cs @@ -5,8 +5,108 @@ namespace Meadow.Modbus.Unit.Tests; +public class TStat8 : ModbusPolledDevice +{ + private float _currentSetPoint; + + private const ushort SetPointRegister = 345; + + public TStat8(ModbusRtuClient client, byte modbusAddress, TimeSpan? refreshPeriod = null) + : base(client, modbusAddress, refreshPeriod) + { + MapHoldingRegistersToProperty( + startRegister: 121, + registerCount: 1, + propertyName: nameof(Temperature), + scale: 0.10); // value is in 0.1 deg + + // map to a field, not a property as the property setter needs to perform an action + MapHoldingRegistersToField( + startRegister: SetPointRegister, + registerCount: 1, + fieldName: nameof(_currentSetPoint), + scale: 0.10); + + MapHoldingRegistersToProperty( + startRegister: 198, + registerCount: 1, + propertyName: nameof(Humidity)); + + MapHoldingRegistersToProperty( + startRegister: 364, // not scaled by 0.1 + registerCount: 1, + propertyName: nameof(PowerUpSetPoint)); + + MapHoldingRegistersToProperty( + startRegister: 365, + registerCount: 1, + propertyName: nameof(MaxSetPoint)); + + MapHoldingRegistersToProperty( + startRegister: 366, + registerCount: 1, + propertyName: nameof(MinSetPoint)); + + MapHoldingRegistersToProperty( + startRegister: 410, + registerCount: 7, + propertyName: nameof(Clock), + conversionFunction: ConvertRegistersToClockTime); + } + + private object ConvertRegistersToClockTime(ushort[] data) + { + // data[2] is week, so ignore + return new DateTime(data[0], data[1], data[3], data[4], data[5], data[6]); + } + + public DateTime Clock { get; private set; } + public int Humidity { get; private set; } + public float Temperature { get; private set; } + public float MinSetPoint { get; private set; } + public float MaxSetPoint { get; private set; } + public float PowerUpSetPoint { get; private set; } + + public float SetPoint + { + get => _currentSetPoint; + set + { + _ = WriteHoldingRegister(SetPointRegister, (ushort)(value * 10)); + } + } +} + public class ModbusSerialTStatTests { + // this class assumes a connected serial Temco Controls TSTAT7 or TSTAT8 + [Fact] + public async void PolledDevicetest() + { + using (var port = new SerialPortShim("COM3", 19200, Hardware.Parity.None, 8, Hardware.StopBits.One)) + { + port.ReadTimeout = TimeSpan.FromSeconds(15); + port.Open(); + + var client = new ModbusRtuClient(port); + var tstat = new TStat8(client, 201, TimeSpan.FromSeconds(1)); + tstat.StartPolling(); + + var i = 0; + while (true) + { + await Task.Delay(1000); + Debug.WriteLine($"Temp: {tstat.Temperature}"); + Debug.WriteLine($"SetPoint: {tstat.SetPoint}"); + Debug.WriteLine($"MinSetPoint: {tstat.MinSetPoint}"); + Debug.WriteLine($"MaxSetPoint: {tstat.MaxSetPoint}"); + Debug.WriteLine($"PowerUpSetPoint: {tstat.PowerUpSetPoint}"); + Debug.WriteLine($"Humidity: {tstat.Humidity}"); + Debug.WriteLine($"Clock: {tstat.Clock}"); + } + } + } + // this class assumes a connected serial Temco Controls TSTAT7 or TSTAT8 [Fact(Skip = "Requires a connected TSTAT8 over RS485")] public async void MultipleReadHoldingRegisterTest() @@ -33,10 +133,10 @@ public async void MultipleReadHoldingRegisterTest() } } - [Fact(Skip = "Requires a connected TSTAT8 over RS485")] + [Fact] public async void MultipleWriteHoldingRegisterTest() { - using (var port = new SerialPortShim("COM4", 19200, Hardware.Parity.None, 8, Hardware.StopBits.One)) + using (var port = new SerialPortShim("COM3", 19200, Hardware.Parity.None, 8, Hardware.StopBits.One)) { port.ReadTimeout = TimeSpan.FromSeconds(15); port.Open(); diff --git a/src/Meadow.Modbus/Clients/ModbusClientBase.cs b/src/Meadow.Modbus/Clients/ModbusClientBase.cs index 6ff22c3..7ac21c7 100644 --- a/src/Meadow.Modbus/Clients/ModbusClientBase.cs +++ b/src/Meadow.Modbus/Clients/ModbusClientBase.cs @@ -17,6 +17,7 @@ public abstract class ModbusClientBase : IModbusBusClient, IDisposable private const int MaxCoilReadCount = 0x7d0; private const int MaxRegisterWriteCount = 0x7b; private const int MaxCoilWriteCount = 0x7b0; + private const int LockTimeoutMs = 2000; /// /// Event triggered when the client is disconnected. @@ -143,7 +144,10 @@ public async Task WriteHoldingRegister(byte modbusAddress, ushort register, usho // swap endianness, because Modbus var data = BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)value)); var message = GenerateWriteMessage(modbusAddress, ModbusFunction.WriteRegister, register, data); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return; + } try { @@ -185,7 +189,10 @@ public async Task WriteHoldingRegisters(byte modbusAddress, ushort startRegister var data = values.SelectMany(i => BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)i))).ToArray(); var message = GenerateWriteMessage(modbusAddress, ModbusFunction.WriteMultipleRegisters, startRegister, data); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return; + } try { @@ -237,7 +244,10 @@ public async Task ReadHoldingRegisters(byte modbusAddress, ushort star } var message = GenerateReadMessage(modbusAddress, ModbusFunction.ReadHoldingRegister, startRegister, registerCount); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return Array.Empty(); + } byte[] result; @@ -279,7 +289,10 @@ public async Task ReadInputRegisters(byte modbusAddress, ushort startR if (registerCount > MaxRegisterReadCount) throw new ArgumentException($"A maximum of {MaxRegisterReadCount} registers can be retrieved at one time"); var message = GenerateReadMessage(modbusAddress, ModbusFunction.ReadInputRegister, startRegister, registerCount); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return Array.Empty(); + } byte[] result; @@ -308,7 +321,11 @@ public async Task WriteCoil(byte modbusAddress, ushort register, bool value) var message = GenerateWriteMessage(modbusAddress, ModbusFunction.WriteCoil, register, data); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return; + } + try { await DeliverMessage(message); @@ -339,7 +356,10 @@ public async Task WriteMultipleCoils(byte modbusAddress, ushort startRegister, I new BitArray(values.ToArray()).CopyTo(msgSegment, 3); // Concatinate bool binary values list as converted bytes var message = GenerateWriteMessage(modbusAddress, ModbusFunction.WriteMultipleCoils, startRegister, msgSegment); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return; + } try { @@ -362,7 +382,10 @@ public async Task ReadCoils(byte modbusAddress, ushort startCoil, int co if (coilCount > MaxCoilReadCount) throw new ArgumentException($"A maximum of {MaxCoilReadCount} coils can be retrieved at one time"); var message = GenerateReadMessage(modbusAddress, ModbusFunction.ReadCoil, startCoil, coilCount); - await _syncRoot.WaitAsync(); + if (!await _syncRoot.WaitAsync(LockTimeoutMs)) + { + return Array.Empty(); + } byte[] result; diff --git a/src/Meadow.Modbus/Clients/ModbusRtuClient.cs b/src/Meadow.Modbus/Clients/ModbusRtuClient.cs index f993ff5..989129b 100644 --- a/src/Meadow.Modbus/Clients/ModbusRtuClient.cs +++ b/src/Meadow.Modbus/Clients/ModbusRtuClient.cs @@ -36,6 +36,7 @@ public class ModbusRtuClient : ModbusClientBase public ModbusRtuClient(ISerialPort port, IDigitalOutputPort? enablePort = null) { _port = port; + _port.WriteTimeout = _port.ReadTimeout = TimeSpan.FromSeconds(5); _enable = enablePort; } diff --git a/src/Meadow.Modbus/Meadow.Modbus.csproj b/src/Meadow.Modbus/Meadow.Modbus.csproj index 6f8f347..a569018 100644 --- a/src/Meadow.Modbus/Meadow.Modbus.csproj +++ b/src/Meadow.Modbus/Meadow.Modbus.csproj @@ -23,6 +23,6 @@ - + diff --git a/src/Meadow.Modbus/ModbusPolledDevice.cs b/src/Meadow.Modbus/ModbusPolledDevice.cs new file mode 100644 index 0000000..7633b1c --- /dev/null +++ b/src/Meadow.Modbus/ModbusPolledDevice.cs @@ -0,0 +1,527 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Meadow.Modbus; + +/// +/// Represents an abstract base class for Modbus devices where register values are polled +/// +public abstract class ModbusPolledDevice +{ + /// + /// Represents the possible formats of source registers + /// + public enum SourceFormat + { + /// + /// Little-endian integer format. + /// + LittleEndianInteger, + + /// + /// Big-endian integer format. + /// + BigEndianInteger, + + /// + /// Little-endian IEEE 794 floating-point format. + /// + LittleEndianFloat, + + /// + /// Big-endian IEEE 794 floating-point format. + /// + BigEndianFloat, + } + + private class RegisterMapping + { + public ushort StartRegister { get; set; } + public int RegisterCount { get; set; } + public FieldInfo? FieldInfo { get; set; } + public PropertyInfo? PropertyInfo { get; set; } + public double? Scale { get; set; } + public double? Offset { get; set; } + public Func? ConversionFunction { get; set; } + public SourceFormat SourceFormat { get; set; } = SourceFormat.LittleEndianInteger; + } + + private const int MinimumPollDelayMs = 100; + private readonly SemaphoreSlim _mapLock = new SemaphoreSlim(1, 1); + + /// + /// Gets the default refresh period for polling. + /// + public static readonly TimeSpan DefaultRefreshPeriod = TimeSpan.FromSeconds(5); + + private ModbusClientBase _client; + private byte _address; + private Timer _timer; + private int _refreshPeriosMs; + + private List _mapping = new(); + + /// + /// Starts polling the Modbus device. + /// + public virtual void StartPolling() + { + _timer.Change(_refreshPeriosMs, -1); + } + + /// + /// Stops polling the Modbus device. + /// + public virtual void StopPolling() + { + _timer.Change(-1, -1); + } + + /// + /// Initializes a new instance of the class. + /// + /// The Modbus client for communication. + /// The Modbus address of the device. + /// The optional refresh period for polling. + public ModbusPolledDevice(ModbusClientBase client, byte modbusAddress, TimeSpan? refreshPeriod = null) + { + _client = client; + _address = modbusAddress; + _refreshPeriosMs = (int)(refreshPeriod ?? DefaultRefreshPeriod).TotalMilliseconds; + _timer = new Timer(RefreshTimerProc, null, -1, -1); + } + + /// + /// Writes one or more values to the holding registers of the Modbus device. + /// + /// The starting register address. + /// The values to be written to the holding registers. + /// A task representing the asynchronous write operation. + protected async Task WriteHoldingRegister(ushort startRegister, params ushort[] data) + { + if (data.Length == 1) + { + await _client.WriteHoldingRegister(_address, startRegister, data[0]); + } + + await _client.WriteHoldingRegisters(_address, startRegister, data); + } + + /// + /// Maps a range of holding registers to a property of the Modbus device. + /// + /// The starting register address. + /// The number of registers to map. + /// The name of the property to map the registers to. + /// The optional scale factor to apply to the register values. + /// The optional offset to apply to the register values. + /// The format of the source registers + protected void MapHoldingRegistersToProperty( + ushort startRegister, + int registerCount, + string propertyName, + double? scale = null, + double? offset = null, + SourceFormat sourceFormat = SourceFormat.LittleEndianInteger) + { + _mapLock.Wait(); + try + { + var prop = this.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new ArgumentException($"Property '{propertyName}' not found"); + + _mapping.Add(new RegisterMapping + { + PropertyInfo = prop, + StartRegister = startRegister, + RegisterCount = registerCount, + Scale = scale, + Offset = offset, + SourceFormat = sourceFormat + }); + } + finally + { + _mapLock.Release(); + } + } + + /// + /// Maps a range of holding registers to a field of the Modbus device. + /// + /// The starting register address. + /// The number of registers to map. + /// The name of the field to map the registers to. + /// The optional scale factor to apply to the register values. + /// The optional offset to apply to the register values. + /// The format of the source registers + protected void MapHoldingRegistersToField( + ushort startRegister, + int registerCount, + string fieldName, + double? scale = null, + double? offset = null, + SourceFormat sourceFormat = SourceFormat.LittleEndianInteger) + { + _mapLock.Wait(); + try + { + var field = this.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new ArgumentException($"Field '{fieldName}' not found"); + + _mapping.Add(new RegisterMapping + { + FieldInfo = field, + StartRegister = startRegister, + RegisterCount = registerCount, + Scale = scale, + Offset = offset, + SourceFormat = sourceFormat + }); + } + finally + { + _mapLock.Release(); + } + } + + /// + /// Maps a range of holding registers to a property of the Modbus device. + /// + /// The starting register address. + /// The number of registers to map. + /// The name of the property to map the registers to. + /// The custom conversion function to transform raw register values to the property type. + protected void MapHoldingRegistersToProperty(ushort startRegister, int registerCount, string propertyName, Func conversionFunction) + { + _mapLock.Wait(); + try + { + var prop = this.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new ArgumentException($"Property '{propertyName}' not found"); + + _mapping.Add(new RegisterMapping + { + PropertyInfo = prop, + StartRegister = startRegister, + RegisterCount = registerCount, + ConversionFunction = conversionFunction + }); + } + finally + { + _mapLock.Release(); + } + } + + /// + /// Maps a range of holding registers to a field of the Modbus device. + /// + /// The starting register address. + /// The number of registers to map. + /// The name of the property to map the registers to. + /// The custom conversion function to transform raw register values to the field type. + protected void MapHoldingRegistersToField(ushort startRegister, int registerCount, string fieldName, Func conversionFunction) + { + _mapLock.Wait(); + try + { + var field = this.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new ArgumentException($"Field '{fieldName}' not found"); + + _mapping.Add(new RegisterMapping + { + FieldInfo = field, + StartRegister = startRegister, + RegisterCount = registerCount, + ConversionFunction = conversionFunction + }); + } + finally + { + _mapLock.Release(); + } + } + + private async void RefreshTimerProc(object _) + { + var start = Environment.TickCount; + + await _mapLock.WaitAsync(); + try + { + // TODO: add support for group reads (i.e. contiguously mapped registers) + + foreach (var r in _mapping) + { + // read registers + ushort[] data; + + try + { + data = await _client.ReadHoldingRegisters(_address, r.StartRegister, r.RegisterCount); + } + catch (TimeoutException) + { + break; + } + + if (data.Length == 0) + { + // TODO: should we notify or log or something? + return; + } + + if (r.PropertyInfo != null) + { + UpdateProperty(data, r); + } + else if (r.FieldInfo != null) + { + UpdateField(data, r); + } + else + { + // no field or prop - should not be possible + throw new ArgumentException(); + } + + } + } + finally + { + _mapLock.Release(); + } + + // subtract execution time from desired period + var et = Environment.TickCount - start; + var delay = _refreshPeriosMs - et; + _timer.Change(delay > MinimumPollDelayMs ? delay : MinimumPollDelayMs, -1); + } + + private void UpdateProperty(ushort[] data, RegisterMapping mapping) + { + if (mapping.ConversionFunction != null) + { + var converted = mapping.ConversionFunction(data); + mapping.PropertyInfo!.SetValue(this, converted); + } + else + { + if ( + mapping.PropertyInfo!.PropertyType == typeof(double) || + mapping.PropertyInfo!.PropertyType == typeof(float)) + { + UpdateDoubleProperty(data, mapping); + } + else if ( + mapping.PropertyInfo!.PropertyType == typeof(byte) || + mapping.PropertyInfo!.PropertyType == typeof(short) || + mapping.PropertyInfo!.PropertyType == typeof(int) || + mapping.PropertyInfo!.PropertyType == typeof(long)) + { + UpdateIntegerProperty(data, mapping); + } + else + { + throw new NotSupportedException(); + } + } + } + + private void UpdateField(ushort[] data, RegisterMapping mapping) + { + if (mapping.ConversionFunction != null) + { + var converted = mapping.ConversionFunction(data); + mapping.FieldInfo!.SetValue(this, converted); + } + else + { + if ( + mapping.FieldInfo!.FieldType == typeof(double) || + mapping.FieldInfo!.FieldType == typeof(float)) + { + UpdateDoubleField(data, mapping); + } + else if ( + mapping.FieldInfo!.FieldType == typeof(byte) || + mapping.FieldInfo!.FieldType == typeof(short) || + mapping.FieldInfo!.FieldType == typeof(int) || + mapping.FieldInfo!.FieldType == typeof(long)) + { + UpdateIntegerField(data, mapping); + } + else + { + throw new NotSupportedException(); + } + } + } + + /// + /// Retrieves the value from the specified register array based on the provided data format. + /// + /// The array of ushort register values. + /// The source data format indicating how to interpret the values in the data array. + /// The extracted value in its raw form. + protected object GetValueByFormat(ushort[] data, SourceFormat format) + { + Span bytes = MemoryMarshal.Cast(data); + + switch (format) + { + case SourceFormat.LittleEndianFloat: + return data.Length switch + { + 1 => BitConverter.ToSingle(bytes[..2]), + 2 => BitConverter.ToSingle(bytes[..4]), + 4 => BitConverter.ToDouble(bytes[..8]), + _ => throw new ArgumentException() + }; + case SourceFormat.BigEndianFloat: + Span bebytes = bytes + .ToArray() + .Reverse() + .ToArray() + .AsSpan(); + return data.Length switch + { + 1 => BitConverter.ToSingle(bebytes[..2]), + 2 => BitConverter.ToSingle(bebytes[..4]), + 4 => BitConverter.ToDouble(bebytes[..8]), + _ => throw new ArgumentException() + }; + case SourceFormat.BigEndianInteger: + return data.Length switch + { + 1 => BinaryPrimitives.ReadInt16BigEndian(bytes[..2]), + 2 => BinaryPrimitives.ReadInt32BigEndian(bytes[..4]), + 4 => BinaryPrimitives.ReadInt64BigEndian(bytes[..8]), + _ => throw new ArgumentException() + }; + default: + return data.Length switch + { + 1 => BinaryPrimitives.ReadInt16LittleEndian(bytes[..2]), + 2 => BinaryPrimitives.ReadInt32LittleEndian(bytes[..4]), + 4 => BinaryPrimitives.ReadInt64LittleEndian(bytes[..8]), + _ => throw new ArgumentException() + }; + } + } + + private void UpdateIntegerProperty(ushort[] data, RegisterMapping mapping) + { + var final = Convert.ToInt64(GetValueByFormat(data, mapping.SourceFormat)); + + if (mapping.Scale != null) + { + final = (long)(final * mapping.Scale.Value); + } + if (mapping.Offset != null) + { + final = (long)(final + mapping.Offset.Value); + } + + if (mapping.PropertyInfo!.PropertyType == typeof(byte)) + { + mapping.PropertyInfo!.SetValue(this, Convert.ToByte(final)); + } + else if (mapping.PropertyInfo!.PropertyType == typeof(short)) + { + mapping.PropertyInfo!.SetValue(this, Convert.ToInt16(final)); + } + else if (mapping.PropertyInfo!.PropertyType == typeof(int)) + { + mapping.PropertyInfo!.SetValue(this, Convert.ToInt32(final)); + } + else if (mapping.PropertyInfo!.PropertyType == typeof(long)) + { + mapping.PropertyInfo!.SetValue(this, final); + } + } + + private void UpdateDoubleProperty(ushort[] data, RegisterMapping mapping) + { + var final = Convert.ToDouble(GetValueByFormat(data, mapping.SourceFormat)); + + if (mapping.Scale != null) + { + final *= mapping.Scale.Value; + } + if (mapping.Offset != null) + { + final += mapping.Offset.Value; + } + + if (mapping.PropertyInfo!.PropertyType == typeof(double)) + { + mapping.PropertyInfo!.SetValue(this, final); + } + else if (mapping.PropertyInfo!.PropertyType == typeof(float)) + { + mapping.PropertyInfo!.SetValue(this, Convert.ToSingle(final)); + } + } + + private void UpdateIntegerField(ushort[] data, RegisterMapping mapping) + { + var final = Convert.ToInt64(GetValueByFormat(data, mapping.SourceFormat)); + + if (mapping.Scale != null) + { + final = (long)(final * mapping.Scale.Value); + } + if (mapping.Offset != null) + { + final = (long)(final + mapping.Offset.Value); + } + + if (mapping.FieldInfo!.FieldType == typeof(byte)) + { + mapping.FieldInfo!.SetValue(this, Convert.ToByte(final)); + } + else if (mapping.FieldInfo!.FieldType == typeof(short)) + { + mapping.FieldInfo!.SetValue(this, Convert.ToInt16(final)); + } + else if (mapping.FieldInfo!.FieldType == typeof(int)) + { + mapping.FieldInfo!.SetValue(this, Convert.ToInt32(final)); + } + else if (mapping.FieldInfo!.FieldType == typeof(long)) + { + mapping.FieldInfo!.SetValue(this, final); + } + } + + private void UpdateDoubleField(ushort[] data, RegisterMapping mapping) + { + var final = Convert.ToDouble(GetValueByFormat(data, mapping.SourceFormat)); + + if (mapping.Scale != null) + { + final *= mapping.Scale.Value; + } + if (mapping.Offset != null) + { + final += mapping.Offset.Value; + } + + if (mapping.FieldInfo!.FieldType == typeof(double)) + { + mapping.FieldInfo!.SetValue(this, final); + } + else if (mapping.FieldInfo!.FieldType == typeof(float)) + { + mapping.FieldInfo!.SetValue(this, Convert.ToSingle(final)); + } + } +}