SSPC-Tester/SSPCTester.Devices/Drivers/Real/SspcModbus.cs
2026-06-30 12:10:29 +08:00

247 lines
10 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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