Initial commit
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
using System.Timers;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
using RobotNet10.Shared;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Tada;
|
||||
|
||||
/// <summary>
|
||||
/// BMU driver cho TADA RS485
|
||||
/// </summary>
|
||||
[Device(DeviceType.Battery, "Tada", "TadaBattery", "1.0.0", Description = "Battery Tada Driver")]
|
||||
public class TadaBattery : DeviceBase, IBattery
|
||||
{
|
||||
private readonly ILogger<TadaBattery> _logger;
|
||||
private readonly Lock _dataLock = new();
|
||||
|
||||
private TadaRs485Client? _client;
|
||||
|
||||
// Polling
|
||||
private System.Timers.Timer? _pollingTimer;
|
||||
|
||||
// Cached (THEO TÀI LIỆU RS485)
|
||||
private double _cachedChargeLevel; // SOC %
|
||||
private double _cachedVoltage; // V
|
||||
private double _cachedCurrent; // A
|
||||
private bool _cachedCharging; // Current > 0
|
||||
|
||||
private double? _cachedHealth; // SOH %
|
||||
private double? _cachedTemperature; // Celsius
|
||||
private double? _cachedRemainingCapacity; // Ah
|
||||
private double? _cachedFullCapacity; // Wh (RemainEnergy)
|
||||
|
||||
private int? _cachedChargeTime; // minutes (time to full)
|
||||
private int? _cachedDischargeTime; // minutes (time to empty)
|
||||
private int? _cachedStatusRaw; // raw bit flags
|
||||
|
||||
private DateTime _lastUpdateTime = DateTime.MinValue;
|
||||
private BatteryState? _cachedBatteryState;
|
||||
|
||||
// IBattery Implementation
|
||||
public BatteryState? CurrentBatteryState
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedBatteryState; } }
|
||||
}
|
||||
|
||||
private readonly string _portName;
|
||||
private readonly int _baud;
|
||||
|
||||
public TadaBattery(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider)
|
||||
: base(deviceId, deviceName, DeviceType.Battery)
|
||||
{
|
||||
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
_logger = loggerFactory.CreateLogger<TadaBattery>();
|
||||
|
||||
_portName = connection.GetValue<string>("Port") ?? throw new Exception("Port is required");
|
||||
_baud = connection.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
|
||||
|
||||
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true;
|
||||
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 2000;
|
||||
MaxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts") ?? 0;
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
// DeviceBase overrides
|
||||
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_client?.Dispose();
|
||||
_client = new TadaRs485Client(_logger, _portName, _baud);
|
||||
}
|
||||
UpdateProperties();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartPollingLoop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopPollingLoop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopPollingLoop();
|
||||
lock (_dataLock)
|
||||
{
|
||||
_client?.ForceReconnect(); // now exists
|
||||
ResetCache();
|
||||
}
|
||||
UpdateProperties();
|
||||
StartPollingLoop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
return Task.FromResult(_client != null && !_client.IsFaulted);
|
||||
}
|
||||
|
||||
// Polling
|
||||
private void StartPollingLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_pollingTimer != null && _pollingTimer.Enabled)
|
||||
return;
|
||||
|
||||
StopPollingLoop(); // Đảm bảo không có timer nào đang chạy
|
||||
|
||||
_pollingTimer = new System.Timers.Timer(500) // 2Hz = 500ms interval
|
||||
{
|
||||
AutoReset = true,
|
||||
Enabled = true
|
||||
};
|
||||
_pollingTimer.Elapsed += PollTimer_Elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void StopPollingLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_pollingTimer != null)
|
||||
{
|
||||
_pollingTimer.Stop();
|
||||
_pollingTimer.Elapsed -= PollTimer_Elapsed;
|
||||
_pollingTimer.Dispose();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PollTimer_Elapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_client?.RequestStatus();
|
||||
var data = _client?.ReadResponse();
|
||||
if (data != null)
|
||||
UpdateCache(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "RS485 polling error");
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCache
|
||||
private void UpdateCache(Dictionary<string, double> data)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// CHARGE LEVEL
|
||||
if (data.TryGetValue("SOC", out var soc))
|
||||
{
|
||||
if (Math.Abs(soc - _cachedChargeLevel) > 0.1)
|
||||
{
|
||||
_cachedChargeLevel = soc;
|
||||
}
|
||||
}
|
||||
|
||||
// VOLTAGE
|
||||
if (data.TryGetValue("Voltage", out var volt))
|
||||
{
|
||||
if (Math.Abs(volt - _cachedVoltage) > 0.05)
|
||||
{
|
||||
_cachedVoltage = volt;
|
||||
}
|
||||
}
|
||||
|
||||
// CURRENT
|
||||
if (data.TryGetValue("Current", out var curr))
|
||||
{
|
||||
if (Math.Abs(curr - _cachedCurrent) > 0.05)
|
||||
{
|
||||
_cachedCurrent = curr;
|
||||
}
|
||||
}
|
||||
|
||||
// CHARGING
|
||||
bool newCharging = _cachedCurrent > 0;
|
||||
if (newCharging != _cachedCharging)
|
||||
{
|
||||
_cachedCharging = newCharging;
|
||||
}
|
||||
|
||||
// TEMPERATURE
|
||||
if (data.TryGetValue("Temp", out var t))
|
||||
{
|
||||
if (_cachedTemperature == null || Math.Abs(t - _cachedTemperature.Value) > 0.1)
|
||||
{
|
||||
_cachedTemperature = t;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields (per document)
|
||||
_cachedHealth = data.TryGetValue("SOH", out var soh) ? soh : null;
|
||||
_cachedRemainingCapacity = data.TryGetValue("RemainCapacity", out var rc) ? rc : null;
|
||||
_cachedFullCapacity = data.TryGetValue("RemainEnergy", out var re) ? re : null;
|
||||
|
||||
_cachedChargeTime = data.TryGetValue("ChargeTime", out var ct) ? (int)ct : null;
|
||||
_cachedDischargeTime = data.TryGetValue("DischargeTime", out var dt) ? (int)dt : null;
|
||||
_cachedStatusRaw = data.TryGetValue("Status", out var st) ? (int)st : null;
|
||||
|
||||
_lastUpdateTime = now;
|
||||
|
||||
// Update cached BatteryState
|
||||
_cachedBatteryState = CreateBatteryStateFromCache();
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetCache()
|
||||
{
|
||||
_cachedChargeLevel = 0;
|
||||
_cachedVoltage = 0;
|
||||
_cachedCurrent = 0;
|
||||
_cachedCharging = false;
|
||||
|
||||
_cachedHealth = null;
|
||||
_cachedTemperature = null;
|
||||
_cachedRemainingCapacity = null;
|
||||
_cachedFullCapacity = null;
|
||||
|
||||
_cachedChargeTime = null;
|
||||
_cachedDischargeTime = null;
|
||||
_cachedStatusRaw = null;
|
||||
|
||||
_lastUpdateTime = DateTime.MinValue;
|
||||
_cachedBatteryState = null;
|
||||
}
|
||||
|
||||
// IBattery Implementation
|
||||
public Task<BatteryState> ReadBatteryStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_cachedBatteryState.HasValue)
|
||||
{
|
||||
return Task.FromResult(_cachedBatteryState.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult(CreateBatteryStateFromCache());
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to create BatteryState from cached values
|
||||
private BatteryState CreateBatteryStateFromCache()
|
||||
{
|
||||
// Map PowerSupplyStatus from charging state
|
||||
byte powerSupplyStatus = BatteryState.PowerSupplyStatusUnknown;
|
||||
if (_cachedCharging)
|
||||
powerSupplyStatus = BatteryState.PowerSupplyStatusCharging;
|
||||
else if (_cachedCurrent < 0)
|
||||
powerSupplyStatus = BatteryState.PowerSupplyStatusDischarging;
|
||||
else if (_cachedCurrent == 0)
|
||||
powerSupplyStatus = BatteryState.PowerSupplyStatusNotCharging;
|
||||
|
||||
// Map PowerSupplyHealth from health percentage
|
||||
byte powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown;
|
||||
if (_cachedHealth.HasValue)
|
||||
{
|
||||
if (_cachedHealth.Value >= 80)
|
||||
powerSupplyHealth = BatteryState.PowerSupplyHealthGood;
|
||||
else if (_cachedHealth.Value < 20)
|
||||
powerSupplyHealth = BatteryState.PowerSupplyHealthDead;
|
||||
}
|
||||
|
||||
return new BatteryState
|
||||
{
|
||||
Header = new Header
|
||||
{
|
||||
Stamp = _lastUpdateTime != DateTime.MinValue ? _lastUpdateTime : DateTime.UtcNow,
|
||||
FrameId = "battery_frame"
|
||||
},
|
||||
Voltage = _cachedVoltage,
|
||||
Current = _cachedCurrent,
|
||||
Charge = _cachedRemainingCapacity.HasValue ? _cachedRemainingCapacity.Value : double.NaN,
|
||||
Capacity = _cachedFullCapacity.HasValue ? _cachedFullCapacity.Value : double.NaN,
|
||||
DesignCapacity = double.NaN, // Not provided by TADA BMU
|
||||
Percentage = _cachedChargeLevel,
|
||||
PowerSupplyStatus = powerSupplyStatus,
|
||||
PowerSupplyHealth = powerSupplyHealth,
|
||||
PowerSupplyTechnology = BatteryState.PowerSupplyTechnologyUnknown, // Not provided by TADA BMU
|
||||
Present = true,
|
||||
CellVoltage = Array.Empty<double>(), // Not provided by TADA BMU
|
||||
CellTemperature = _cachedTemperature.HasValue ? new[] { _cachedTemperature.Value } : Array.Empty<double>(),
|
||||
Location = string.Empty,
|
||||
SerialNumber = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
// UpdateProperties
|
||||
private void UpdateProperties()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
SetProperty("ChargeLevel", _cachedChargeLevel.ToString("F1"));
|
||||
SetProperty("Voltage", _cachedVoltage.ToString("F2"));
|
||||
SetProperty("Current", _cachedCurrent.ToString("F2"));
|
||||
SetProperty("Charging", _cachedCharging.ToString());
|
||||
SetProperty("Temperature", _cachedTemperature?.ToString("F1") ?? "0");
|
||||
SetProperty("Health", _cachedHealth?.ToString("F0") ?? "0");
|
||||
SetProperty("RemainCapacity", _cachedRemainingCapacity?.ToString("F2") ?? "0");
|
||||
SetProperty("FullCapacity", _cachedFullCapacity?.ToString("F2") ?? "0");
|
||||
|
||||
SetProperty("ChargeTime", _cachedChargeTime?.ToString() ?? "0");
|
||||
SetProperty("DischargeTime", _cachedDischargeTime?.ToString() ?? "0");
|
||||
SetProperty("StatusRaw", _cachedStatusRaw?.ToString() ?? "0");
|
||||
}
|
||||
}
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
yield return new PropertyDescription("ChargeLevel", "Charge Level (%)", "Mức pin (%)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 1,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Voltage", "Voltage (V)", "Điện áp (V)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 2,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Current", "Current (A)", "Dòng điện (A)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 3,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Charging", "Charging", "Đang sạc?")
|
||||
{
|
||||
DataType = "boolean",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 4,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Temperature", "Temperature (°C)", "Nhiệt độ")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 5,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Health", "SOH (%)", "Sức khỏe pin")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 6,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("RemainCapacity", "Remaining Capacity (Ah)", "Dung lượng còn lại")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 7,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("FullCapacity", "Remaining Energy (Wh)", "Năng lượng còn lại (Wh)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 8,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("ChargeTime", "Charge Time (min)", "Thời gian còn lại để sạc đầy")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 9,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("DischargeTime", "Discharge Time (min)", "Thời gian còn lại để xả hết")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 10,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("StatusRaw", "Status Flags", "Trạng thái bit (BMU Flags)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 11,
|
||||
Category = "Status"
|
||||
};
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
StopPollingLoop();
|
||||
_client?.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Ports;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Tada;
|
||||
|
||||
[Flags]
|
||||
public enum DataKind1 : byte
|
||||
{
|
||||
Voltage = 1 << 0,
|
||||
Current = 1 << 1,
|
||||
SOC = 1 << 2,
|
||||
Status = 1 << 3,
|
||||
ChargeTime = 1 << 4,
|
||||
DischargeTime = 1 << 5,
|
||||
Temp = 1 << 6
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum DataKind2 : byte
|
||||
{
|
||||
SOH = 1 << 0,
|
||||
RemainCapacity = 1 << 1,
|
||||
RemainEnergy = 1 << 2
|
||||
}
|
||||
public class TadaRs485Client : IDisposable
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private SerialPort _port;
|
||||
private readonly string _portName;
|
||||
private readonly int _baud;
|
||||
private readonly Parity _parity;
|
||||
private readonly int _dataBits;
|
||||
private readonly StopBits _stopBits;
|
||||
private readonly int _readTimeoutMs;
|
||||
private readonly int _writeTimeoutMs;
|
||||
public bool IsFaulted => _faulted;
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private bool _faulted = false;
|
||||
private DateTime _lastRetry = DateTime.MinValue;
|
||||
private int _retryDelayMs = 1000; // backoff min = 1s
|
||||
private DateTime _lastDataTime = DateTime.MinValue;
|
||||
private readonly int _dataTimeoutMs = 10_000; // 10 giây
|
||||
private int _consecutiveFails = 0;
|
||||
private readonly int _maxFails = 3; // sau 3 lần fail liên tiếp thì coi như lost
|
||||
|
||||
public TadaRs485Client(
|
||||
ILogger logger,
|
||||
string portName,
|
||||
int baud = 19200,
|
||||
Parity parity = Parity.None,
|
||||
int dataBits = 8,
|
||||
StopBits stopBits = StopBits.One,
|
||||
int readTimeoutMs = 2000,
|
||||
int writeTimeoutMs = 1000)
|
||||
{
|
||||
_logger = logger;
|
||||
_portName = portName;
|
||||
_baud = baud;
|
||||
_parity = parity;
|
||||
_dataBits = dataBits;
|
||||
_stopBits = stopBits;
|
||||
_readTimeoutMs = readTimeoutMs;
|
||||
_writeTimeoutMs = writeTimeoutMs;
|
||||
_lastDataTime = DateTime.Now;
|
||||
_consecutiveFails = 0;
|
||||
_port = null!;
|
||||
EnsureConnected();
|
||||
}
|
||||
|
||||
private void CheckDataTimeout()
|
||||
{
|
||||
if (_lastDataTime != DateTime.MinValue &&
|
||||
(DateTime.Now - _lastDataTime).TotalMilliseconds > _dataTimeoutMs)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Communication lost (data timeout).");
|
||||
_faulted = true;
|
||||
_lastDataTime = DateTime.MinValue; // reset để tránh spam log
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_port != null && _port.IsOpen && !_faulted) return;
|
||||
|
||||
if ((DateTime.Now - _lastRetry).TotalMilliseconds < _retryDelayMs)
|
||||
return;
|
||||
_lastRetry = DateTime.Now;
|
||||
|
||||
try
|
||||
{
|
||||
_port?.Dispose();
|
||||
_port = new SerialPort(_portName, _baud, _parity, _dataBits, _stopBits)
|
||||
{
|
||||
ReadTimeout = _readTimeoutMs,
|
||||
WriteTimeout = _writeTimeoutMs
|
||||
};
|
||||
_port.Open();
|
||||
_faulted = false;
|
||||
_retryDelayMs = 1000;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Connect failed: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
// exponential backoff up to 30s
|
||||
_retryDelayMs = Math.Min(_retryDelayMs * 2, 30_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte Checksum(byte[] data, int start, int len)
|
||||
{
|
||||
int sum = 0;
|
||||
for (int i = start; i < start + len; i++) sum += data[i];
|
||||
return (byte)(sum & 0xFF);
|
||||
}
|
||||
|
||||
private static string ToHex(byte[] data, int len)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
sb.Append(data[i].ToString("X2"));
|
||||
if (i < len - 1) sb.Append('-');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void RequestStatus(byte address = 0x60,
|
||||
DataKind1 kind1 = DataKind1.Voltage | DataKind1.Current | DataKind1.SOC | DataKind1.Status |
|
||||
DataKind1.ChargeTime | DataKind1.DischargeTime | DataKind1.Temp,
|
||||
DataKind2 kind2 = DataKind2.SOH | DataKind2.RemainCapacity | DataKind2.RemainEnergy)
|
||||
{
|
||||
EnsureConnected();
|
||||
if (_port == null || !_port.IsOpen || _faulted) return;
|
||||
|
||||
try
|
||||
{
|
||||
byte kind1Byte = (byte)kind1;
|
||||
byte kind2Byte = (byte)kind2;
|
||||
|
||||
byte[] frame =
|
||||
[
|
||||
0xAF, 0xFA,
|
||||
address,
|
||||
0x05,
|
||||
0x01,
|
||||
address,
|
||||
kind1Byte, kind2Byte,
|
||||
0x00,
|
||||
0xAF, 0xA0
|
||||
];
|
||||
|
||||
frame[8] = Checksum(frame, 2, 6);
|
||||
_port.DiscardInBuffer();
|
||||
_port.DiscardOutBuffer();
|
||||
_port.Write(frame, 0, frame.Length);
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Write error: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] ReadFrame()
|
||||
{
|
||||
EnsureConnected();
|
||||
if (_port == null || !_port.IsOpen || _faulted) return [];
|
||||
|
||||
var buffer = new List<byte>();
|
||||
int expectedLen = -1;
|
||||
var start = DateTime.Now;
|
||||
|
||||
try
|
||||
{
|
||||
while ((DateTime.Now - start).TotalMilliseconds < _readTimeoutMs)
|
||||
{
|
||||
int bytesAvailable = _port.BytesToRead;
|
||||
if (bytesAvailable > 0)
|
||||
{
|
||||
byte[] tempBuffer = new byte[bytesAvailable];
|
||||
int bytesRead = _port.Read(tempBuffer, 0, bytesAvailable);
|
||||
buffer.AddRange(tempBuffer.Take(bytesRead));
|
||||
|
||||
// check start marker (chuẩn AF FA hoặc bản partial 4D)
|
||||
if (buffer.Count >= 3 && expectedLen == -1)
|
||||
{
|
||||
if (buffer[0] == 0xAF && buffer[1] == 0xFA)
|
||||
{
|
||||
expectedLen = buffer[3] + 6;
|
||||
}
|
||||
else if (buffer[0] == 0x4D)
|
||||
{
|
||||
// frame thiếu AF FA -> vẫn tính chiều dài như thường
|
||||
expectedLen = buffer[2] + 5; // vì mất 2 byte start
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedLen > 0 && buffer.Count >= expectedLen)
|
||||
{
|
||||
if (buffer[^2] == 0xAF && buffer[^1] == 0xA0)
|
||||
{
|
||||
return [.. buffer];
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer.Clear();
|
||||
expectedLen = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(2);
|
||||
}
|
||||
}
|
||||
|
||||
// hết thời gian chờ
|
||||
if (buffer.Count > 0)
|
||||
_logger.LogWarning("[TadaRs485Client] Timeout / partial frame ({buffer.Count} bytes): {frame}", buffer.Count, ToHex([.. buffer], buffer.Count));
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Read error: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, double>? ReadResponse()
|
||||
{
|
||||
var frame = ReadFrame();
|
||||
if (frame == null || frame.Length < 9)
|
||||
{
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Communication lost (too many failed reads).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (frame[0] == 0x4D)
|
||||
{
|
||||
// Partial frame - fix it by prepending AF FA
|
||||
var newFrame = new byte[frame.Length + 1];
|
||||
newFrame[0] = 0xAF; newFrame[1] = 0xFA;
|
||||
Array.Copy(frame, 1, newFrame, 2, frame.Length - 1);
|
||||
frame = newFrame;
|
||||
}
|
||||
else if (frame[0] == 0xAF && frame.Length > 1 && frame[1] == 0xFA)
|
||||
{
|
||||
// Valid full frame - continue processing
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid frame format
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Communication lost (invalid frame format).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (frame[^2] != 0xAF || frame[^1] != 0xA0)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Footer mismatch");
|
||||
throw new Exception("invalid frame: footer");
|
||||
}
|
||||
if (frame[4] != 0x03)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Command code mismatch");
|
||||
throw new Exception("invalid frame: command");
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, double>();
|
||||
int dataLen = frame[3] - 3;
|
||||
int dataStart = 6;
|
||||
|
||||
for (int i = 0; i + 1 < dataLen; i += 2)
|
||||
{
|
||||
int idx = dataStart + i;
|
||||
if (idx + 1 >= frame.Length) break;
|
||||
|
||||
ushort raw = (ushort)((frame[idx] << 8) | frame[idx + 1]);
|
||||
int index = i / 2;
|
||||
|
||||
switch (index)
|
||||
{
|
||||
case 0: result["Voltage"] = raw / 100.0; break;
|
||||
case 1: result["Current"] = (short)raw / 10.0; break;
|
||||
case 2: result["SOC"] = raw; break;
|
||||
case 3: result["Status"] = raw; break;
|
||||
case 4: result["ChargeTime"] = raw; break;
|
||||
case 5: result["DischargeTime"] = raw; break;
|
||||
case 6: result["Temp"] = (short)raw / 10.0; break;
|
||||
case 7: result["SOH"] = raw; break;
|
||||
case 8: result["RemainCapacity"] = raw / 100.0; break;
|
||||
case 9: result["RemainEnergy"] = raw / 10.0; break;
|
||||
}
|
||||
}
|
||||
|
||||
_lastDataTime = DateTime.Now; // reset watchdog
|
||||
_consecutiveFails = 0; // reset fail counter
|
||||
return result;
|
||||
}
|
||||
|
||||
public void ForceReconnect()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
_port?.Close();
|
||||
}
|
||||
catch { }
|
||||
_faulted = false;
|
||||
_lastRetry = DateTime.MinValue;
|
||||
_retryDelayMs = 1000;
|
||||
EnsureConnected();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_port != null)
|
||||
{
|
||||
if (_port.IsOpen)
|
||||
{
|
||||
_port.Close();
|
||||
}
|
||||
_port.Dispose();
|
||||
_port = null!;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Error in Dispose: {ex.Message}", ex.Message);
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user