SSPC-Tester/SSPCTester.Devices/Drivers/Real/SspcModbus.cs

247 lines
10 KiB
C#
Raw Normal View History

using System.Diagnostics;
using System.IO.Ports;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SSPCTester.Devices.Config;
using SSPCTester.Devices.Interfaces;
using SSPCTester.Devices.Protocol;
namespace SSPCTester.Devices.Drivers.Real;
/// <summary>按实测 Profile 实现的 24 路 Modbus-RTU SSPC 驱动。</summary>
public sealed class SspcModbus : ISspc, IDisposable
{
private readonly DeviceOptions _rootOptions;
private readonly ILogger<SspcModbus> _logger;
private readonly SemaphoreSlim _busLock = new(1, 1);
private readonly bool[] _states = new bool[24];
private SerialPort? _port;
private ModbusDeviceProfile? _profile;
public SspcModbus(IOptions<DeviceOptions> options, ILogger<SspcModbus> logger)
{
_rootOptions = options.Value;
_logger = logger;
}
public int ChannelCount => 24;
public bool IsConnected => _port?.IsOpen == true;
public string DisplayName => "SSPC (Modbus-RTU)";
public event EventHandler<bool>? ConnectionChanged;
public Task<bool> ConnectAsync(CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (IsConnected) return Task.FromResult(true);
var options = _rootOptions.Sspc;
_profile = ModbusDeviceProfileLoader.Load(options.DeviceProfilePath);
var defaults = _profile.SerialDefaults;
_port = new SerialPort(
options.PortName,
options.BaudRate > 0 ? options.BaudRate : defaults.BaudRate,
ParseParity(options.Parity),
options.DataBits > 0 ? options.DataBits : defaults.DataBits,
ParseStopBits(options.StopBits))
{
ReadTimeout = options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : defaults.ReadTimeoutMs,
WriteTimeout = options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : defaults.ReadTimeoutMs,
Handshake = Handshake.None
};
_port.Open();
_logger.LogInformation("Modbus 已连接:{Port} {BaudRate}bps", _port.PortName, _port.BaudRate);
ConnectionChanged?.Invoke(this, true);
return Task.FromResult(true);
}
public async Task DisconnectAsync()
{
if (!IsConnected) return;
if (_rootOptions.Sspc.CloseAllRelaysOnDisconnect)
{
for (int slot = 1; slot <= ChannelCount; slot++)
{
try { await TurnOffAsync(slot).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "断开前关闭 CH{Slot} 失败。", slot); }
}
}
_port?.Close();
_port?.Dispose();
_port = null;
ConnectionChanged?.Invoke(this, false);
_logger.LogInformation("Modbus 已断开。");
}
public Task TurnOnAsync(int physicalSlot, CancellationToken ct = default) =>
SetRelayAsync(physicalSlot, true, ct);
public Task TurnOffAsync(int physicalSlot, CancellationToken ct = default) =>
SetRelayAsync(physicalSlot, false, ct);
public async Task FlashAsync(int physicalSlot, int milliseconds, CancellationToken ct = default)
{
await TurnOnAsync(physicalSlot, ct).ConfigureAwait(false);
try { await Task.Delay(milliseconds, ct).ConfigureAwait(false); }
finally { await TurnOffAsync(physicalSlot, CancellationToken.None).ConfigureAwait(false); }
}
public Task<bool> GetChannelStateAsync(int physicalSlot, CancellationToken ct = default)
{
ValidateSlot(physicalSlot);
return Task.FromResult(_states[physicalSlot - 1]);
}
public async Task<ChannelMeasurement> ReadMeasurementAsync(int physicalSlot, CancellationToken ct = default)
{
ValidateSlot(physicalSlot);
var all = await ReadMeasurementsAsync(ct).ConfigureAwait(false);
return all[physicalSlot - 1];
}
public async Task<IReadOnlyList<ChannelMeasurement>> ReadMeasurementsAsync(CancellationToken ct = default)
{
EnsureConnected();
var profile = _profile!;
var voltageFrame = await ReadRegistersAsync(profile.Voltage, ct).ConfigureAwait(false);
var currentFrame = await ReadRegistersAsync(profile.Current, ct).ConfigureAwait(false);
var timestamp = DateTimeOffset.Now;
return profile.Channels.OrderBy(x => x.PhysicalSlot).Select(mapping =>
{
ushort vRaw = ReadUInt16(voltageFrame, profile.Voltage, mapping.Voltage.RegisterOffset);
ushort currentBits = ReadUInt16(currentFrame, profile.Current, mapping.Current.RegisterOffset);
short iRaw = unchecked((short)currentBits);
return new ChannelMeasurement
{
PhysicalSlot = mapping.PhysicalSlot,
VoltageRaw = vRaw,
CurrentRaw = iRaw,
Voltage = ModbusValueConverter.ConvertRaw(
vRaw, profile.Voltage.Signed, profile.Voltage.Formula, profile.Voltage.Range),
Current = Math.Abs(ModbusValueConverter.ConvertRaw(
currentBits, profile.Current.Signed, profile.Current.Formula, profile.Current.Range)),
Timestamp = timestamp
};
}).ToArray();
}
private async Task SetRelayAsync(int physicalSlot, bool isOn, CancellationToken ct)
{
ValidateSlot(physicalSlot);
EnsureConnected();
var mapping = _profile!.Channels.Single(x => x.PhysicalSlot == physicalSlot);
ushort value = isOn ? _profile.Relay.OnValueNumber : _profile.Relay.OffValueNumber;
byte[] request = ModbusRtuProtocol.BuildRequest(
mapping.Relay.Slave, _profile.Relay.FunctionCodeWrite, mapping.Relay.Coil, value);
byte[] response = await TransactAsync(request, 8, ct).ConfigureAwait(false);
if (_profile.Relay.VerifyEchoResponse && !response.SequenceEqual(request))
throw new InvalidDataException($"继电器 CH{physicalSlot} 回显与请求不一致。");
_states[physicalSlot - 1] = isOn;
}
private async Task<byte[]> ReadRegistersAsync(MeasurementProtocol protocol, CancellationToken ct)
{
byte[] request = ModbusRtuProtocol.BuildRequest(
protocol.Slave, protocol.FunctionCodeRead, protocol.StartRegister, protocol.RegisterCount);
int expectedLength = 3 + protocol.RegisterCount * 2 + 2;
byte[] response = await TransactAsync(request, expectedLength, ct).ConfigureAwait(false);
if (response[0] != protocol.Slave) throw new InvalidDataException("Modbus 响应从站地址错误。");
if (response[1] != protocol.FunctionCodeRead) throw new InvalidDataException("Modbus 响应功能码错误。");
if (response[2] != protocol.RegisterCount * 2) throw new InvalidDataException("Modbus 响应字节数错误。");
return response;
}
private async Task<byte[]> TransactAsync(byte[] request, int expectedLength, CancellationToken ct)
{
await _busLock.WaitAsync(ct).ConfigureAwait(false);
try
{
int retry = Math.Max(0, _rootOptions.Sspc.Retry);
Exception? last = null;
for (int attempt = 0; attempt <= retry; attempt++)
{
ct.ThrowIfCancellationRequested();
try
{
var sw = Stopwatch.StartNew();
_port!.DiscardInBuffer();
_port.Write(request, 0, request.Length);
byte[] response = ReadResponse(expectedLength, ct);
ModbusRtuProtocol.ValidateCrc(response);
if (response[0] != request[0]) throw new InvalidDataException("Modbus 响应从站与请求不一致。");
if ((response[1] & 0x80) != 0)
throw new InvalidDataException($"Modbus 异常响应,异常码 0x{response[2]:X2}。");
LogFrame(request, response, sw.ElapsedMilliseconds, null);
return response;
}
catch (Exception ex) when (ex is TimeoutException or InvalidDataException or IOException)
{
last = ex;
LogFrame(request, null, 0, ex);
try { _port?.DiscardInBuffer(); } catch { }
}
}
throw new IOException($"Modbus 请求失败,已尝试 {retry + 1} 次。", last);
}
finally { _busLock.Release(); }
}
private byte[] ReadResponse(int normalLength, CancellationToken ct)
{
var bytes = new List<byte>(normalLength);
while (bytes.Count < 3)
{
ct.ThrowIfCancellationRequested();
bytes.Add((byte)_port!.ReadByte());
}
int length = (bytes[1] & 0x80) != 0 ? 5 : normalLength;
while (bytes.Count < length)
{
ct.ThrowIfCancellationRequested();
bytes.Add((byte)_port!.ReadByte());
}
return bytes.ToArray();
}
private void LogFrame(byte[] request, byte[]? response, long elapsedMs, Exception? error)
{
if (!_rootOptions.Sspc.LogRawFrames && error == null) return;
string tx = Convert.ToHexString(request);
string rx = response == null ? "-" : Convert.ToHexString(response);
if (error == null)
_logger.LogInformation("Modbus TX={Tx} RX={Rx} {Elapsed}ms", tx, rx, elapsedMs);
else
_logger.LogWarning(error, "Modbus TX={Tx} RX={Rx} 失败", tx, rx);
}
private static ushort ReadUInt16(byte[] frame, MeasurementProtocol p, int registerOffset)
{
int position = p.DataStartOffset + registerOffset * p.ChannelBytes;
if (position + 1 >= frame.Length - 2) throw new InvalidDataException("寄存器映射超出响应数据区。");
return (ushort)((frame[position] << 8) | frame[position + 1]);
}
private void EnsureConnected()
{
if (!IsConnected || _profile == null) throw new InvalidOperationException("Modbus 尚未连接。");
}
private static void ValidateSlot(int slot)
{
if (slot is < 1 or > 24)
throw new ArgumentOutOfRangeException(nameof(slot), slot, "物理通道范围必须为 124。");
}
private static Parity ParseParity(string value) =>
Enum.TryParse<Parity>(value, true, out var result) ? result : Parity.None;
private static StopBits ParseStopBits(string value) =>
Enum.TryParse<StopBits>(value, true, out var result) ? result : StopBits.One;
public void Dispose()
{
try { _port?.Dispose(); } catch { }
_busLock.Dispose();
}
}