From dddf3fc594e28f8addee787afdc6ed707184890f Mon Sep 17 00:00:00 2001 From: QUYVN Date: Mon, 13 Jul 2026 16:28:07 +0700 Subject: [PATCH] update motor Co-authored-by: Copilot --- .../RobotNet10.CANOpen/CanOpenDevice.cs | 22 +- .../RobotNet10.CANOpen/Services/PdoManager.cs | 101 +- .../RobotNet10.CANOpen/Services/SdoClient.cs | 128 +- .../Devices/IDualAxisVelocityCommand.cs | 16 + .../Drivers/ZLAC/Zlac8015dServo.cs | 1704 +++++++++++++++++ .../ModelConfigs/ZLAC-test.json | 110 ++ .../Motion/DifferentialDrive.cs | 21 +- .../RobotApp/RobotNet10.RobotApp/Program.cs | 18 + .../RobotNet10.RobotApp/appsettings.json | 70 +- 9 files changed, 2142 insertions(+), 48 deletions(-) create mode 100644 srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Devices/IDualAxisVelocityCommand.cs create mode 100644 srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/ZLAC/Zlac8015dServo.cs create mode 100644 srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/ModelConfigs/ZLAC-test.json diff --git a/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/CanOpenDevice.cs b/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/CanOpenDevice.cs index ef36410..3a53040 100644 --- a/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/CanOpenDevice.cs +++ b/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/CanOpenDevice.cs @@ -29,6 +29,13 @@ public class CanOpenDevice : ICanOpenDevice, IDisposable /// When enabled, State will be updated automatically when heartbeat is received /// public bool AutoVerifyStateViaHeartbeat { get; set; } = false; + + /// + /// Lock cho trình tự cấu hình cấp node (PDO config, NMT state, tham số chung). + /// Nhiều facade dùng chung một node vật lý (vd. 2 trục của ZLAC8015D) phải giữ lock này + /// trong suốt trình tự connect để không xen kẽ NMT/PDO của nhau. + /// + public SemaphoreSlim ConfigurationLock { get; } = new(1, 1); // Expose các services để user có thể truy cập public PdoManager Pdo => _pdoManager; @@ -154,7 +161,20 @@ public class CanOpenDevice : ICanOpenDevice, IDisposable { await WriteObjectAsync(index, subIndex, BitConverter.GetBytes(value), cancellationToken); } - + + /// + /// Ghi nhiều giá trị int32 trong một lần giữ kênh SDO liên tục — không giao dịch nào + /// khác chen được vào giữa các lệnh. Dùng cho lệnh cần đi liền nhau (vd vận tốc 2 bánh). + /// + public async Task WriteInt32BatchAsync(IReadOnlyList<(ushort Index, byte SubIndex, int Value)> items, CancellationToken cancellationToken = default) + { + var batch = new (ushort, byte, byte[])[items.Count]; + for (int i = 0; i < items.Count; i++) + batch[i] = (items[i].Index, items[i].SubIndex, BitConverter.GetBytes(items[i].Value)); + + await _sdoClient.DownloadBatchAsync(batch, cancellationToken); + } + #endregion #region NMT Operations diff --git a/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/PdoManager.cs b/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/PdoManager.cs index 023cc39..f4bb170 100644 --- a/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/PdoManager.cs +++ b/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/PdoManager.cs @@ -360,6 +360,97 @@ public class PdoManager : IDisposable return supportedIndices.Contains(mappingParamIndex); } + /// + /// Poll 0x1000 (device type) cho tới khi drive trả lời SDO, tối đa maxWaitMs. + /// Dùng sau NMT Reset Communication để bước SDO kế tiếp không bị timeout oan. + /// + private async Task WaitUntilSdoRespondsAsync(ICanOpenDevice device, CancellationToken ct, int maxWaitMs = 3000) + { + const int retryDelayMs = 200; + int waited = 0; + + while (waited < maxWaitMs) + { + try + { + await device.ReadUInt32Async(0x1000, 0x00, ct); + return; // Drive đã trả lời -> stack sẵn sàng + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + await Task.Delay(retryDelayMs, ct); + waited += retryDelayMs; + } + } + + _logger?.LogWarning( + "Device Node {NodeId} chưa trả lời SDO sau {MaxWaitMs}ms kể từ Reset Communication. Tiếp tục cấu hình PDO...", + device.NodeId, maxWaitMs); + } + + /// + /// Đọc ngược cấu hình TPDO từ drive để biết giá trị nào THỰC SỰ được chấp nhận. + /// Nhiều firmware im lặng bỏ qua hoặc ép lại COB-ID / transmission type / event timer, + /// khiến TPDO không bao giờ phát dù mọi lệnh ghi đều báo thành công. + /// + private async Task LogActualTpdoConfigAsync(ICanOpenDevice device, byte pdoNumber, CancellationToken ct) + { + ushort commParamIndex = (ushort)(0x1800 + (pdoNumber - 1)); + ushort mappingParamIndex = (ushort)(0x1A00 + (pdoNumber - 1)); + + try + { + uint cobId = await device.ReadUInt32Async(commParamIndex, 0x01, ct); + byte transmissionType = await device.ReadUInt8Async(commParamIndex, 0x02, ct); + byte mappingCount = await device.ReadUInt8Async(mappingParamIndex, 0x00, ct); + + ushort eventTimer = 0; + try + { + eventTimer = await device.ReadUInt16Async(commParamIndex, 0x05, ct); + } + catch + { + // Firmware không có event timer -> giữ 0 + } + + bool enabled = (cobId & 0x80000000) == 0; + + _logger?.LogInformation( + "TPDO{PDONumber} readback Node {NodeId}: COB-ID=0x{CobId:X3} ({State}), TransmissionType=0x{Type:X2}, EventTimer={EventTimer}ms, MappingCount={Count}", + pdoNumber, device.NodeId, cobId & 0x7FF, enabled ? "ENABLED" : "DISABLED", + transmissionType, eventTimer, mappingCount); + + if (!enabled) + { + _logger?.LogWarning("TPDO{PDONumber} Node {NodeId}: drive vẫn để bit 31 = 1 (PDO bị DISABLE) -> sẽ không phát frame nào.", + pdoNumber, device.NodeId); + } + + if (mappingCount == 0) + { + _logger?.LogWarning("TPDO{PDONumber} Node {NodeId}: MappingCount = 0 -> drive không giữ mapping đã ghi, TPDO sẽ không có dữ liệu.", + pdoNumber, device.NodeId); + } + + if (transmissionType is not (0xFE or 0xFF) && eventTimer == 0) + { + _logger?.LogWarning( + "TPDO{PDONumber} Node {NodeId}: TransmissionType=0x{Type:X2} là kiểu đồng bộ (theo SYNC) và EventTimer=0. " + + "Không có SYNC producer thì TPDO sẽ không bao giờ phát.", + pdoNumber, device.NodeId, transmissionType); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Không đọc ngược được cấu hình TPDO{PDONumber} trên Node {NodeId}", pdoNumber, device.NodeId); + } + } + /// /// Stop và clear tất cả TPDOs trên device /// @@ -426,7 +517,12 @@ public class PdoManager : IDisposable try { await device.ResetCommunicationAsync(ct); - await Task.Delay(100, ct); // Wait for device to reset + await Task.Delay(500, ct); + + // Chờ drive dựng xong CANopen stack: poll 0x1000 (device type) cho tới khi + // nó trả lời. Delay cố định không đủ tin cậy — SDO đầu tiên sau reset hay bị + // timeout, gây warning "Failed to stop/clear TPDO1" ở bước sau. + await WaitUntilSdoRespondsAsync(device, ct); } catch (Exception ex) { @@ -534,6 +630,9 @@ public class PdoManager : IDisposable await WritePdoConfigurationToDeviceAsync(device, true, kvp.Key, ct); _configuredTpdoNumbers.Add(kvp.Key); + + // Đọc ngược để xác nhận drive thực sự chấp nhận cấu hình (không chỉ ACK lệnh ghi) + await LogActualTpdoConfigAsync(device, kvp.Key, ct); } catch (Exception ex) { diff --git a/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/SdoClient.cs b/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/SdoClient.cs index dd1ceee..65b4354 100644 --- a/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/SdoClient.cs +++ b/srcs/RobotNet10/RobotApp/Communication/RobotNet10.CANOpen/Services/SdoClient.cs @@ -12,6 +12,9 @@ public class SdoClient : IDisposable private readonly ICanBus _canBus; private readonly byte _nodeId; private readonly ConcurrentDictionary> _pendingRequests; + // CANopen chỉ cho phép 1 giao dịch SDO tại một thời điểm trên mỗi node. + // Hai facade trái/phải của ZLAC8015D dùng chung SdoClient nên phải xếp hàng ở đây. + private readonly SemaphoreSlim _transactionLock = new(1, 1); private readonly TimeSpan _timeout; private readonly ILogger? _logger; private bool _disposed; @@ -31,26 +34,37 @@ public class SdoClient : IDisposable { if (_disposed) throw new ObjectDisposedException(nameof(SdoClient)); - - var request = SdoRequest.CreateUpload(index, subIndex); - var response = await SendRequestAsync(request, cancellationToken); - - if (response.IsAbort) + + await _transactionLock.WaitAsync(cancellationToken); + try { - var abortCode = (SdoAbortCode)(uint)response.AbortCode; - var message = $"SDO Upload failed: {abortCode.GetDescription()}"; - throw new SdoException(_nodeId, index, subIndex, (uint)response.AbortCode, message); + if (_disposed) + throw new ObjectDisposedException(nameof(SdoClient)); + + var request = SdoRequest.CreateUpload(index, subIndex); + var response = await SendRequestAsync(request, cancellationToken); + + if (response.IsAbort) + { + var abortCode = (SdoAbortCode)(uint)response.AbortCode; + var message = $"SDO Upload failed: {abortCode.GetDescription()}"; + throw new SdoException(_nodeId, index, subIndex, (uint)response.AbortCode, message); + } + + // Check if expedited or segmented + if (response.IsExpedited) + { + return response.GetDataBytes(); + } + else + { + // Segmented transfer + return await UploadSegmentedAsync(index, subIndex, response, cancellationToken); + } } - - // Check if expedited or segmented - if (response.IsExpedited) + finally { - return response.GetDataBytes(); - } - else - { - // Segmented transfer - return await UploadSegmentedAsync(index, subIndex, response, cancellationToken); + _transactionLock.Release(); } } @@ -126,27 +140,81 @@ public class SdoClient : IDisposable { if (_disposed) throw new ObjectDisposedException(nameof(SdoClient)); - - if (data.Length <= 4) + + await _transactionLock.WaitAsync(cancellationToken); + try { - // Expedited transfer (≤4 bytes) - var request = SdoRequest.CreateDownload(index, subIndex, data); - var response = await SendRequestAsync(request, cancellationToken); - - if (response.IsAbort) + if (_disposed) + throw new ObjectDisposedException(nameof(SdoClient)); + + if (data.Length <= 4) { - var abortCode = (SdoAbortCode)(uint)response.AbortCode; - var message = $"SDO Download failed: {abortCode.GetDescription()}"; - throw new SdoException(_nodeId, index, subIndex, (uint)response.AbortCode, message); + // Expedited transfer (≤4 bytes) + var request = SdoRequest.CreateDownload(index, subIndex, data); + var response = await SendRequestAsync(request, cancellationToken); + + if (response.IsAbort) + { + var abortCode = (SdoAbortCode)(uint)response.AbortCode; + var message = $"SDO Download failed: {abortCode.GetDescription()}"; + throw new SdoException(_nodeId, index, subIndex, (uint)response.AbortCode, message); + } + } + else + { + // Segmented transfer (>4 bytes) + await DownloadSegmentedAsync(index, subIndex, data, cancellationToken); } } - else + finally { - // Segmented transfer (>4 bytes) - await DownloadSegmentedAsync(index, subIndex, data, cancellationToken); + _transactionLock.Release(); } } + /// + /// Ghi nhiều object liên tiếp trong MỘT lần giữ transaction lock. + /// Dùng khi các lệnh phải đi liền nhau không bị giao dịch SDO khác chen giữa, + /// ví dụ lệnh vận tốc hai bánh của ZLAC8015D khi không có RPDO: nếu ghi bằng hai + /// DownloadAsync riêng, poll feedback có thể chiếm kênh giữa hai lệnh làm bánh sau + /// nhận lệnh muộn hơn hẳn. Chỉ hỗ trợ expedited transfer (data ≤ 4 byte mỗi mục). + /// + public async Task DownloadBatchAsync(IReadOnlyList<(ushort Index, byte SubIndex, byte[] Data)> items, CancellationToken cancellationToken = default) + { + if (_disposed) + throw new ObjectDisposedException(nameof(SdoClient)); + + foreach (var item in items) + { + if (item.Data.Length > 4) + throw new NotSupportedException("DownloadBatchAsync chỉ hỗ trợ expedited transfer (≤4 byte mỗi mục)"); + } + + await _transactionLock.WaitAsync(cancellationToken); + try + { + if (_disposed) + throw new ObjectDisposedException(nameof(SdoClient)); + + foreach (var (index, subIndex, data) in items) + { + var request = SdoRequest.CreateDownload(index, subIndex, data); + var response = await SendRequestAsync(request, cancellationToken); + + if (response.IsAbort) + { + var abortCode = (SdoAbortCode)(uint)response.AbortCode; + var message = $"SDO Download failed: {abortCode.GetDescription()}"; + throw new SdoException(_nodeId, index, subIndex, (uint)response.AbortCode, message); + } + } + } + finally + { + _transactionLock.Release(); + } + } + /// /// Download data using segmented transfer (for data > 4 bytes) /// diff --git a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Devices/IDualAxisVelocityCommand.cs b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Devices/IDualAxisVelocityCommand.cs new file mode 100644 index 0000000..a7dc883 --- /dev/null +++ b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Devices/IDualAxisVelocityCommand.cs @@ -0,0 +1,16 @@ +namespace RobotNet10.RobotApp.Devices; + +/// +/// Khả năng gửi vận tốc của hai trục dùng chung một node CAN trong MỘT khung duy nhất, +/// để hai bánh nhận lệnh đồng thời (vd. ZLAC8015D dual-hub). +/// Driver một-trục-một-node không cần implement interface này. +/// +public interface IDualAxisVelocityCommand +{ + /// + /// Gửi vận tốc cả hai trục cùng lúc. Đơn vị counts/s, đã bao gồm hiệu chỉnh IsReversed. + /// Trả false nếu không gửi được theo cơ chế gộp (vd. RPDO chưa cấu hình) — caller nên + /// fallback về cách gửi từng trục. + /// + Task TrySetBothTargetVelocitiesAsync(int leftCountsPerSec, int rightCountsPerSec, CancellationToken ct = default); +} diff --git a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/ZLAC/Zlac8015dServo.cs b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/ZLAC/Zlac8015dServo.cs new file mode 100644 index 0000000..b0c8992 --- /dev/null +++ b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/ZLAC/Zlac8015dServo.cs @@ -0,0 +1,1704 @@ +using RobotNet10.CANOpen; +using RobotNet10.CANOpen.CiA402.Enums; +using RobotNet10.CANOpen.CiA402.Models; +using RobotNet10.CANOpen.Enums; +using RobotNet10.CANOpen.Exceptions; +using RobotNet10.CANOpen.Models; +using RobotNet10.CANOpen.Services; +using RobotNet10.RobotApp.Client.Shared.Devices; +using RobotNet10.RobotApp.Devices; +using RobotNet10.RobotApp.Drivers.PhenikaaX; +using System.Collections.Concurrent; + +namespace RobotNet10.RobotApp.Drivers.ZLAC; + +/// +/// Trục của motor trên driver ZLAC8015D (1 node CAN điều khiển 2 bánh) +/// +public enum Zlac8015dAxis +{ + Left, + Right +} + +/// +/// Cấu hình cho ZLAC8015D driver +/// +internal class Zlac8015dConfig +{ + public string CanInterface { get; set; } = string.Empty; + public string NodeId { get; set; } = string.Empty; + /// + /// Trục motor mà instance này đại diện: "Left" hoặc "Right" + /// + public string Axis { get; set; } = string.Empty; + /// + /// Số count encoder trên một vòng bánh (ZLAC8015D mặc định 4096) + /// Dùng để quy đổi counts/s (interface ICiA402Servo) sang r/min (đơn vị của drive) + /// + public int CountsPerRevolution { get; set; } = 4096; + /// + /// Độ phân giải tốc độ đặt (object 0x2026 sub 06): đơn vị lệnh 0x60FF = 1/N r/min. + /// N = 1..10. Mặc định 10 (0.1 rpm). Nếu firmware không hỗ trợ, driver tự fallback về 1. + /// + public int SpeedResolution { get; set; } = 10; + /// + /// Thời gian tăng tốc (0x6083, ms, 0-32767). ZLAC dùng đơn vị thời gian, + /// không quy đổi được từ counts/s² nên cấu hình trực tiếp tại đây. + /// + public ushort AccelTimeMs { get; set; } = 200; + /// + /// Thời gian giảm tốc (0x6084, ms, 0-32767) + /// + public ushort DecelTimeMs { get; set; } = 200; + public bool? AutoReconnectEnabled { get; set; } + public int? ReconnectDelayMs { get; set; } + public int? MaxReconnectAttempts { get; set; } + public int? PdoConfigRetryCount { get; set; } + public int? PdoConfigRetryTimeoutMs { get; set; } + public bool? UseHeartbeatCheck { get; set; } +} + +/// +/// Driver cho ZLAC8015D dual-wheel hub servo (CANopen CiA402). +/// Đặc thù: MỘT node CAN điều khiển HAI bánh, các object trục dùng sub-index 01 (trái) / 02 (phải); +/// Controlword/Statusword/Mode (0x6040/0x6041/0x6060) dùng chung sub 0 cho cả hai bánh. +/// +/// Để giữ nguyên mô hình "mỗi bánh một ICiA402Servo" của DifferentialDrive/OdometryService, +/// mỗi instance của driver này là một "facade" cho MỘT trục (Axis = Left/Right); +/// hai instance (trái + phải) trỏ cùng CanInterface + NodeId sẽ dùng chung một CanOpenDevice +/// (ICanOpenManager cache theo interface + nodeId). +/// +/// Đơn vị: interface nhận/trả counts và counts/s (giống driver PhenikaaX); +/// driver quy đổi sang r/min của ZLAC theo CountsPerRevolution. +/// Lệnh 0x60FF ghi theo độ phân giải 1/SpeedResolution r/min (0x2026:06); +/// feedback 0x606C cố định 0.1 r/min; feedback 0x6064 là counts (không quy đổi). +/// +/// Feedback qua TPDO. Firmware ZLAC8015D phát TPDO khi DỮ LIỆU THAY ĐỔI (bỏ qua event +/// timer 0x18xx:05 — đo thực tế: TPDO đặt statusword ở đầu không bao giờ phát dù position +/// phía sau đổi liên tục, còn TPDO velocity phát ~240 lần/s theo mỗi thay đổi). Vì vậy: +/// TPDO1 = 0x6064:01 + 0x6064:02 (position 2 bánh - đổi khi lăn bánh -> có event) +/// TPDO2 = 0x6041:00 (statusword - phát ngay khi đổi, vd fault) +/// TPDO3 = 0x606C:01 + 0x606C:02 (velocity 2 bánh) +/// Mỗi TPDO đặt InhibitTime 50ms để firmware không phát dồn dập theo từng thay đổi. +/// Hai instance trái/phải đăng ký mapping GIỐNG HỆT nhau (idempotent), mỗi instance +/// chỉ đọc phần bit của trục mình. Khi TPDO không phát (đứng yên), poll SDO nền làm tươi. +/// +[Device(DeviceType.CiA402Servo, "ZLAC", "ZLAC8015D", "1.0.0", Description = "ZLAC8015D Dual Wheel Hub Servo Driver (CANopen CiA402, 1 node / 2 bánh)")] +public class Zlac8015dServo : DeviceBase, ICiA402Servo, IDualAxisVelocityCommand +{ + // Object dictionary ZLAC8015D + private const ushort ObjControlword = 0x6040; // sub 0, chung 2 bánh + private const ushort ObjStatusword = 0x6041; // sub 0, chung 2 bánh + private const ushort ObjModesOfOperation = 0x6060; // sub 0, chung 2 bánh + private const ushort ObjModesOfOperationDisplay = 0x6061; + private const ushort ObjPositionActual = 0x6064; // sub 1/2, counts + private const ushort ObjVelocityActual = 0x606C; // sub 1/2, 0.1 r/min + private const ushort ObjTargetTorque = 0x6071; // sub 1/2, mA + private const ushort ObjTargetPosition = 0x607A; // sub 1/2, counts + private const ushort ObjProfileVelocity = 0x6081; // sub 1/2, r/min (position mode) + private const ushort ObjProfileAcceleration = 0x6083; // sub 1/2, ms + private const ushort ObjProfileDeceleration = 0x6084; // sub 1/2, ms + private const ushort ObjTargetVelocity = 0x60FF; // sub 1/2, 1/SpeedResolution r/min + private const ushort ObjProducerHeartbeatTime = 0x1017; // sub 0, ms - node tự phát heartbeat + private const ushort HeartbeatProducerTimeMs = 500; // phải nhỏ hơn HeartbeatConsumerTimeoutMs + private const int HeartbeatConsumerTimeoutMs = 2000; + private const int HeartbeatStartupWaitMs = 1500; // ~3 chu kỳ producer + private const int EnableTimeoutMs = 3000; // tổng thời gian đưa drive lên OperationEnabled + private const int StateStepDelayMs = 100; // chờ giữa mỗi bước chuyển trạng thái + // RPDO gộp target velocity của CẢ HAI trục vào MỘT khung: 0x60FF:01 (byte 0-3) + + // 0x60FF:02 (byte 4-7). Một frame lệnh cả hai bánh cùng lúc -> đồng bộ tuyệt đối, + // và không đi qua semaphore SDO nên không bị poll feedback chen ngang. + private const byte DualVelocityRpdoNumber = 1; // RPDO1, COB-ID 0x200 + nodeId + private const ushort ObjSyncControlFlag = 0x200F; // 0 = asynchronous control + private const ushort ObjMotorParams = 0x2026; // sub 6 = given speed resolution (1..10) + private const byte SubSpeedResolution = 0x06; + + // Feedback 0x606C luôn là 0.1 r/min bất kể 0x2026:06 + private const double FeedbackUnitRpm = 0.1; + private const int MaxRpm = 1000; + + private readonly ICanOpenManager? _canOpenManager; + private readonly string _canInterface; + private readonly byte _nodeId; + private readonly Zlac8015dAxis _axis; + private readonly byte _axisSub; // sub-index của trục: 1 = trái, 2 = phải + private readonly int _countsPerRevolution; + private readonly int _configuredSpeedResolution; + private int _speedResolution; // hiệu lực thực tế (fallback = 1 nếu drive từ chối 0x2026:06) + private readonly ushort _accelTimeMs; + private readonly ushort _decelTimeMs; + private readonly int _pdoConfigRetryCount; + private readonly int _pdoConfigRetryTimeoutMs; + private readonly bool _useHeartbeatCheck; + // Trạng thái runtime của heartbeat check: tắt nếu không ghi được 0x1017 (drive không phát heartbeat) + private volatile bool _heartbeatCheckActive; + private readonly ILogger _logger; + private CanOpenDevice? _canOpenDevice; + + // TPDO layout dùng chung cho cả 2 trục (xem doc comment của class) + private const byte PositionTpdoNumber = 1; // 0x6064:01 + 0x6064:02 + private const byte StatuswordTpdoNumber = 2; // 0x6041:00 + private const byte VelocityTpdoNumber = 3; // 0x606C:01 + 0x606C:02 + // Firmware phát TPDO theo thay đổi dữ liệu, inhibit time chặn phát dồn dập (đơn vị 100µs) + private const ushort TpdoInhibitTime100Us = 500; // 50ms + + // Registry mapping TPDO của riêng trục này: (index, subIndex) -> info + private readonly ConcurrentDictionary<(ushort Index, byte SubIndex), PdoMappingInfo> _pdoMappingRegistry = new(); + + // Kế hoạch parse TPDO tính sẵn theo PdoNumber. OnPdoReceived chạy đồng bộ trên thread + // nhận frame CAN (PdoManager.OnFrameReceived) nên tuyệt đối không LINQ/cấp phát mỗi frame. + private Dictionary _tpdoParsePlan = new(); + + // Thread-safe cached values (đơn vị interface: counts, counts/s) + private readonly Lock _lock = new(); + private Statusword _cachedStatusword; + private int _cachedPosition; + private int _cachedVelocity; + + // Thời điểm TPDO cập nhật từng giá trị lần cuối. Chỉ tin cache khi TPDO còn "tươi"; + // nếu TPDO không về (mapping lỗi/chưa Operational) thì đọc thẳng SDO. Không có + // fallback này thì Position/Velocity đứng yên ở 0 vĩnh viễn dù drive vẫn chạy. + private DateTime _statuswordUpdatedAt = DateTime.MinValue; + private DateTime _positionUpdatedAt = DateTime.MinValue; + private DateTime _velocityUpdatedAt = DateTime.MinValue; + private static readonly TimeSpan TpdoCacheTtl = TimeSpan.FromMilliseconds(200); + + // Poll nền làm tươi feedback khi TPDO không về (xem StartFeedbackPolling) + private const int FeedbackPollIntervalMs = 50; + private static readonly TimeSpan TpdoAbsentGracePeriod = TimeSpan.FromSeconds(3); + private CancellationTokenSource? _feedbackPollCts; + private Task? _feedbackPollTask; + private volatile bool _feedbackPollWarned; + private DateTime? _tpdoAbsentSinceUtc; + private short _cachedTorque; + private int _targetPosition; + + public event EventHandler? StatuswordChanged; + public event EventHandler? PositionChanged; + public event EventHandler? VelocityChanged; + + public Zlac8015dServo(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider) + : base(deviceId, deviceName, DeviceType.CiA402Servo) + { + var config = new Zlac8015dConfig(); + connection.Bind(config); + + if (string.IsNullOrWhiteSpace(config.CanInterface)) + throw new InvalidOperationException("CanInterface is required in connection configuration"); + + if (string.IsNullOrWhiteSpace(config.NodeId)) + throw new InvalidOperationException("NodeId is required in connection configuration"); + + if (!Enum.TryParse(config.Axis, ignoreCase: true, out _axis)) + throw new InvalidOperationException($"Invalid Axis: '{config.Axis}'. Must be 'Left' or 'Right'"); + + _canInterface = config.CanInterface; + + if (!byte.TryParse(config.NodeId, out _nodeId) || _nodeId == 0 || _nodeId > 127) + throw new InvalidOperationException($"Invalid NodeId: {config.NodeId}. Must be between 1 and 127"); + + if (config.CountsPerRevolution <= 0) + throw new InvalidOperationException($"Invalid CountsPerRevolution: {config.CountsPerRevolution}. Must be greater than 0"); + + if (config.SpeedResolution < 1 || config.SpeedResolution > 10) + throw new InvalidOperationException($"Invalid SpeedResolution: {config.SpeedResolution}. Must be between 1 and 10"); + + _countsPerRevolution = config.CountsPerRevolution; + _configuredSpeedResolution = config.SpeedResolution; + _speedResolution = config.SpeedResolution; + _accelTimeMs = config.AccelTimeMs; + _decelTimeMs = config.DecelTimeMs; + + _axisSub = _axis == Zlac8015dAxis.Left ? (byte)1 : (byte)2; + + _pdoConfigRetryCount = config.PdoConfigRetryCount ?? 3; + _pdoConfigRetryTimeoutMs = config.PdoConfigRetryTimeoutMs ?? 1000; + _useHeartbeatCheck = config.UseHeartbeatCheck ?? true; + + AutoReconnectEnabled = config.AutoReconnectEnabled ?? true; + ReconnectDelayMs = config.ReconnectDelayMs ?? 3000; + MaxReconnectAttempts = config.MaxReconnectAttempts ?? 0; + + SetProperty("CanInterface", _canInterface); + SetProperty("NodeId", _nodeId.ToString()); + SetProperty("Axis", _axis.ToString()); + SetProperty("UseHeartbeatCheck", _useHeartbeatCheck.ToString()); + + _canOpenManager = serviceProvider.GetRequiredService(); + _logger = serviceProvider.GetRequiredService().CreateLogger(); + + _cachedStatusword = new Statusword(0); + } + + protected override IEnumerable CreatePropertyDescriptions() + { + yield return new PropertyDescription("CanInterface", "CAN Interface", "Tên CAN interface (ví dụ: can0, can1)") + { + DataType = "string", + IsReadOnly = true, + DisplayOrder = 1, + Category = "Kết nối", + DefaultValue = "" + }; + + yield return new PropertyDescription("NodeId", "Node ID", "CANOpen Node ID (1-127), chung cho cả 2 bánh") + { + DataType = "number", + IsReadOnly = true, + DisplayOrder = 2, + Category = "Kết nối", + DefaultValue = "1" + }; + + yield return new PropertyDescription("Axis", "Trục", "Trục motor: Left hoặc Right") + { + DataType = "string", + IsReadOnly = true, + DisplayOrder = 3, + Category = "Kết nối", + DefaultValue = "Left" + }; + + yield return new PropertyDescription("UseHeartbeatCheck", "Use Heartbeat Check", "Bật/tắt kiểm tra heartbeat") + { + DataType = "boolean", + IsReadOnly = true, + DisplayOrder = 4, + Category = "Cấu hình", + DefaultValue = "true" + }; + + yield return new PropertyDescription("Statusword", "Statusword", "Trạng thái servo (hex, chung 2 bánh)") + { + DataType = "string", + IsReadOnly = true, + DisplayOrder = 5, + Category = "Trạng thái", + DefaultValue = "0x0000" + }; + + yield return new PropertyDescription("Position", "Vị trí", "Vị trí hiện tại (encoder counts)") + { + DataType = "number", + IsReadOnly = true, + DisplayOrder = 6, + Category = "Trạng thái", + DefaultValue = "0" + }; + + yield return new PropertyDescription("Velocity", "Vận tốc", "Vận tốc hiện tại (encoder counts/s)") + { + DataType = "number", + IsReadOnly = true, + DisplayOrder = 7, + Category = "Trạng thái", + DefaultValue = "0" + }; + + yield return new PropertyDescription("DriveState", "Drive State", "Trạng thái drive") + { + DataType = "string", + IsReadOnly = true, + DisplayOrder = 8, + Category = "Trạng thái", + DefaultValue = "Unknown" + }; + } + + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + if (_canOpenManager == null) + { + throw new InvalidOperationException("ICanOpenManager service is required. Make sure to register it in DI container."); + } + + // CanOpenManager cache theo (interface, nodeId) nên 2 instance trái/phải nhận cùng một CanOpenDevice + _canOpenDevice = await _canOpenManager.GetOrCreateDeviceAsync(_canInterface, _nodeId, cancellationToken); + + _canOpenDevice.PdoReceived += OnPdoReceived; + + ConfigurePdos(); + UpdateProperties(); + } + + protected override async Task OnConnectAsync(CancellationToken cancellationToken) + { + if (_canOpenDevice == null) + throw new InvalidOperationException("Device not initialized. Call InitializeAsync first."); + + if (_canOpenManager != null) + { + var canBus = _canOpenManager.GetCanBus(_canInterface); + if (canBus == null || !canBus.IsConnected) + { + throw new InvalidOperationException($"ICanBus for interface '{_canInterface}' is not connected."); + } + } + + // Hai facade trái/phải dùng chung node vật lý: giữ lock để trình tự + // PDO config + NMT + tham số của trục này không xen kẽ với trục kia + await _canOpenDevice.ConfigurationLock.WaitAsync(cancellationToken); + try + { + await ConfigureDevicePdosAsync(cancellationToken); + + var currentState = _canOpenDevice.State; + if (currentState != NmtState.PreOperational && currentState != NmtState.Stopped) + { + await _canOpenDevice.SendNmtCommandAsync(NmtCommand.PreOperational, cancellationToken); + await Task.Delay(100, cancellationToken); + } + + // Bật heartbeat SAU khi cấu hình PDO: PdoManager gửi NMT Reset Communication, + // lệnh này reset vùng object 0x1000-0x1FFF về mặc định (0x1017 = 0) nên nếu ghi + // trước thì heartbeat producer bị xóa và node không bao giờ phát heartbeat. + if (_useHeartbeatCheck) + { + await EnableHeartbeatAsync(cancellationToken); + } + + await _canOpenDevice.StartNodeAsync(cancellationToken); + await VerifyOperationalAsync(cancellationToken); + + await ConfigureDriveParametersAsync(cancellationToken); + + bool rpdoReady = _canOpenDevice.Pdo.IsRpdoConfigured(DualVelocityRpdoNumber); + _logger.LogInformation( + "ZLAC8015D Node {NodeId} axis {Axis}: lệnh vận tốc đồng bộ qua RPDO1 = {State}. {Detail}", + _nodeId, _axis, rpdoReady ? "BẬT" : "TẮT", + rpdoReady + ? "Hai bánh nhận lệnh trong cùng một khung CAN." + : "Firmware không nhận RPDO -> fallback ghi SDO tuần tự (có thể lệch nhẹ)."); + } + finally + { + _canOpenDevice.ConfigurationLock.Release(); + } + + StartFeedbackPolling(); + UpdateProperties(); + } + + /// + /// Ghi 0x1017 để drive tự phát heartbeat, bật monitor phía consumer rồi chờ heartbeat đầu tiên về. + /// Nếu drive không phát (firmware không hỗ trợ 0x1017, hoặc bị Reset Communication xóa) thì + /// tắt heartbeat check và fallback sang đọc statusword — nếu không, IsAlive không bao giờ true + /// và device sẽ fail connect vĩnh viễn. + /// + private async Task EnableHeartbeatAsync(CancellationToken ct) + { + try + { + await _canOpenDevice!.WriteUInt16Async(ObjProducerHeartbeatTime, 0, HeartbeatProducerTimeMs, ct); + _canOpenDevice.AutoVerifyStateViaHeartbeat = true; + _canOpenDevice.EnableHeartbeatMonitoring(timeoutMs: HeartbeatConsumerTimeoutMs); + + if (await WaitForFirstHeartbeatAsync(ct)) + { + _heartbeatCheckActive = true; + return; + } + + _heartbeatCheckActive = false; + _canOpenDevice.AutoVerifyStateViaHeartbeat = false; + _canOpenDevice.DisableHeartbeatMonitoring(); + _logger.LogWarning( + "ZLAC8015D Node {NodeId}: đã ghi 0x1017 = {TimeMs}ms nhưng không nhận được heartbeat sau {WaitMs}ms. " + + "Tắt kiểm tra heartbeat, fallback đọc statusword.", + _nodeId, HeartbeatProducerTimeMs, HeartbeatStartupWaitMs); + } + catch (Exception ex) + { + _heartbeatCheckActive = false; + _logger.LogWarning(ex, + "ZLAC8015D Node {NodeId}: không ghi được 0x1017 (producer heartbeat = {TimeMs}ms), tắt kiểm tra heartbeat, fallback đọc statusword", + _nodeId, HeartbeatProducerTimeMs); + } + } + + /// + /// Xác nhận node thật sự vào Operational sau NMT Start. TPDO CHỈ được phát ở Operational, + /// nên nếu lệnh Start bị bỏ qua thì mọi cấu hình TPDO đúng đến đâu cũng vô nghĩa. + /// Trạng thái NMT lấy từ heartbeat (byte dữ liệu của frame 0x700+NodeId). + /// + private async Task VerifyOperationalAsync(CancellationToken ct) + { + if (!_heartbeatCheckActive) + return; // Không có heartbeat thì không quan sát được state, bỏ qua + + const int maxWaitMs = 1500; + int waited = 0; + + while (waited < maxWaitMs) + { + await Task.Delay(100, ct); + waited += 100; + + var nodeInfo = _canOpenDevice!.Heartbeat.GetNodeInfo(_nodeId); + if (nodeInfo?.LastState == NmtState.Operational) + { + _logger.LogInformation("ZLAC8015D Node {NodeId} axis {Axis}: node đã vào Operational, TPDO sẽ bắt đầu phát.", + _nodeId, _axis); + return; + } + } + + var lastState = _canOpenDevice!.Heartbeat.GetNodeInfo(_nodeId)?.LastState; + _logger.LogWarning( + "ZLAC8015D Node {NodeId} axis {Axis}: sau NMT Start, heartbeat vẫn báo trạng thái {State} (không phải Operational) sau {MaxWaitMs}ms. " + + "TPDO sẽ không phát — feedback sẽ chạy bằng fallback SDO.", + _nodeId, _axis, lastState?.ToString() ?? "không rõ", maxWaitMs); + } + + private async Task WaitForFirstHeartbeatAsync(CancellationToken ct) + { + int waited = 0; + while (waited < HeartbeatStartupWaitMs) + { + await Task.Delay(50, ct); + waited += 50; + + var nodeInfo = _canOpenDevice!.Heartbeat.GetNodeInfo(_nodeId); + if (nodeInfo != null && nodeInfo.IsAlive) + return true; + } + + return false; + } + + /// + /// Ghi các tham số vận hành của ZLAC8015D sau khi node Operational: + /// chế độ điều khiển bất đồng bộ, độ phân giải tốc độ, thời gian tăng/giảm tốc. + /// Hai instance trái/phải ghi trùng các object chung (0x200F, 0x2026) — idempotent, không sao. + /// + private async Task ConfigureDriveParametersAsync(CancellationToken ct) + { + if (_canOpenDevice == null) return; + + // 0x200F = 0: asynchronous control (mỗi bánh nhận lệnh độc lập, không chờ frame sync) + await _canOpenDevice.WriteUInt16Async(ObjSyncControlFlag, 0, 0, ct); + + // 0x2026:06 - độ phân giải tốc độ đặt (1/N r/min). Firmware cũ có thể không hỗ trợ. + // QUAN TRỌNG: object này là CHUNG cho cả node nhưng _speedResolution là bản riêng của + // từng instance trái/phải. Nếu một instance ghi lỗi mà tự đoán fallback = 1 trong khi + // instance kia đã ghi 10 thành công, thì mọi lệnh SDO của trục lệch sẽ nhỏ hơn 10 lần + // -> một bánh luôn chạy chậm hơn bánh kia. Vì vậy luôn ĐỌC LẠI giá trị thật từ drive + // để hai instance và drive thống nhất một độ phân giải. + try + { + await _canOpenDevice.WriteUInt16Async(ObjMotorParams, SubSpeedResolution, (ushort)_configuredSpeedResolution, ct); + _speedResolution = _configuredSpeedResolution; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "ZLAC8015D Node {NodeId} axis {Axis}: không ghi được 0x2026:06 (speed resolution = {Resolution}), sẽ đọc lại giá trị thật từ drive", + _nodeId, _axis, _configuredSpeedResolution); + } + + try + { + ushort actual = await _canOpenDevice.ReadUInt16Async(ObjMotorParams, SubSpeedResolution, ct); + if (actual >= 1 && actual <= 10) + { + if (actual != _speedResolution) + { + _logger.LogWarning( + "ZLAC8015D Node {NodeId} axis {Axis}: 0x2026:06 trên drive = {Actual} khác giá trị dự kiến {Expected}, dùng giá trị của drive", + _nodeId, _axis, actual, _speedResolution); + } + _speedResolution = actual; + } + } + catch (Exception ex) + { + // Không đọc được (firmware không hỗ trợ 0x2026:06) -> chắc chắn không ghi được, + // drive chạy đơn vị mặc định 1 r/min cho cả hai trục nên fallback 1 là nhất quán. + _speedResolution = 1; + _logger.LogWarning(ex, + "ZLAC8015D Node {NodeId} axis {Axis}: không đọc được 0x2026:06, fallback đơn vị lệnh 1 r/min", + _nodeId, _axis); + } + + _logger.LogInformation("ZLAC8015D Node {NodeId} axis {Axis}: speed resolution hiệu lực = 1/{Resolution} r/min", + _nodeId, _axis, _speedResolution); + + // Thời gian tăng/giảm tốc (ms). Ghi cho CẢ HAI trục chứ không chỉ trục mình: khi + // instance kia connect SAU, bước cấu hình PDO của nó gửi NMT Reset Communication làm + // drive khôi phục mặc định (đo thực tế: trục đã ghi 200ms bị trả về 10ms) -> hai bánh + // tăng/giảm tốc lệch nhau 20 lần, một bánh luôn "ì" hơn ở pha quá độ. Hai instance + // dùng chung AccelTimeMs/DecelTimeMs trong config nên ghi trùng là vô hại. + for (byte sub = 1; sub <= 2; sub++) + { + await _canOpenDevice.WriteUInt32Async(ObjProfileAcceleration, sub, _accelTimeMs, ct); + await _canOpenDevice.WriteUInt32Async(ObjProfileDeceleration, sub, _decelTimeMs, ct); + } + } + + protected override async Task OnDisconnectAsync(CancellationToken cancellationToken) + { + StopFeedbackPolling(); + + if (_canOpenDevice != null) + { + // Dừng bánh của trục này trước khi ngắt (node CAN dùng chung với trục còn lại nên không stop node ở đây) + try + { + await _canOpenDevice.WriteInt32Async(ObjTargetVelocity, _axisSub, 0, cancellationToken); + } + catch + { + // Ignore - bus có thể đã mất + } + _canOpenDevice.DisableHeartbeatMonitoring(); + } + } + + protected override async Task OnResetAsync(CancellationToken cancellationToken) + { + try + { + await DisableAsync(cancellationToken); + await Task.Delay(100, cancellationToken); + + var state = await GetStateAsync(cancellationToken); + if (state == DriveState.Fault) + { + await FaultResetAsync(cancellationToken); + await Task.Delay(100, cancellationToken); + } + + await ShutdownAsync(cancellationToken); + await Task.Delay(100, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error occurred during reset for Node {NodeId} axis {Axis}", _nodeId, _axis); + } + + UpdateProperties(); + } + + protected override async Task OnCheckConnectionAsync(CancellationToken cancellationToken) + { + if (_canOpenDevice == null) + return false; + + try + { + if (_heartbeatCheckActive) + { + var nodeInfo = _canOpenDevice.Heartbeat.GetNodeInfo(_nodeId); + if (nodeInfo == null || !nodeInfo.IsAlive) + { + return false; + } + } + + await GetStatuswordAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking connection for Node {NodeId} axis {Axis}", _nodeId, _axis); + return false; + } + } + + #region PDO Configuration + + /// + /// Đăng ký TPDO feedback cho trục này lên CanOpenDevice dùng chung. + /// ZLAC8015D chỉ có 3 TPDO (0x1A03 không tồn tại trên firmware). Firmware phát TPDO + /// theo THAY ĐỔI dữ liệu chứ không theo event timer (đo thực tế trên bus), nên mỗi TPDO + /// chỉ chở một loại dữ liệu và object hay thay đổi phải đứng đầu: + /// TPDO1 = position CẢ HAI trục (0x6064:01 + 0x6064:02) = 8 byte + /// TPDO2 = statusword (0x6041:00) = 2 byte + /// TPDO3 = velocity CẢ HAI trục (0x606C:01 + 0x606C:02) = 8 byte + /// (layout cũ TPDO1/2 = statusword + position từng trục KHÔNG BAO GIỜ phát vì object + /// đầu tiên - statusword - hầu như không đổi.) + /// Hai instance cùng đăng ký mapping GIỐNG HỆT nhau (idempotent), mỗi instance chỉ đọc + /// phần bit của trục mình: trái bit 0-31, phải bit 32-63. + /// + private void ConfigurePdos() + { + if (_canOpenDevice == null) + return; + + _pdoMappingRegistry.Clear(); + + // TPDO1 position: 0x6064:01 (bit 0-31) + 0x6064:02 (bit 32-63), counts + var positionTpdo = new PdoConfiguration(PositionTpdoNumber, (uint)(0x180 + (PositionTpdoNumber - 1) * 0x100 + _nodeId)) + { + EventTimer = 50, + InhibitTime = TpdoInhibitTime100Us + }; + positionTpdo.AddMapping(new PdoMapping(ObjPositionActual, 1, 32)); + positionTpdo.AddMapping(new PdoMapping(ObjPositionActual, 2, 32)); + _canOpenDevice.Pdo.ConfigureTPDO(positionTpdo); + + _pdoMappingRegistry[(ObjPositionActual, _axisSub)] = new PdoMappingInfo + { + IsRPDO = false, + PdoNumber = PositionTpdoNumber, + BitOffset = (_axisSub - 1) * 32, + BitLength = 32, + ObjectIndex = ObjPositionActual, + SubIndex = _axisSub + }; + + // TPDO2 statusword: 0x6041:00 (16 bit, chung 2 trục). Statusword ít đổi nên TPDO này + // hiếm khi phát — nhưng khi đổi (vd fault) sẽ phát ngay lập tức; giữa các lần đổi, + // poll SDO nền vẫn làm tươi định kỳ. + var statuswordTpdo = new PdoConfiguration(StatuswordTpdoNumber, (uint)(0x180 + (StatuswordTpdoNumber - 1) * 0x100 + _nodeId)) + { + EventTimer = 50, + InhibitTime = TpdoInhibitTime100Us + }; + statuswordTpdo.AddMapping(new PdoMapping(ObjStatusword, 0, 16)); + _canOpenDevice.Pdo.ConfigureTPDO(statuswordTpdo); + + _pdoMappingRegistry[(ObjStatusword, (byte)0)] = new PdoMappingInfo + { + IsRPDO = false, + PdoNumber = StatuswordTpdoNumber, + BitOffset = 0, + BitLength = 16, + ObjectIndex = ObjStatusword, + SubIndex = 0 + }; + + // TPDO3 velocity: 0x606C:01 (bit 0-31) + 0x606C:02 (bit 32-63), đơn vị 0.1 r/min. + // InhibitTime 50ms để firmware không phát theo từng thay đổi (đo được 240 frame/s khi chạy). + var velocityTpdo = new PdoConfiguration(VelocityTpdoNumber, (uint)(0x180 + (VelocityTpdoNumber - 1) * 0x100 + _nodeId)) + { + EventTimer = 50, + InhibitTime = TpdoInhibitTime100Us + }; + velocityTpdo.AddMapping(new PdoMapping(ObjVelocityActual, 1, 32)); + velocityTpdo.AddMapping(new PdoMapping(ObjVelocityActual, 2, 32)); + _canOpenDevice.Pdo.ConfigureTPDO(velocityTpdo); + + _pdoMappingRegistry[(ObjVelocityActual, _axisSub)] = new PdoMappingInfo + { + IsRPDO = false, + PdoNumber = VelocityTpdoNumber, + BitOffset = (_axisSub - 1) * 32, + BitLength = 32, + ObjectIndex = ObjVelocityActual, + SubIndex = _axisSub + }; + + // RPDO1 lệnh vận tốc đồng bộ: 0x60FF:01 (bit 0-31) + 0x60FF:02 (bit 32-63). + // Cả hai instance đăng ký y hệt nhau (idempotent). Nếu firmware không nhận RPDO + // này thì TrySetBothTargetVelocitiesAsync trả false và tự fallback về SDO tuần tự. + var dualVelocityRpdo = new PdoConfiguration(DualVelocityRpdoNumber, (uint)(0x200 + _nodeId)); + dualVelocityRpdo.AddMapping(new PdoMapping(ObjTargetVelocity, 1, 32)); + dualVelocityRpdo.AddMapping(new PdoMapping(ObjTargetVelocity, 2, 32)); + _canOpenDevice.Pdo.ConfigureRPDO(dualVelocityRpdo); + + // Tính sẵn kế hoạch parse cho OnPdoReceived. RequiredBytes theo bit CUỐI CÙNG cần đọc, + // không phải tổng độ dài: trục phải đọc velocity ở bit 32-63 của TPDO3 nên cần đủ + // 8 byte dù chỉ map 32 bit. + _tpdoParsePlan = _pdoMappingRegistry.Values + .Where(m => !m.IsRPDO) + .GroupBy(m => m.PdoNumber) + .ToDictionary( + g => g.Key, + g => + { + var mappings = g.OrderBy(m => m.BitOffset).ToArray(); + int lastBit = mappings.Max(m => m.BitOffset + m.BitLength); + return (mappings, (lastBit + 7) / 8); + }); + } + + /// + /// Ghi PDO configuration xuống device qua SDO (retry giống driver PhenikaaX) + /// + private async Task ConfigureDevicePdosAsync(CancellationToken ct) + { + Exception? lastException = null; + for (int attempt = 1; attempt <= _pdoConfigRetryCount; attempt++) + { + try + { + await EnsurePreOperationalStateAsync(ct); + await _canOpenDevice!.WriteAllPdoConfigurationsToDeviceAsync(null, ct); + return; + } + catch (CanOpenTimeoutException timeoutEx) + { + lastException = timeoutEx; + + _logger.LogWarning("PDO configuration attempt {Attempt}/{MaxRetries} failed for Node {NodeId} axis {Axis}: {Error}", + attempt, _pdoConfigRetryCount, _nodeId, _axis, timeoutEx.Message); + + if (attempt < _pdoConfigRetryCount) + { + int delayMs = _pdoConfigRetryTimeoutMs * attempt; + await Task.Delay(delayMs, ct); + + try + { + await _canOpenDevice!.SendNmtCommandAsync(NmtCommand.PreOperational, ct); + await Task.Delay(200, ct); + } + catch + { + // Ignore errors + } + } + } + } + + throw new InvalidOperationException( + $"Failed to configure PDOs after {_pdoConfigRetryCount} attempts for Node {_nodeId} axis {_axis}. " + + $"Last error: {lastException?.Message}", lastException); + } + + private async Task EnsurePreOperationalStateAsync(CancellationToken ct) + { + var currentState = _canOpenDevice!.State; + if (currentState != NmtState.PreOperational) + { + await _canOpenDevice.SendNmtCommandAsync(NmtCommand.PreOperational, ct); + await Task.Delay(100, ct); + } + } + + #endregion + + #region PDO Receive + + private void OnPdoReceived(object? sender, PdoReceivedEventArgs e) + { + var data = e.Data; + + // Chỉ parse TPDO thuộc trục này (instance của trục kia tự parse TPDO của nó) + if (!_tpdoParsePlan.TryGetValue(data.PdoNumber, out var plan)) + return; + + if (data.Data.Length < plan.RequiredBytes) + { + _logger.LogWarning("TPDO{Number} data length ({Length}) is less than expected ({Expected})", + data.PdoNumber, data.Data.Length, plan.RequiredBytes); + return; + } + + // Cập nhật cache trong lock, nhưng bắn event NGOÀI lock: handler này chạy đồng bộ + // trên thread nhận frame CAN, subscriber chậm (UI/odometry) mà chạy trong lock sẽ + // chặn cả đường nhận CAN (SDO response, heartbeat, TPDO trục kia) -> trễ dây chuyền + // cả điều khiển lẫn hiển thị. + StatuswordChangedEventArgs? statuswordArgs = null; + PositionChangedEventArgs? positionArgs = null; + VelocityChangedEventArgs? velocityArgs = null; + ushort oldStatuswordValue = 0; + + lock (_lock) + { + foreach (var mappingInfo in plan.Mappings) + { + try + { + byte[] valueBytes = CiA402Helper.ExtractValueFromPdoData(data.Data, mappingInfo.BitOffset, mappingInfo.BitLength); + + switch (mappingInfo.ObjectIndex) + { + case ObjStatusword when valueBytes.Length >= 2: + var newStatusword = new Statusword(BitConverter.ToUInt16(valueBytes, 0)); + _statuswordUpdatedAt = DateTime.UtcNow; + + if (newStatusword.Value != _cachedStatusword.Value) + { + oldStatuswordValue = _cachedStatusword.Value; + statuswordArgs = new StatuswordChangedEventArgs( + newStatusword, _cachedStatusword.GetState(), newStatusword.GetState()); + _cachedStatusword = newStatusword; + } + break; + + case ObjPositionActual when valueBytes.Length >= 4: + var position = BitConverter.ToInt32(valueBytes, 0); + _positionUpdatedAt = DateTime.UtcNow; + + if (position != _cachedPosition) + { + positionArgs = new PositionChangedEventArgs(position, _cachedPosition); + _cachedPosition = position; + } + break; + + case ObjVelocityActual when valueBytes.Length >= 4: + var velocity = FeedbackRawToCountsPerSec(BitConverter.ToInt32(valueBytes, 0)); + _velocityUpdatedAt = DateTime.UtcNow; + + if (velocity != _cachedVelocity) + { + velocityArgs = new VelocityChangedEventArgs(velocity, _cachedVelocity); + _cachedVelocity = velocity; + } + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing TPDO{Number} mapping for object 0x{Index:X4}:{SubIndex}", + mappingInfo.PdoNumber, mappingInfo.ObjectIndex, mappingInfo.SubIndex); + } + } + } + + if (statuswordArgs != null) + { + if (statuswordArgs.NewState != statuswordArgs.OldState) + { + _logger.LogInformation( + "ZLAC8015D Node {NodeId} axis {Axis}: DriveState {OldState} -> {NewState} (statusword 0x{Old:X4} -> 0x{New:X4})", + _nodeId, _axis, statuswordArgs.OldState, statuswordArgs.NewState, + oldStatuswordValue, statuswordArgs.Statusword.Value); + } + + StatuswordChanged?.Invoke(this, statuswordArgs); + } + + if (positionArgs != null) + PositionChanged?.Invoke(this, positionArgs); + + if (velocityArgs != null) + VelocityChanged?.Invoke(this, velocityArgs); + + // Chỉ tốn công cập nhật property khi thực sự có giá trị thay đổi + if (statuswordArgs != null || positionArgs != null || velocityArgs != null) + UpdateProperties(); + } + + #endregion + + #region Unit Conversion + + /// + /// counts/s (đơn vị interface) -> giá trị lệnh 0x60FF (1/SpeedResolution r/min), kẹp ±1000 r/min + /// + private int CountsPerSecToCommandRaw(int countsPerSec) + { + double rpm = (double)countsPerSec * 60.0 / _countsPerRevolution; + double clamped = Math.Clamp(rpm, -MaxRpm, MaxRpm); + return (int)Math.Round(clamped * _speedResolution); + } + + /// + /// Giá trị feedback 0x606C (0.1 r/min) -> counts/s (đơn vị interface) + /// + private int FeedbackRawToCountsPerSec(int feedbackRaw) + { + return (int)Math.Round(feedbackRaw * FeedbackUnitRpm / 60.0 * _countsPerRevolution); + } + + /// + /// counts/s -> r/min nguyên (dùng cho 0x6081 profile velocity, range 1-1000) + /// + private uint CountsPerSecToRpm(uint countsPerSec) + { + double rpm = (double)countsPerSec * 60.0 / _countsPerRevolution; + return (uint)Math.Clamp(Math.Round(rpm), 1, MaxRpm); + } + + #endregion + + private void UpdateProperties() + { + try + { + lock (_lock) + { + SetProperty("Statusword", $"0x{_cachedStatusword.Value:X4}"); + SetProperty("Position", _cachedPosition.ToString()); + SetProperty("Velocity", _cachedVelocity.ToString()); + + var state = _cachedStatusword.GetState(); + SetProperty("DriveState", state.ToString()); + } + } + catch + { + // Ignore errors + } + } + + private CanOpenDevice RequireDevice() + { + return _canOpenDevice ?? throw new InvalidOperationException("Device not initialized"); + } + + #region ICiA402Servo Implementation + + public Statusword CachedStatusword + { + get { lock (_lock) { return _cachedStatusword; } } + } + + public int CachedPosition + { + get { lock (_lock) { return _cachedPosition; } } + } + + public int CachedVelocity + { + get { lock (_lock) { return _cachedVelocity; } } + } + + public short CachedTorque + { + get { lock (_lock) { return _cachedTorque; } } + } + + // Statusword & Controlword (0x6040/0x6041 sub 0 - chung cho cả 2 bánh) + public async Task GetStatuswordAsync(CancellationToken ct = default) + { + var device = RequireDevice(); + + // Chỉ dùng cache khi TPDO vừa cập nhật trong TTL. Trước đây cache được dùng + // mãi mãi khi != 0, nên nếu TPDO ngừng về thì GetStateAsync trả giá trị cũ + // vĩnh viễn và EnableAsync không bao giờ thấy trạng thái tiến triển. + lock (_lock) + { + if (DateTime.UtcNow - _statuswordUpdatedAt < TpdoCacheTtl) + return _cachedStatusword; + } + + return await ReadStatuswordViaSdoAsync(device, ct); + } + + /// + /// Đọc statusword trực tiếp qua SDO, bỏ qua cache TPDO. State machine dùng hàm này + /// để mỗi bước chuyển trạng thái đều thấy giá trị mới nhất từ drive. + /// + private async Task ReadStatuswordViaSdoAsync(CanOpenDevice device, CancellationToken ct) + { + var value = await device.ReadUInt16Async(ObjStatusword, 0, ct); + var statusword = new Statusword(value); + + bool changed; + Statusword previous; + lock (_lock) + { + previous = _cachedStatusword; + changed = statusword.Value != previous.Value; + _cachedStatusword = statusword; + // Không cập nhật _statuswordUpdatedAt: mốc đó chỉ dành cho TPDO, + // để lần đọc sau vẫn lấy giá trị tươi nếu TPDO chưa hoạt động. + } + + if (changed) + { + var oldState = previous.GetState(); + var newState = statusword.GetState(); + + if (newState != oldState) + { + _logger.LogInformation( + "ZLAC8015D Node {NodeId} axis {Axis}: DriveState {OldState} -> {NewState} (statusword 0x{Old:X4} -> 0x{New:X4}, đọc qua SDO)", + _nodeId, _axis, oldState, newState, previous.Value, statusword.Value); + } + + StatuswordChanged?.Invoke(this, new StatuswordChangedEventArgs(statusword, oldState, newState)); + } + + return statusword; + } + + private async Task ReadStateViaSdoAsync(CancellationToken ct) + { + var statusword = await ReadStatuswordViaSdoAsync(RequireDevice(), ct); + return statusword.GetState(); + } + + public async Task SetControlwordAsync(Controlword controlword, CancellationToken ct = default) + { + await RequireDevice().WriteUInt16Async(ObjControlword, 0, controlword.Value, ct); + } + + public async Task GetStateAsync(CancellationToken ct = default) + { + var statusword = await GetStatuswordAsync(ct); + return statusword.GetState(); + } + + // Operation Mode (0x6060 sub 0 - chung cho cả 2 bánh) + public async Task SetOperationModeAsync(OperationMode mode, CancellationToken ct = default) + { + await RequireDevice().WriteUInt8Async(ObjModesOfOperation, 0, (byte)mode, ct); + } + + public async Task GetOperationModeAsync(CancellationToken ct = default) + { + var value = await RequireDevice().ReadUInt8Async(ObjModesOfOperationDisplay, 0, ct); + return (OperationMode)(sbyte)value; + } + + // State Machine Control (chung 2 bánh - enable một trục là enable cả drive) + public async Task FaultResetAsync(CancellationToken ct = default) + { + await SetControlwordAsync(Controlword.FaultResetCmd, ct); + await Task.Delay(100, ct); + await SetControlwordAsync(new Controlword(0x0000), ct); + } + + public async Task ShutdownAsync(CancellationToken ct = default) + { + await SetControlwordAsync(Controlword.Shutdown, ct); + } + + public async Task SwitchOnAsync(CancellationToken ct = default) + { + await SetControlwordAsync(Controlword.SwitchOnCmd, ct); + } + + public async Task EnableOperationAsync(CancellationToken ct = default) + { + await SetControlwordAsync(Controlword.EnableOperationCmd, ct); + } + + public async Task DisableOperationAsync(CancellationToken ct = default) + { + await SetControlwordAsync(Controlword.DisableOperation, ct); + } + + public async Task QuickStopAsync(CancellationToken ct = default) + { + await SetControlwordAsync(Controlword.QuickStopCmd, ct); + } + + /// + /// Đưa drive lên OperationEnabled theo state machine CiA402. + /// Chạy vòng lặp cho tới khi đạt trạng thái hoặc hết thời gian, mỗi vòng đọc statusword + /// tươi qua SDO (không tin cache TPDO), và ném exception kèm statusword nếu thất bại — + /// bản cũ chạy một lượt tuyến tính rồi im lặng bỏ cuộc nếu drive chưa kịp chuyển trạng thái. + /// + public async Task EnableAsync(CancellationToken ct = default) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(EnableTimeoutMs); + DriveState state = DriveState.Unknown; + + while (DateTime.UtcNow < deadline) + { + state = await ReadStateViaSdoAsync(ct); + + switch (state) + { + case DriveState.OperationEnabled: + return; + + case DriveState.NotReadyToSwitchOn: + // Trạng thái tự động của CiA402: drive đang tự khởi tạo, KHÔNG nhận lệnh + // controlword nào. Chỉ được chờ nó tự chuyển sang SwitchOnDisabled. + await Task.Delay(StateStepDelayMs, ct); + break; + + case DriveState.Fault: + await FaultResetAsync(ct); + await Task.Delay(StateStepDelayMs, ct); + break; + + case DriveState.SwitchOnDisabled: + await ShutdownAsync(ct); // 0x0006 -> ReadyToSwitchOn + await Task.Delay(StateStepDelayMs, ct); + break; + + case DriveState.ReadyToSwitchOn: + await SwitchOnAsync(ct); // 0x0007 -> SwitchedOn + await Task.Delay(StateStepDelayMs, ct); + break; + + case DriveState.SwitchedOn: + await EnableOperationAsync(ct); // 0x000F -> OperationEnabled + await Task.Delay(StateStepDelayMs, ct); + break; + + default: + await Task.Delay(StateStepDelayMs, ct); + break; + } + } + + var statusword = await ReadStatuswordViaSdoAsync(RequireDevice(), ct); + throw new InvalidOperationException( + $"ZLAC8015D Node {_nodeId} axis {_axis}: không đạt được OperationEnabled sau {EnableTimeoutMs}ms. " + + $"Trạng thái cuối: {state}, statusword = 0x{statusword.Value:X4}. " + + (statusword.Value == 0 + ? "Statusword = 0 nghĩa là drive không trả về trạng thái hợp lệ — kiểm tra nguồn động lực (motor power) và dây encoder." + : "Kiểm tra fault code và nguồn động lực của drive.")); + } + + public async Task DisableAsync(CancellationToken ct = default) + { + // Đưa tốc độ trục này về 0 trước; controlword là chung nên chỉ disable + // khi caller thực sự muốn tắt cả drive (DifferentialDrive luôn disable cả 2 bánh) + try + { + await RequireDevice().WriteInt32Async(ObjTargetVelocity, _axisSub, 0, ct); + } + catch + { + // Ignore - vẫn tiếp tục disable + } + await DisableOperationAsync(ct); + } + + // Position Control (0x607A sub theo trục, counts) + /// + /// Vị trí thực tế (counts). Ưu tiên cache TPDO khi còn tươi, ngược lại đọc SDO 0x6064:sub. + /// Trước đây hàm này CHỈ trả cache TPDO nên khi TPDO không về (mapping lỗi) thì + /// Position đứng yên ở 0 mãi mãi dù động cơ vẫn quay. + /// + public async Task GetActualPositionAsync(CancellationToken ct = default) + { + var device = RequireDevice(); + + lock (_lock) + { + if (DateTime.UtcNow - _positionUpdatedAt < TpdoCacheTtl) + return _cachedPosition; + } + + var position = await device.ReadInt32Async(ObjPositionActual, _axisSub, ct); + + bool changed; + int oldPosition; + lock (_lock) + { + oldPosition = _cachedPosition; + changed = position != _cachedPosition; + _cachedPosition = position; + } + + if (changed) + { + PositionChanged?.Invoke(this, new PositionChangedEventArgs(position, oldPosition)); + UpdateProperties(); + } + + return position; + } + + public async Task SetTargetPositionAsync(int position, CancellationToken ct = default) + { + lock (_lock) + { + _targetPosition = position; + } + + await RequireDevice().WriteInt32Async(ObjTargetPosition, _axisSub, position, ct); + } + + public async Task SetProfileSpeedAsync(uint velocity, CancellationToken ct = default) + { + // 0x6081 sub theo trục: max speed trong position mode, đơn vị r/min (1-1000) + await RequireDevice().WriteUInt32Async(ObjProfileVelocity, _axisSub, CountsPerSecToRpm(velocity), ct); + } + + public async Task SetProfileVelocityAsync(uint velocity, CancellationToken ct = default) + { + // Giống driver PhenikaaX: profile velocity ghi vào target velocity (0x60FF) + await RequireDevice().WriteInt32Async(ObjTargetVelocity, _axisSub, CountsPerSecToCommandRaw((int)velocity), ct); + } + + public async Task SetProfileAccelerationAsync(uint acceleration, CancellationToken ct = default) + { + // ZLAC dùng thời gian tăng tốc (ms), không quy đổi được từ counts/s² + // -> luôn ghi giá trị AccelTimeMs từ config, bỏ qua tham số + _logger.LogTrace("ZLAC8015D axis {Axis}: SetProfileAcceleration({Value}) -> ghi AccelTimeMs={AccelMs}ms từ config", + _axis, acceleration, _accelTimeMs); + await RequireDevice().WriteUInt32Async(ObjProfileAcceleration, _axisSub, _accelTimeMs, ct); + } + + public async Task SetProfileDecelerationAsync(uint deceleration, CancellationToken ct = default) + { + _logger.LogTrace("ZLAC8015D axis {Axis}: SetProfileDeceleration({Value}) -> ghi DecelTimeMs={DecelMs}ms từ config", + _axis, deceleration, _decelTimeMs); + await RequireDevice().WriteUInt32Async(ObjProfileDeceleration, _axisSub, _decelTimeMs, ct); + } + + public async Task GetProfileSpeedAsync(CancellationToken ct = default) + { + return await RequireDevice().ReadUInt32Async(ObjProfileVelocity, _axisSub, ct); + } + + public async Task GetProfileAccelerationAsync(CancellationToken ct = default) + { + return await RequireDevice().ReadUInt32Async(ObjProfileAcceleration, _axisSub, ct); + } + + public async Task GetProfileDecelerationAsync(CancellationToken ct = default) + { + return await RequireDevice().ReadUInt32Async(ObjProfileDeceleration, _axisSub, ct); + } + + public async Task StartPositionMoveAsync(CancellationToken ct = default) + { + var newSetpoint = new Controlword(0x001F); + var resetSetpoint = new Controlword(0x000F); + await SetControlwordAsync(newSetpoint, ct); + await Task.Delay(50, ct); + await SetControlwordAsync(resetSetpoint, ct); + } + + public async Task MoveToPositionAsync(int position, uint velocity = 1000, uint acceleration = 5000, uint deceleration = 5000, CancellationToken ct = default) + { + _logger.LogInformation("Zlac8015dServo[{DeviceId}] axis {Axis}: MoveToPositionAsync position={Position}, velocity={Velocity}", + DeviceId, _axis, position, velocity); + + OperationMode currentMode; + DriveState currentState; + try + { + currentMode = await GetOperationModeAsync(ct); + currentState = await GetStateAsync(ct); + } + catch + { + currentMode = OperationMode.ProfilePosition; + currentState = DriveState.SwitchedOn; + } + + bool alreadyReady = currentMode == OperationMode.ProfilePosition && currentState == DriveState.OperationEnabled; + + if (!alreadyReady) + { + await DisableOperationAsync(ct); + await SetOperationModeAsync(OperationMode.ProfilePosition, ct); + await Task.Delay(50, ct); + await EnableAsync(ct); + await Task.Delay(50, ct); + } + + await SetProfileSpeedAsync(velocity, ct); + await SetProfileAccelerationAsync(acceleration, ct); + await SetProfileDecelerationAsync(deceleration, ct); + await SetTargetPositionAsync(position, ct); + await Task.Delay(150, ct); + await StartPositionMoveAsync(ct); + } + + // Velocity Control (0x60FF sub theo trục) + /// + /// Vận tốc thực tế (counts/s). Ưu tiên cache TPDO khi còn tươi, ngược lại đọc SDO 0x606C:sub. + /// + public async Task GetActualVelocityAsync(CancellationToken ct = default) + { + var device = RequireDevice(); + + lock (_lock) + { + if (DateTime.UtcNow - _velocityUpdatedAt < TpdoCacheTtl) + return _cachedVelocity; + } + + var raw = await device.ReadInt32Async(ObjVelocityActual, _axisSub, ct); + var velocity = FeedbackRawToCountsPerSec(raw); + + bool changed; + int oldVelocity; + lock (_lock) + { + oldVelocity = _cachedVelocity; + changed = velocity != _cachedVelocity; + _cachedVelocity = velocity; + } + + if (changed) + { + VelocityChanged?.Invoke(this, new VelocityChangedEventArgs(velocity, oldVelocity)); + UpdateProperties(); + } + + return velocity; + } + + public async Task SetTargetVelocityAsync(int velocity, CancellationToken ct = default) + { + await RequireDevice().WriteInt32Async(ObjTargetVelocity, _axisSub, CountsPerSecToCommandRaw(velocity), ct); + } + + /// + /// Gửi vận tốc CẢ HAI trục trong MỘT khung RPDO để hai bánh nhận lệnh đồng thời. + /// Vì 0x60FF:01 và 0x60FF:02 dùng chung một node vật lý, hai lệnh SDO tuần tự sẽ tới + /// lệch nhau (nặng hơn khi feedback poll đang chiếm kênh SDO), gây hai bánh chạy lệch. + /// Nếu RPDO không có, fallback ghi SDO THEO LÔ (giữ kênh SDO liên tục cho cả hai lệnh): + /// độ lệch giữa hai bánh chỉ còn đúng một vòng SDO (~ms), không bị poll feedback chen + /// vào giữa như khi caller tự ghi hai lệnh SetTargetVelocityAsync riêng lẻ. + /// Trả false chỉ khi cả hai đường đều thất bại -> caller fallback về SDO tuần tự. + /// Đơn vị leftCountsPerSec/rightCountsPerSec giống SetTargetVelocityAsync (đã gồm IsReversed). + /// + public async Task TrySetBothTargetVelocitiesAsync(int leftCountsPerSec, int rightCountsPerSec, CancellationToken ct = default) + { + var device = RequireDevice(); + + // Conversion counts/s -> raw là như nhau cho cả hai trục (cùng countsPerRevolution + // và speedResolution), nên tính cả hai từ một instance là chính xác. + int leftRaw = CountsPerSecToCommandRaw(leftCountsPerSec); + int rightRaw = CountsPerSecToCommandRaw(rightCountsPerSec); + + if (device.Pdo.IsRpdoConfigured(DualVelocityRpdoNumber)) + { + var frame = new byte[8]; + BitConverter.TryWriteBytes(frame.AsSpan(0, 4), leftRaw); // 0x60FF:01 + BitConverter.TryWriteBytes(frame.AsSpan(4, 4), rightRaw); // 0x60FF:02 + + try + { + await device.Pdo.SendRPDOAsync(DualVelocityRpdoNumber, frame, ct); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ZLAC8015D Node {NodeId}: gửi RPDO vận tốc gộp thất bại, thử fallback SDO theo lô", _nodeId); + } + } + + try + { + await device.WriteInt32BatchAsync( + [ + (ObjTargetVelocity, (byte)1, leftRaw), + (ObjTargetVelocity, (byte)2, rightRaw) + ], ct); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ZLAC8015D Node {NodeId}: ghi SDO theo lô vận tốc hai trục thất bại", _nodeId); + return false; + } + } + + public async Task TargetVelocityAsync(int targetVelocity, uint acceleration = 5000, uint deceleration = 5000, CancellationToken ct = default) + { + await SetOperationModeAsync(OperationMode.ProfileVelocity, ct); + await EnableAsync(ct); + await SetProfileAccelerationAsync(acceleration, ct); + await SetProfileDecelerationAsync(deceleration, ct); + await SetTargetVelocityAsync(targetVelocity, ct); + } + + public async Task ProfileVelocityAsync(int targetVelocity, uint acceleration = 5000, uint deceleration = 5000, CancellationToken ct = default) + { + await SetOperationModeAsync(OperationMode.ProfileVelocity, ct); + await Task.Delay(50, ct); + await EnableAsync(ct); + await SetProfileAccelerationAsync(acceleration, ct); + await SetProfileDecelerationAsync(deceleration, ct); + await SetTargetVelocityAsync(targetVelocity, ct); + } + + // Torque Control (0x6071 sub theo trục, đơn vị mA) + public async Task GetActualTorqueAsync(CancellationToken ct = default) + { + var value = await RequireDevice().ReadInt16Async(ObjTargetTorque, _axisSub, ct); + lock (_lock) + { + _cachedTorque = value; + return _cachedTorque; + } + } + + public async Task SetTargetTorqueAsync(short torque, CancellationToken ct = default) + { + await RequireDevice().WriteInt16Async(ObjTargetTorque, _axisSub, torque, ct); + } + + public async Task RunTorqueAsync(short torque, CancellationToken ct = default) + { + await SetOperationModeAsync(OperationMode.ProfileTorque, ct); + await Task.Delay(50, ct); + await SetTargetTorqueAsync(torque, ct); + } + + // Homing - ZLAC8015D là drive bánh xe hub, không hỗ trợ homing + public Task SetHomingMethodAsync(byte method, CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + public Task SetHomingSpeedAsync(int speed, CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + public Task SetHomingOffsetAsync(int offset, CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + public Task GetHomingMethodAsync(CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + public Task GetHomingSpeedAsync(CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + public Task GetHomingOffsetAsync(CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + public Task StartHomingAsync(byte method, int speed, CancellationToken ct = default) + => throw new NotSupportedException("ZLAC8015D không hỗ trợ homing"); + + // Target Position Checking + public bool IsAtTarget(int tolerance = 100, bool useStatusword = true) + { + if (useStatusword) + { + lock (_lock) + { + return _cachedStatusword.TargetReached; + } + } + else + { + lock (_lock) + { + return Math.Abs(_cachedPosition - _targetPosition) <= tolerance; + } + } + } + + public async Task WaitUntilAtTargetAsync(int tolerance = 100, bool useStatusword = true, int checkIntervalMs = 10, CancellationToken ct = default) + { + while (!ct.IsCancellationRequested) + { + if (IsAtTarget(tolerance, useStatusword)) + return; + + await Task.Delay(checkIntervalMs, ct); + } + + throw new OperationCanceledException("Wait for servo at target was cancelled", ct); + } + + // Error and Status Information + public async Task IsInFaultStateAsync(CancellationToken ct = default) + { + var state = await GetStateAsync(ct); + return state == DriveState.Fault; + } + + public async Task GetErrorRegisterAsync(CancellationToken ct = default) + { + try + { + return await RequireDevice().ReadUInt8Async(0x1001, 0, ct); + } + catch + { + return 0; + } + } + + public async Task GetErrorHistoryAsync(CancellationToken ct = default) + { + try + { + var device = RequireDevice(); + byte errorCount = await device.ReadUInt8Async(0x1003, 0, ct); + + if (errorCount == 0) return []; + + var errors = new List(); + for (byte i = 1; i <= errorCount && i <= 8; i++) + { + try + { + ushort errorCode = await device.ReadUInt16Async(0x1003, i, ct); + errors.Add(errorCode); + } + catch + { + break; + } + } + + return [.. errors]; + } + catch + { + return []; + } + } + + public async Task GetLatestErrorCodeAsync(CancellationToken ct = default) + { + var errorHistory = await GetErrorHistoryAsync(ct); + return errorHistory.Length > 0 ? errorHistory[0] : (ushort)0x0000; + } + + public async Task TryFaultResetAsync(CancellationToken ct = default) + { + var state = await GetStateAsync(ct); + if (state != DriveState.Fault) + return false; + + await FaultResetAsync(ct); + return true; + } + + public async Task IsEnabledAsync(CancellationToken ct = default) + { + var state = await GetStateAsync(ct); + return state == DriveState.OperationEnabled; + } + + public async Task IsReadyAsync(CancellationToken ct = default) + { + var state = await GetStateAsync(ct); + return state == DriveState.ReadyToSwitchOn || state == DriveState.SwitchedOn; + } + + #endregion + + #region Feedback polling fallback + + /// + /// Vòng poll nền làm tươi Position/Velocity qua SDO khi TPDO không về. + /// Cần thiết vì OdometryService và UI đọc CachedPosition/CachedVelocity một cách đồng bộ, + /// mà cache đó chỉ do TPDO ghi — TPDO hỏng thì odometry đứng yên mà không báo lỗi gì. + /// Nếu TPDO hoạt động, TTL còn tươi nên vòng lặp này không phát sinh SDO nào. + /// + private void StartFeedbackPolling() + { + StopFeedbackPolling(); + + _feedbackPollCts = new CancellationTokenSource(); + var ct = _feedbackPollCts.Token; + + _feedbackPollTask = Task.Run(async () => + { + // Xoay vòng statusword -> position -> velocity, mỗi tick đọc TỐI ĐA MỘT object. + // Kênh SDO là một hàng đợi tuần tự dùng chung cho cả node (2 trục): nếu mỗi tick + // đọc cả 3 object x 2 instance thì lệnh điều khiển (controlword, target velocity + // fallback SDO) phải xếp hàng sau feedback -> điều khiển bị trễ rõ rệt. + // Với tick 50ms, mỗi giá trị vẫn được làm tươi tối đa mỗi 150ms khi TPDO mất hẳn. + int pollPhase = 0; + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(FeedbackPollIntervalMs, ct); + + bool statuswordStale, positionStale, velocityStale; + lock (_lock) + { + var now = DateTime.UtcNow; + statuswordStale = now - _statuswordUpdatedAt >= TpdoCacheTtl; + positionStale = now - _positionUpdatedAt >= TpdoCacheTtl; + velocityStale = now - _velocityUpdatedAt >= TpdoCacheTtl; + } + + int selected = -1; + for (int i = 0; i < 3; i++) + { + int idx = (pollPhase + i) % 3; + bool isStale = idx switch { 0 => statuswordStale, 1 => positionStale, _ => velocityStale }; + if (isStale) + { + selected = idx; + pollPhase = idx + 1; + break; + } + } + + switch (selected) + { + // Statusword cũng phải được làm tươi, nếu không _cachedStatusword đứng + // mãi ở giá trị lần cuối (thường là OperationEnabled sau khi enable xong) + // và UI báo OperationEnabled vĩnh viễn kể cả khi drive đã fault. + case 0: + var before = CachedStatusword.Value; + var after = await ReadStatuswordViaSdoAsync(RequireDevice(), ct); + if (after.Value != before) + UpdateProperties(); + break; + + case 1: + await GetActualPositionAsync(ct); + break; + + case 2: + await GetActualVelocityAsync(ct); + break; + } + + // Báo cáo trung thực trạng thái TPDO: chỉ cảnh báo khi TPDO vắng LIÊN TỤC + // đủ lâu (không tính giai đoạn khởi động, lúc trục kia còn đang reset/cấu hình + // lại node), và báo lại khi TPDO hoạt động trở lại. + bool tpdoAlive = !positionStale; + + if (tpdoAlive) + { + _tpdoAbsentSinceUtc = null; + + if (_feedbackPollWarned) + { + _feedbackPollWarned = false; + _logger.LogInformation( + "ZLAC8015D Node {NodeId} axis {Axis}: TPDO feedback đã hoạt động trở lại, ngừng fallback SDO.", + _nodeId, _axis); + } + } + else + { + _tpdoAbsentSinceUtc ??= DateTime.UtcNow; + + if (!_feedbackPollWarned && + DateTime.UtcNow - _tpdoAbsentSinceUtc.Value >= TpdoAbsentGracePeriod) + { + _feedbackPollWarned = true; + _logger.LogWarning( + "ZLAC8015D Node {NodeId} axis {Axis}: không nhận được TPDO suốt {Seconds}s, đang fallback poll Statusword/Position/Velocity qua SDO mỗi {IntervalMs}ms. " + + "Kiểm tra node đã ở trạng thái Operational chưa.", + _nodeId, _axis, TpdoAbsentGracePeriod.TotalSeconds, FeedbackPollIntervalMs); + } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Feedback poll failed for Node {NodeId} axis {Axis}", _nodeId, _axis); + } + } + }, ct); + } + + private void StopFeedbackPolling() + { + if (_feedbackPollCts == null) + return; + + try + { + _feedbackPollCts.Cancel(); + _feedbackPollCts.Dispose(); + } + catch + { + // Ignore + } + + _feedbackPollCts = null; + _feedbackPollTask = null; + } + + #endregion + + protected override void Dispose(bool disposing) + { + if (disposing) + { + StopFeedbackPolling(); + + if (_canOpenDevice != null) + { + _canOpenDevice.PdoReceived -= OnPdoReceived; + } + } + base.Dispose(disposing); + } +} diff --git a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/ModelConfigs/ZLAC-test.json b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/ModelConfigs/ZLAC-test.json new file mode 100644 index 0000000..cd2c8cf --- /dev/null +++ b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/ModelConfigs/ZLAC-test.json @@ -0,0 +1,110 @@ +{ + "_Comment": "Config test cho ZLAC8015D: MỘT node CAN (can0, node 1, 500kbps) điều khiển CẢ HAI bánh. Hai device dưới đây là 2 facade trái/phải của cùng một drive - phải trỏ cùng CanInterface + NodeId, khác nhau ở Axis. WheelDiameter/CountsPerRevolution/PulsesPerRevolution cần đo lại theo bánh thực tế (mặc định: encoder ZLAC 4096 counts/vòng, bánh 8.5 inch ~0.17m).", + "Devices": { + "ZLAC_LeftWheel": { + "DeviceId": "left-wheel", + "Enabled": true, + "DeviceType": "CiA402Servo", + "DeviceName": "Left Wheel (ZLAC8015D)", + "DriverName": "ZLAC8015D", + "DriverVersion": "1.0.0", + "Description": "ZLAC8015D dual hub servo - truc trai", + "Connection": { + "CanInterface": "can0", + "NodeId": "1", + "Axis": "Left", + "CountsPerRevolution": 4096, + "SpeedResolution": 1, + "AccelTimeMs": 200, + "DecelTimeMs": 200, + "AutoReconnectEnabled": true, + "ReconnectDelayMs": 3000, + "MaxReconnectAttempts": 0, + "PdoConfigRetryCount": 3, + "PdoConfigRetryTimeoutMs": 1000, + "UseHeartbeatCheck": true + } + }, + "ZLAC_RightWheel": { + "DeviceId": "right-wheel", + "Enabled": true, + "DeviceType": "CiA402Servo", + "DeviceName": "Right Wheel (ZLAC8015D)", + "DriverName": "ZLAC8015D", + "DriverVersion": "1.0.0", + "Description": "ZLAC8015D dual hub servo - truc phai", + "Connection": { + "CanInterface": "can0", + "NodeId": "1", + "Axis": "Right", + "CountsPerRevolution": 4096, + "SpeedResolution": 1, + "AccelTimeMs": 200, + "DecelTimeMs": 200, + "AutoReconnectEnabled": true, + "ReconnectDelayMs": 3000, + "MaxReconnectAttempts": 0, + "PdoConfigRetryCount": 3, + "PdoConfigRetryTimeoutMs": 1000, + "UseHeartbeatCheck": true + } + } + }, + "Motion": { + "DifferentialDrive": { + "Enable": true, + "LeftWheel": { + "DeviceId": "left-wheel", + "Position": { + "Position": { + "X": 0.0, + "Y": 0.2, + "Z": 0.0 + }, + "Orientation": { + "X": 0.0, + "Y": 0.0, + "Z": 0.7071067, + "W": 0.7071067 + } + }, + "WheelDiameter": 0.17, + "PulsesPerRevolution": 4096, + "IsReversed": false + }, + "RightWheel": { + "DeviceId": "right-wheel", + "Position": { + "Position": { + "X": 0.0, + "Y": -0.2, + "Z": 0.0 + }, + "Orientation": { + "X": 0.0, + "Y": 0.0, + "Z": -0.7071067, + "W": 0.7071067 + } + }, + "WheelDiameter": 0.17, + "PulsesPerRevolution": 4096, + "IsReversed": true + } + }, + "ManualControl": { + "Enable": true, + "UsingKeyboard": true, + "MinLinearVelocity": 0.0, + "MaxLinearVelocity": 0.5, + "MinAngularVelocity": 0.0, + "MaxAngularVelocity": 0.3, + "UpdateRate": 20.0, + "Acceleration": 0.5, + "Deceleration": 0.5 + } + }, + "Cartographer": { + "Enable": false + } +} diff --git a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Motion/DifferentialDrive.cs b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Motion/DifferentialDrive.cs index cf5f571..eee4975 100644 --- a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Motion/DifferentialDrive.cs +++ b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Motion/DifferentialDrive.cs @@ -684,17 +684,28 @@ public class DifferentialDrive : IInverseKinematics, IOdometryEstimator, IHosted } var (leftVel, rightVel) = CalculateWheelVelocities(twist); - + try { - if (_leftWheelServo != null) + // Ưu tiên gửi cả hai bánh trong một khung (RPDO) để chúng nhận lệnh đồng thời. + // Nếu driver/firmware không hỗ trợ thì fallback về ghi SDO từng bánh. + bool sentTogether = false; + if (_leftWheelServo is IDualAxisVelocityCommand dualAxis) { - await _leftWheelServo.SetTargetVelocityAsync(leftVel, ct); + sentTogether = await dualAxis.TrySetBothTargetVelocitiesAsync(leftVel, rightVel, ct); } - if (_rightWheelServo != null) + if (!sentTogether) { - await _rightWheelServo.SetTargetVelocityAsync(rightVel, ct); + if (_leftWheelServo != null) + { + await _leftWheelServo.SetTargetVelocityAsync(leftVel, ct); + } + + if (_rightWheelServo != null) + { + await _rightWheelServo.SetTargetVelocityAsync(rightVel, ct); + } } } catch (Exception ex) diff --git a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Program.cs b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Program.cs index 11cf220..90ed969 100644 --- a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Program.cs +++ b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Program.cs @@ -247,6 +247,24 @@ if (!string.IsNullOrEmpty(netCoreDir)) var app = builder.Build(); +// SQLite không tự tạo thư mục chứa file database - tạo trước các thư mục trong ConnectionStrings +// (đường dẫn tương đối như ./bin/dbs phụ thuộc working directory nên có thể chưa tồn tại) +foreach (var connectionStringEntry in builder.Configuration.GetSection("ConnectionStrings").GetChildren()) +{ + var dataSource = connectionStringEntry.Value? + .Split(';') + .Select(part => part.Split('=', 2)) + .Where(kv => kv.Length == 2 && kv[0].Trim().Equals("Data Source", StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv[1].Trim()) + .FirstOrDefault(); + + var dbDirectory = string.IsNullOrEmpty(dataSource) ? null : Path.GetDirectoryName(dataSource); + if (!string.IsNullOrEmpty(dbDirectory)) + { + Directory.CreateDirectory(dbDirectory); + } +} + await app.Services.SeedApplicationDbAsync(); await app.Services.SeedScriptEngineDbAsync(); await app.Services.SeedMapManagerAsync(); diff --git a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/appsettings.json b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/appsettings.json index 11d0718..e14ca4e 100644 --- a/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/appsettings.json +++ b/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/appsettings.json @@ -70,7 +70,7 @@ }, "MBDV_Servo01": { "DeviceId": "right-wheel", - "Enabled": true, + "Enabled": false, "DeviceType": "CiA402Servo", "DeviceName": "Right Wheel Servo", "DriverName": "CiA402Servo", @@ -139,7 +139,7 @@ }, "MBDV_Servo02": { "DeviceId": "left-wheel", - "Enabled": true, + "Enabled": false, "DeviceType": "CiA402Servo", "DeviceName": "Left Wheel Servo", "DriverName": "CiA402Servo", @@ -206,8 +206,56 @@ } } }, + "ZLAC_LeftWheel": { + "DeviceId": "left-wheel", + "Enabled": true, + "DeviceType": "CiA402Servo", + "DeviceName": "Left Wheel (ZLAC8015D)", + "DriverName": "ZLAC8015D", + "DriverVersion": "1.0.0", + "Description": "ZLAC8015D dual hub servo - truc trai (1 node CAN dieu khien ca 2 banh, cung NodeId voi truc phai)", + "Connection": { + "CanInterface": "can0", + "NodeId": "1", + "Axis": "Left", + "CountsPerRevolution": 4096, + "SpeedResolution": 10, + "AccelTimeMs": 200, + "DecelTimeMs": 200, + "AutoReconnectEnabled": true, + "ReconnectDelayMs": 3000, + "MaxReconnectAttempts": 0, + "PdoConfigRetryCount": 3, + "PdoConfigRetryTimeoutMs": 1000, + "UseHeartbeatCheck": true + } + }, + "ZLAC_RightWheel": { + "DeviceId": "right-wheel", + "Enabled": true, + "DeviceType": "CiA402Servo", + "DeviceName": "Right Wheel (ZLAC8015D)", + "DriverName": "ZLAC8015D", + "DriverVersion": "1.0.0", + "Description": "ZLAC8015D dual hub servo - truc phai (1 node CAN dieu khien ca 2 banh, cung NodeId voi truc trai)", + "Connection": { + "CanInterface": "can0", + "NodeId": "1", + "Axis": "Right", + "CountsPerRevolution": 4096, + "SpeedResolution": 10, + "AccelTimeMs": 200, + "DecelTimeMs": 200, + "AutoReconnectEnabled": true, + "ReconnectDelayMs": 3000, + "MaxReconnectAttempts": 0, + "PdoConfigRetryCount": 3, + "PdoConfigRetryTimeoutMs": 1000, + "UseHeartbeatCheck": true + } + }, "MDX_Servo03": { - "DeviceId": "lift-motor", + "DeviceId": "lift-motor", "Enabled": false, "DeviceType": "CiA402Servo", "DeviceName": "Lift Motor", @@ -469,7 +517,7 @@ }, "Olei-front": { "DeviceId": "scan_1", - "Enabled": true, + "Enabled": false, "DeviceName": "Olei LR-1BS2", "DeviceType": "Lidar", "DriverName": "Olei2dLidarDriver", @@ -494,7 +542,7 @@ }, "Olei-rear": { "DeviceId": "scan_2", - "Enabled": true, + "Enabled": false, "DeviceName": "Olei LR-1BS5", "DeviceType": "Lidar", "DriverName": "OleiLidarDriver", @@ -618,7 +666,7 @@ }, "WheeltecIMU": { "DeviceId": "imu", - "Enabled": true, + "Enabled": false, "DeviceName": "Wheetec N100 IMU", "DeviceType": "IMU", "DriverName": "WheeltecN100IMU", @@ -657,7 +705,7 @@ }, "PLC": { "DeviceId": "plc-001", - "Enabled": true, + "Enabled": false, "DeviceName": "PLC_Controller", "DeviceType": "ModbusTcp", "DriverName": "ModbusTCP", @@ -771,7 +819,7 @@ } }, "WheelDiameter": 0.195, - "PulsesPerRevolution": 100000, + "PulsesPerRevolution": 4096, "IsReversed": false }, "RightWheel": { @@ -790,7 +838,7 @@ } }, "WheelDiameter": 0.195, - "PulsesPerRevolution": 100000, + "PulsesPerRevolution": 4096, "IsReversed": true } }, @@ -815,12 +863,12 @@ "LeftWheel": { "DeviceId": "left-wheel", "WheelDiameter": 0.182, - "PulsesPerRevolution": 100000 + "PulsesPerRevolution": 4096 }, "RightWheel": { "DeviceId": "right-wheel", "WheelDiameter": 0.182, - "PulsesPerRevolution": 100000 + "PulsesPerRevolution": 4096 }, "Wheelbase": 0.452, "FrameId": "odom",