247 lines
10 KiB
C#
247 lines
10 KiB
C#
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, "物理通道范围必须为 1~24。");
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|