using System.Timers; using RobotNet10.CANOpen; using RobotNet10.RobotApp.Client.Shared.Devices; using RobotNet10.RobotApp.Devices; using RobotNet10.Shared; using RobotNet10.Shared.Sensor; namespace RobotNet10.RobotApp.Drivers.Battery.Varta; [Device(DeviceType.Battery, "Varta", "VartaBattery", "1.0.0", Description = "Battery Varta CAN Driver")] public class VartaBattery : DeviceBase, IBattery { private readonly ILogger _logger; private readonly ICanOpenManager _canOpenManager; private readonly Lock _dataLock = new(); private VartaCanClient? _client; private System.Timers.Timer? _pollingTimer; private readonly string _canInterface; private readonly int _readTimeoutMs; private readonly int _pollingIntervalMs; private readonly int _connectionTimeoutMs; private double _cachedChargeLevel; private double _cachedVoltage; private double _cachedCurrent; private bool _cachedCharging; private double? _cachedFetTemperature; private double? _cachedCellTemperature; private double? _cachedChargeReqVoltage; private double? _cachedChargeReqCurrent; private double? _cachedNominalCapacityMah; private double? _cachedFullCapacityMah; private double? _cachedRemainingCapacityMah; private double? _cachedHealth; private int? _cachedInfo; private int? _cachedWarn; private int? _cachedError; private int? _cachedChargeCtrl; private DateTime _lastUpdateTime = DateTime.MinValue; private DateTime _connectedAt = DateTime.MinValue; private bool _connectionLossSignaled; private BatteryState? _cachedBatteryState; public BatteryState? CurrentBatteryState { get { lock (_dataLock) { return _cachedBatteryState; } } } public VartaBattery(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider) : base(deviceId, deviceName, DeviceType.Battery) { var loggerFactory = serviceProvider.GetRequiredService(); _logger = loggerFactory.CreateLogger(); _canOpenManager = serviceProvider.GetRequiredService(); _canInterface = connection.GetValue("CanInterface") ?? connection.GetValue("Interface") ?? "can0"; _readTimeoutMs = connection.GetValue("ReadTimeoutMs") ?? 200; _pollingIntervalMs = connection.GetValue("PollingIntervalMs") ?? 500; _connectionTimeoutMs = connection.GetValue("ConnectionTimeoutMs") ?? Math.Max(5000, _pollingIntervalMs * 10); AutoReconnectEnabled = connection.GetValue("AutoReconnectEnabled") ?? true; ReconnectDelayMs = connection.GetValue("ReconnectDelayMs") ?? 2000; MaxReconnectAttempts = connection.GetValue("MaxReconnectAttempts") ?? 0; UpdateProperties(); } protected override Task OnInitializeAsync(CancellationToken cancellationToken) { lock (_dataLock) { _client?.Dispose(); _client = new VartaCanClient(_logger, _canOpenManager, _canInterface, _readTimeoutMs); } UpdateProperties(); return Task.CompletedTask; } protected override Task OnConnectAsync(CancellationToken cancellationToken) { lock (_dataLock) { if (_client == null) { _client = new VartaCanClient(_logger, _canOpenManager, _canInterface, _readTimeoutMs); } else { _client.ForceReconnect(); } _connectedAt = DateTime.UtcNow; _connectionLossSignaled = false; } StartPollingLoop(); return Task.CompletedTask; } protected override Task OnDisconnectAsync(CancellationToken cancellationToken) { StopPollingLoop(); lock (_dataLock) { _connectionLossSignaled = false; _connectedAt = DateTime.MinValue; } return Task.CompletedTask; } protected override Task OnResetAsync(CancellationToken cancellationToken) { StopPollingLoop(); lock (_dataLock) { _client?.ForceReconnect(); ResetCache(); _connectedAt = DateTime.UtcNow; _connectionLossSignaled = false; } UpdateProperties(); StartPollingLoop(); return Task.CompletedTask; } protected override Task OnCheckConnectionAsync(CancellationToken cancellationToken) { lock (_dataLock) { if (_client == null || _client.IsFaulted) { return Task.FromResult(false); } var referenceTime = _lastUpdateTime != DateTime.MinValue ? _lastUpdateTime : _connectedAt; if (referenceTime == DateTime.MinValue) { return Task.FromResult(false); } var isRecent = (DateTime.UtcNow - referenceTime).TotalMilliseconds <= _connectionTimeoutMs; return Task.FromResult(isRecent); } } private void StartPollingLoop() { lock (_dataLock) { if (_pollingTimer != null && _pollingTimer.Enabled) { return; } StopPollingLoop(); _pollingTimer = new System.Timers.Timer(_pollingIntervalMs) { AutoReset = true, Enabled = true }; _pollingTimer.Elapsed += PollTimer_Elapsed; } } private void StopPollingLoop() { lock (_dataLock) { if (_pollingTimer == null) { return; } _pollingTimer.Stop(); _pollingTimer.Elapsed -= PollTimer_Elapsed; _pollingTimer.Dispose(); _pollingTimer = null; } } private void PollTimer_Elapsed(object? sender, ElapsedEventArgs e) { try { var data = _client?.ReadResponse(); if (data != null) { UpdateCache(data); return; } var noDataMs = _lastUpdateTime != DateTime.MinValue ? (DateTime.UtcNow - _lastUpdateTime).TotalMilliseconds : 0; _logger.LogWarning("[VartaBattery] No CAN data received (last update {NoDataMs:F0}ms ago, IsFaulted={IsFaulted})", noDataMs, _client?.IsFaulted); if (_client?.IsFaulted == true) { NotifyConnectionLost("Varta CAN socket faulted"); return; } if (_lastUpdateTime != DateTime.MinValue && noDataMs > _connectionTimeoutMs) { NotifyConnectionLost($"No Varta CAN data for more than {_connectionTimeoutMs}ms"); } } catch (Exception ex) { NotifyConnectionLost("Varta CAN polling exception", ex); } } private void NotifyConnectionLost(string reason, Exception? cause = null) { lock (_dataLock) { if (_connectionLossSignaled) { return; } _connectionLossSignaled = true; } LastError = cause ?? new TimeoutException(reason); OnErrorOccurred(LastError, reason); _ = CheckConnectionAsync(); } private void UpdateCache(Dictionary data) { lock (_dataLock) { if (data.TryGetValue("SOC", out var soc)) { _cachedChargeLevel = soc; } if (data.TryGetValue("Voltage", out var voltage)) { _cachedVoltage = voltage; } if (data.TryGetValue("Current", out var current)) { _cachedCurrent = current; _cachedCharging = current > 0; } _cachedFetTemperature = data.TryGetValue("FetTemp", out var fetTemp) ? fetTemp : _cachedFetTemperature; _cachedCellTemperature = data.TryGetValue("CellTemp", out var cellTemp) ? cellTemp : _cachedCellTemperature; _cachedChargeReqVoltage = data.TryGetValue("ChargeReqVoltage", out var chargeReqVoltage) ? chargeReqVoltage : _cachedChargeReqVoltage; _cachedChargeReqCurrent = data.TryGetValue("ChargeReqCurrent", out var chargeReqCurrent) ? chargeReqCurrent : _cachedChargeReqCurrent; _cachedNominalCapacityMah = data.TryGetValue("NominalCapacityMah", out var nominalCap) ? nominalCap : _cachedNominalCapacityMah; _cachedFullCapacityMah = data.TryGetValue("FullCapacityMah", out var fullCap) ? fullCap : _cachedFullCapacityMah; _cachedRemainingCapacityMah = data.TryGetValue("RemainingCapacityMah", out var remainingCap) ? remainingCap : _cachedRemainingCapacityMah; // If device provides SOH value directly, use it. Otherwise compute from capacities when available if (data.TryGetValue("SOH", out var sohVal)) { _cachedHealth = sohVal; } else if (_cachedFullCapacityMah.HasValue && _cachedNominalCapacityMah.HasValue && _cachedNominalCapacityMah.Value > 0) { try { _cachedHealth = (_cachedFullCapacityMah.Value / _cachedNominalCapacityMah.Value) * 100.0; } catch { _cachedHealth = null; } } else { _cachedHealth = null; } _cachedInfo = data.TryGetValue("Info", out var info) ? (int)info : _cachedInfo; _cachedWarn = data.TryGetValue("Warn", out var warn) ? (int)warn : _cachedWarn; _cachedError = data.TryGetValue("Error", out var error) ? (int)error : _cachedError; _cachedChargeCtrl = data.TryGetValue("ChargeCtrl", out var chargeCtrl) ? (int)chargeCtrl : _cachedChargeCtrl; _lastUpdateTime = DateTime.UtcNow; _connectionLossSignaled = false; _cachedBatteryState = CreateBatteryStateFromCache(); UpdateProperties(); } } private void ResetCache() { _cachedChargeLevel = 0; _cachedVoltage = 0; _cachedCurrent = 0; _cachedCharging = false; _cachedFetTemperature = null; _cachedCellTemperature = null; _cachedChargeReqVoltage = null; _cachedChargeReqCurrent = null; _cachedNominalCapacityMah = null; _cachedFullCapacityMah = null; _cachedRemainingCapacityMah = null; _cachedInfo = null; _cachedWarn = null; _cachedError = null; _cachedChargeCtrl = null; _lastUpdateTime = DateTime.MinValue; _connectedAt = DateTime.MinValue; _connectionLossSignaled = false; _cachedBatteryState = null; } public Task ReadBatteryStateAsync(CancellationToken cancellationToken = default) { lock (_dataLock) { if (_cachedBatteryState.HasValue) { return Task.FromResult(_cachedBatteryState.Value); } return Task.FromResult(CreateBatteryStateFromCache()); } } private BatteryState CreateBatteryStateFromCache() { byte powerSupplyStatus = BatteryState.PowerSupplyStatusUnknown; if (_cachedCharging) { powerSupplyStatus = BatteryState.PowerSupplyStatusCharging; } else if (_cachedCurrent < 0) { powerSupplyStatus = BatteryState.PowerSupplyStatusDischarging; } else if (_cachedCurrent == 0) { powerSupplyStatus = BatteryState.PowerSupplyStatusNotCharging; } var cellTemperature = _cachedCellTemperature.HasValue ? new[] { _cachedCellTemperature.Value } : Array.Empty(); // Map PowerSupplyHealth from computed SOH where possible byte powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown; if (_cachedHealth.HasValue) { if (_cachedHealth.Value >= 80.0) powerSupplyHealth = BatteryState.PowerSupplyHealthGood; else if (_cachedHealth.Value < 20.0) powerSupplyHealth = BatteryState.PowerSupplyHealthDead; else powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown; } return new BatteryState { Header = new Header { Stamp = _lastUpdateTime != DateTime.MinValue ? _lastUpdateTime : DateTime.UtcNow, FrameId = "battery_frame" }, Voltage = (float)_cachedVoltage, Current = (float)_cachedCurrent, Charge = _cachedRemainingCapacityMah.HasValue ? (float)(_cachedRemainingCapacityMah.Value / 1000.0) : float.NaN, Capacity = _cachedFullCapacityMah.HasValue ? (float)(_cachedFullCapacityMah.Value / 1000.0) : float.NaN, DesignCapacity = _cachedNominalCapacityMah.HasValue ? (float)(_cachedNominalCapacityMah.Value / 1000.0) : float.NaN, Percentage = (float)_cachedChargeLevel, PowerSupplyStatus = powerSupplyStatus, PowerSupplyHealth = powerSupplyHealth, PowerSupplyTechnology = BatteryState.PowerSupplyTechnologyUnknown, Present = true, CellVoltage = [], CellTemperature = cellTemperature, Location = string.Empty, SerialNumber = string.Empty }; } private void UpdateProperties() { lock (_dataLock) { SetProperty("CanInterface", _canInterface); SetProperty("ConnectionTimeoutMs", _connectionTimeoutMs.ToString()); SetProperty("ChargeLevel", _cachedChargeLevel.ToString("F1")); SetProperty("Voltage", _cachedVoltage.ToString("F2")); SetProperty("Current", _cachedCurrent.ToString("F2")); SetProperty("Charging", _cachedCharging.ToString()); SetProperty("FetTemperature", _cachedFetTemperature?.ToString("F1") ?? "0"); SetProperty("CellTemperature", _cachedCellTemperature?.ToString("F1") ?? "0"); SetProperty("ChargeReqVoltage", _cachedChargeReqVoltage?.ToString("F2") ?? "0"); SetProperty("ChargeReqCurrent", _cachedChargeReqCurrent?.ToString("F2") ?? "0"); SetProperty("NominalCapacityMah", _cachedNominalCapacityMah?.ToString("F0") ?? "0"); SetProperty("FullCapacityMah", _cachedFullCapacityMah?.ToString("F0") ?? "0"); SetProperty("RemainingCapacityMah", _cachedRemainingCapacityMah?.ToString("F0") ?? "0"); SetProperty("Health", _cachedHealth?.ToString("F0") ?? "0"); SetProperty("Info", _cachedInfo?.ToString() ?? "0"); SetProperty("Warn", _cachedWarn?.ToString() ?? "0"); SetProperty("Error", _cachedError?.ToString() ?? "0"); SetProperty("ChargeCtrl", _cachedChargeCtrl?.ToString() ?? "0"); } } protected override IEnumerable CreatePropertyDescriptions() { yield return new PropertyDescription("CanInterface", "CAN Interface", "CAN interface của pin") { DataType = "text", IsReadOnly = true, DisplayOrder = 1, Category = "Config" }; yield return new PropertyDescription("ConnectionTimeoutMs", "Connection Timeout (ms)", "Ngưỡng timeout phát hiện mất kết nối") { DataType = "number", IsReadOnly = true, DisplayOrder = 2, Category = "Config" }; yield return new PropertyDescription("ChargeLevel", "Charge Level (%)", "Mức pin (%)") { DataType = "number", IsReadOnly = true, DisplayOrder = 3, Category = "Status" }; yield return new PropertyDescription("Voltage", "Voltage (V)", "Điện áp (V)") { DataType = "number", IsReadOnly = true, DisplayOrder = 4, Category = "Status" }; yield return new PropertyDescription("Current", "Current (A)", "Dòng điện (A)") { DataType = "number", IsReadOnly = true, DisplayOrder = 5, Category = "Status" }; yield return new PropertyDescription("Charging", "Charging", "Đang sạc?") { DataType = "boolean", IsReadOnly = true, DisplayOrder = 6, Category = "Status" }; yield return new PropertyDescription("FetTemperature", "FET Temp (C)", "Nhiệt độ FET") { DataType = "number", IsReadOnly = true, DisplayOrder = 7, Category = "Status" }; yield return new PropertyDescription("CellTemperature", "Cell Temp (C)", "Nhiệt độ cell") { DataType = "number", IsReadOnly = true, DisplayOrder = 8, Category = "Status" }; yield return new PropertyDescription("ChargeReqVoltage", "Charge Req Voltage (V)", "Điện áp sạc yêu cầu") { DataType = "number", IsReadOnly = true, DisplayOrder = 9, Category = "Status" }; yield return new PropertyDescription("ChargeReqCurrent", "Charge Req Current (A)", "Dòng sạc yêu cầu") { DataType = "number", IsReadOnly = true, DisplayOrder = 10, Category = "Status" }; yield return new PropertyDescription("NominalCapacityMah", "Nominal Capacity (mAh)", "Dung lượng danh định") { DataType = "number", IsReadOnly = true, DisplayOrder = 11, Category = "Status" }; yield return new PropertyDescription("FullCapacityMah", "Full Capacity (mAh)", "Dung lượng đầy") { DataType = "number", IsReadOnly = true, DisplayOrder = 12, Category = "Status" }; yield return new PropertyDescription("RemainingCapacityMah", "Remaining Capacity (mAh)", "Dung lượng còn lại") { DataType = "number", IsReadOnly = true, DisplayOrder = 13, Category = "Status" }; yield return new PropertyDescription("Health", "SOH (%)", "Sức khỏe pin") { DataType = "number", IsReadOnly = true, DisplayOrder = 14, Category = "Status" }; yield return new PropertyDescription("Info", "Info Flags", "Cờ thông tin") { DataType = "number", IsReadOnly = true, DisplayOrder = 15, Category = "Status" }; yield return new PropertyDescription("Warn", "Warn Flags", "Cờ cảnh báo") { DataType = "number", IsReadOnly = true, DisplayOrder = 16, Category = "Status" }; yield return new PropertyDescription("Error", "Error Flags", "Cờ lỗi") { DataType = "number", IsReadOnly = true, DisplayOrder = 17, Category = "Status" }; yield return new PropertyDescription("ChargeCtrl", "Charge Control", "Trạng thái điều khiển sạc") { DataType = "number", IsReadOnly = true, DisplayOrder = 18, Category = "Status" }; } protected override void Dispose(bool disposing) { if (disposing) { StopPollingLoop(); _client?.Dispose(); } base.Dispose(disposing); } }