Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

View File

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

View File

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