Initial commit
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Hik;
|
||||
|
||||
/// <summary>
|
||||
/// Driver Hik cho Camera QR
|
||||
/// </summary>
|
||||
[Device(DeviceType.CameraQr, "Hik", "HikVisionQr", "1.0.0", Description = "Camera QR Code Detection Hik Driver")]
|
||||
public class HikVisionQr : DeviceBase, ICameraQr
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly Lock _dataLock = new();
|
||||
private Thread? _receiveThread;
|
||||
private CancellationTokenSource? _receiveCts;
|
||||
private UdpClient? _udpClient;
|
||||
private IPEndPoint? _localEndPoint;
|
||||
private IPEndPoint? _remoteEndPoint;
|
||||
|
||||
// Camera parameters
|
||||
private readonly int _cameraWidth;
|
||||
private readonly int _cameraHeight;
|
||||
private readonly double _distanceToQr;
|
||||
private readonly double _fovRangeWidthMm;
|
||||
private readonly double _fovRangeHeightMm;
|
||||
private readonly double _fovRangeDistanceMm;
|
||||
private readonly double _focalLengthPixels;
|
||||
private readonly string _localIp;
|
||||
private readonly int _localPort;
|
||||
private readonly double _qrDataTimeoutSeconds;
|
||||
|
||||
// QR data dictionary (PoseStamped already contains timestamp in Header.Stamp)
|
||||
private readonly Dictionary<string, PoseStamped> _qrDictionary = [];
|
||||
|
||||
// Data rate tracking
|
||||
private int _packetCount = 0;
|
||||
private double _currentDataRate = 0.0;
|
||||
|
||||
public Dictionary<string, PoseStamped> Codes
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
var validCodes = new Dictionary<string, PoseStamped>();
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredKeys = new List<string>();
|
||||
|
||||
foreach (var (code, poseStamped) in _qrDictionary)
|
||||
{
|
||||
var elapsed = (now - poseStamped.Header.Stamp).TotalSeconds;
|
||||
if (elapsed <= _qrDataTimeoutSeconds)
|
||||
{
|
||||
// Data is still valid
|
||||
validCodes[code] = poseStamped;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Data expired, mark for removal
|
||||
expiredKeys.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired entries
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_qrDictionary.Remove(key);
|
||||
}
|
||||
|
||||
return validCodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HikVisionQr(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider)
|
||||
: base(deviceId, deviceName, DeviceType.CameraQr)
|
||||
{
|
||||
|
||||
_logger = serviceProvider.GetRequiredService<ILoggerFactory>().CreateLogger<HikVisionQr>();
|
||||
// Read configuration
|
||||
var autoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled");
|
||||
var reconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs");
|
||||
var maxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts");
|
||||
|
||||
// Camera parameters
|
||||
_cameraWidth = connection.GetValue<int?>("CameraWidth") ?? 1920;
|
||||
_cameraHeight = connection.GetValue<int?>("CameraHeight") ?? 1080;
|
||||
_distanceToQr = connection.GetValue<double?>("DistanceToQr") ?? 0.1;
|
||||
_fovRangeWidthMm = connection.GetValue<double?>("FovRangeWidthMm") ?? 170.0;
|
||||
_fovRangeHeightMm = connection.GetValue<double?>("FovRangeHeightMm") ?? 130.0;
|
||||
_fovRangeDistanceMm = connection.GetValue<double?>("FovRangeDistanceMm") ?? 100.0;
|
||||
_qrDataTimeoutSeconds = connection.GetValue<double?>("QrDataTimeoutSeconds") ?? 3.0;
|
||||
|
||||
// Compute focal length in pixels from FOV range (pinhole camera model)
|
||||
// f = CameraWidth * FovRangeDistance / FovRangeWidth
|
||||
_focalLengthPixels = _cameraWidth * _fovRangeDistanceMm / _fovRangeWidthMm;
|
||||
|
||||
// UDP parameters
|
||||
_localIp = connection.GetValue<string>("LocalIP") ?? "192.168.254.100";
|
||||
_localPort = connection.GetValue<int?>("LocalPort") ?? 1024;
|
||||
|
||||
if (autoReconnectEnabled.HasValue)
|
||||
AutoReconnectEnabled = autoReconnectEnabled.Value;
|
||||
|
||||
if (reconnectDelayMs.HasValue)
|
||||
ReconnectDelayMs = reconnectDelayMs.Value;
|
||||
|
||||
if (maxReconnectAttempts.HasValue)
|
||||
MaxReconnectAttempts = maxReconnectAttempts.Value;
|
||||
|
||||
// Initialize properties
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
yield return new PropertyDescription("DataRate", "Data Rate", "Tần số nhận dữ liệu (packets/s)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 1,
|
||||
Category = "Trạng thái",
|
||||
DefaultValue = "0"
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await Task.FromResult(_udpClient != null && _receiveThread != null && _receiveThread.IsAlive);
|
||||
}
|
||||
|
||||
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Create local endpoint and UDP client
|
||||
_localEndPoint = new IPEndPoint(IPAddress.Parse(_localIp), _localPort);
|
||||
_udpClient = new UdpClient(_localEndPoint);
|
||||
|
||||
// Create cancellation token source for receive thread
|
||||
_receiveCts = new CancellationTokenSource();
|
||||
|
||||
// Create and start high-priority thread for receiving data
|
||||
_receiveThread = new Thread(() => ReceiveDataLoop(_receiveCts.Token))
|
||||
{
|
||||
IsBackground = true,
|
||||
Priority = ThreadPriority.Highest,
|
||||
Name = $"HikQr-{DeviceId}-ReceiveThread"
|
||||
};
|
||||
_receiveThread.Start();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Stop receive thread
|
||||
_receiveCts?.Cancel();
|
||||
|
||||
// Wait for thread to finish (with timeout)
|
||||
if (_receiveThread != null && _receiveThread.IsAlive)
|
||||
{
|
||||
var joined = _receiveThread.Join(TimeSpan.FromSeconds(5));
|
||||
if (!joined)
|
||||
{
|
||||
// Thread didn't finish gracefully
|
||||
System.Diagnostics.Debug.WriteLine($"[HikQr] Receive thread did not finish in time");
|
||||
}
|
||||
}
|
||||
|
||||
_receiveCts?.Dispose();
|
||||
_receiveCts = null;
|
||||
_receiveThread = null;
|
||||
|
||||
// Close and dispose UDP client
|
||||
_udpClient?.Close();
|
||||
_udpClient?.Dispose();
|
||||
_udpClient = null;
|
||||
_localEndPoint = null;
|
||||
_remoteEndPoint = null;
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
// Clear QR dictionary
|
||||
_qrDictionary.Clear();
|
||||
_packetCount = 0;
|
||||
_currentDataRate = 0.0;
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ReceiveDataLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
Thread.BeginThreadAffinity();
|
||||
try
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var packetCountInSecond = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if 1 second has elapsed
|
||||
if (stopwatch.Elapsed.TotalSeconds >= 1.0)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_currentDataRate = packetCountInSecond / stopwatch.Elapsed.TotalSeconds;
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
// Reset counter and stopwatch
|
||||
packetCountInSecond = 0;
|
||||
stopwatch.Restart();
|
||||
}
|
||||
|
||||
// Check if UDP client is available
|
||||
if (_udpClient == null)
|
||||
break;
|
||||
|
||||
// Set receive timeout to allow checking cancellation token
|
||||
_udpClient.Client.ReceiveTimeout = 100;
|
||||
|
||||
// Receive UDP packet
|
||||
var receivedData = _udpClient.Receive(ref _remoteEndPoint);
|
||||
|
||||
if (receivedData.Length > 0)
|
||||
{
|
||||
packetCountInSecond++;
|
||||
lock (_dataLock)
|
||||
{
|
||||
_packetCount++;
|
||||
}
|
||||
|
||||
// Process received data
|
||||
UpdateData(receivedData);
|
||||
}
|
||||
}
|
||||
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut)
|
||||
{
|
||||
// Timeout is expected, continue loop
|
||||
continue;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error and continue
|
||||
_logger.LogError(ex, "[HikVisionQr] ReceiveDataLoop error");
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.EndThreadAffinity();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateData(byte[] receivedData)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (receivedData.Length == 0)
|
||||
{
|
||||
// No data, clear dictionary
|
||||
_qrDictionary.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
// Extract message (40 bytes from position 37)
|
||||
if (receivedData.Length < 77)
|
||||
{
|
||||
// Invalid packet size
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] messageBytes = new byte[40];
|
||||
Array.Copy(receivedData, 37, messageBytes, 0, 40);
|
||||
string message = Encoding.UTF8.GetString(messageBytes);
|
||||
|
||||
// Extract QR data (9 bytes)
|
||||
byte[] dataBytes = new byte[9];
|
||||
Array.Copy(messageBytes, 0, dataBytes, 0, 9);
|
||||
string qrCode = Encoding.UTF8.GetString(dataBytes);
|
||||
|
||||
// Extract X position (3 bytes from position 11)
|
||||
byte[] xBytes = new byte[3];
|
||||
Array.Copy(messageBytes, 11, xBytes, 0, 3);
|
||||
int qrPositionX = int.Parse(Encoding.ASCII.GetString(xBytes));
|
||||
|
||||
// Extract Y position (3 bytes from position 19)
|
||||
byte[] yBytes = new byte[3];
|
||||
Array.Copy(messageBytes, 19, yBytes, 0, 3);
|
||||
int qrPositionY = int.Parse(Encoding.ASCII.GetString(yBytes));
|
||||
|
||||
// Extract angle (between ')' and '@')
|
||||
double qrAngle = 0;
|
||||
int start = message.IndexOf(')') + 1;
|
||||
int end = message.IndexOf('@', start);
|
||||
if (start > 0 && end > start)
|
||||
{
|
||||
string numberAngle = message[start..end];
|
||||
qrAngle = double.Parse(numberAngle);
|
||||
}
|
||||
|
||||
// Calculate pose immediately
|
||||
var pose = CreatePoseFromPixels(
|
||||
qrPositionX,
|
||||
qrPositionY,
|
||||
qrAngle,
|
||||
_cameraWidth,
|
||||
_cameraHeight,
|
||||
_distanceToQr,
|
||||
_focalLengthPixels);
|
||||
|
||||
if (pose.HasValue)
|
||||
{
|
||||
// Console.WriteLine($"[HikQr] Detected QR Code: {qrCode}, X: {pose.Value.Pose.Position.X}, Y: {pose.Value.Pose.Position.Y}, Angle: {pose.Value.Pose.Orientation.ToYawDegrees()} degrees");
|
||||
// Add or update dictionary (timestamp is in pose.Header.Stamp)
|
||||
_qrDictionary[qrCode] = pose.Value;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[HikQr] UpdateData parsing error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateProperties()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
SetProperty("DataRate", _currentDataRate.ToString("F2"));
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_receiveCts?.Cancel();
|
||||
_receiveCts?.Dispose();
|
||||
_udpClient?.Close();
|
||||
_udpClient?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region ICameraQr Implementation
|
||||
|
||||
public PoseStamped? this[string code]
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (!_qrDictionary.TryGetValue(code, out var poseStamped))
|
||||
return null;
|
||||
|
||||
// Check if data is still valid (within timeout)
|
||||
var elapsed = (DateTime.UtcNow - poseStamped.Header.Stamp).TotalSeconds;
|
||||
if (elapsed > _qrDataTimeoutSeconds)
|
||||
{
|
||||
// Data expired, remove from dictionary
|
||||
_qrDictionary.Remove(code);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Data is valid, return pose
|
||||
return poseStamped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PoseStamped? CreatePoseFromPixels(
|
||||
int? px,
|
||||
int? py,
|
||||
double? angleDeg,
|
||||
int cameraWidth,
|
||||
int cameraHeight,
|
||||
double distanceMeters,
|
||||
double focalLengthPixels)
|
||||
{
|
||||
if (!px.HasValue || !py.HasValue || !angleDeg.HasValue)
|
||||
return null;
|
||||
|
||||
// Convert pixel coordinates to meters using pinhole camera model
|
||||
// Same focal length for both axes (square pixels: 4.8μm × 4.8μm)
|
||||
double xMeters = (px.Value - cameraWidth / 2.0) * distanceMeters / focalLengthPixels;
|
||||
double yMeters = -(py.Value - cameraHeight / 2.0) * distanceMeters / focalLengthPixels; // Invert Y axis
|
||||
double zMeters = distanceMeters;
|
||||
|
||||
// Convert angle to quaternion
|
||||
double angleRad = angleDeg.Value * Math.PI / 180.0;
|
||||
var quat = RobotNet10.Shared.Numbers.Quaternion.FromYawRadian(angleRad);
|
||||
|
||||
// Create header
|
||||
var header = new RobotNet10.Shared.Header
|
||||
{
|
||||
Seq = 0,
|
||||
Stamp = DateTime.UtcNow,
|
||||
FrameId = "camera"
|
||||
};
|
||||
|
||||
// Create pose
|
||||
var pose = new Pose
|
||||
{
|
||||
Position = new Point { X = xMeters, Y = yMeters, Z = zMeters },
|
||||
Orientation = new Quaternion(quat.X, quat.Y, quat.Z, quat.W)
|
||||
};
|
||||
|
||||
return new PoseStamped(header, pose);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
BIN
srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/Olei.rar
Normal file
BIN
srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/Olei.rar
Normal file
Binary file not shown.
@@ -0,0 +1,557 @@
|
||||
using System.Diagnostics;
|
||||
using Olei.LidarSensor;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Olei;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Olei 2D LiDAR Driver
|
||||
/// </summary>
|
||||
public class Olei2dLidarDriverConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// UDP port to listen for LiDAR data
|
||||
/// Default: 2368 (typical LiDAR port)
|
||||
/// </summary>
|
||||
public int UdpPort { get; set; } = 2368;
|
||||
|
||||
/// <summary>
|
||||
/// Frame ID for the LiDAR sensor
|
||||
/// Used in ROS-style message headers
|
||||
/// </summary>
|
||||
public string FrameId { get; set; } = "laser";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver for Olei 2D LiDAR Sensor (LR-1F / LR-1BS)
|
||||
/// Implements DeviceBase and ILidar interface
|
||||
/// Protocol: UDP/IP v2.1
|
||||
/// </summary>
|
||||
[Device(DeviceType.Lidar, "Olei", "Olei2dLidarDriver", "1.0.0",
|
||||
Description = "2D LiDAR Sensor Communication Data Protocol v2.1")]
|
||||
public class Olei2dLidarDriver : DeviceBase, ILidar
|
||||
{
|
||||
private readonly Olei2dLidarDriverConfig _config = new();
|
||||
private OleiLidarServer? _lidarServer;
|
||||
|
||||
// Cached measurements
|
||||
private LaserScan? _currentMeasurementData;
|
||||
private DateTime? _lastScanDataTimestamp;
|
||||
|
||||
// Frequency update tracking (for UI properties)
|
||||
private readonly Stopwatch _frequencyUpdateStopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Scan frequency calculation (actual LaserScan generation rate)
|
||||
private readonly Stopwatch _scanFrequencyStopwatch = Stopwatch.StartNew();
|
||||
private long _scansGeneratedInCurrentSecond = 0;
|
||||
private readonly Lock _scanFrequencyLock = new();
|
||||
|
||||
// Calculated LiDAR specifications from actual data
|
||||
private const double _minAngleRad = 0.0;
|
||||
private const double _maxAngleRad = 2.0 * Math.PI;
|
||||
private double _angularResolutionRad = 0.0; // Calculated from actual packet data
|
||||
private const double DEFAULT_ACCURACY_M = 0.02; // 2 cm accuracy (fixed by hardware)
|
||||
|
||||
// Hardware range specifications (no filtering applied)
|
||||
private const double HARDWARE_MIN_RANGE_M = 0.0;
|
||||
private const double HARDWARE_MAX_RANGE_M = 30.0; // 30m max range for Olei LiDAR
|
||||
|
||||
// Packet accumulation for full scan (0° ~ 360°)
|
||||
private readonly List<LidarDataBlock> _accumulatedBlocks = [];
|
||||
private double _lastPacketStartAngle = -1.0;
|
||||
private readonly Lock _accumulationLock = new();
|
||||
private uint _scanSequenceNumber = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor with configuration
|
||||
/// </summary>
|
||||
public Olei2dLidarDriver(
|
||||
string deviceId,
|
||||
string deviceName, IConfigurationSection configuration)
|
||||
: base(deviceId, deviceName, DeviceType.Lidar)
|
||||
{
|
||||
configuration.Bind(_config);
|
||||
Description = "Olei 2D LiDAR Sensor (LR-1F/LR-1BS) - UDP Protocol v2.1";
|
||||
}
|
||||
|
||||
#region ILidar Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Current measurement data (scan points)
|
||||
/// </summary>
|
||||
public LaserScan? CurrentMeasurementData => _currentMeasurementData;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the most recent scan data
|
||||
/// </summary>
|
||||
public DateTime? LastScanDataTimestamp => _lastScanDataTimestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum scan angle (radians)
|
||||
/// Calculated from actual LiDAR data
|
||||
/// </summary>
|
||||
public double MinAngleRad => _minAngleRad;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum scan angle (radians)
|
||||
/// Calculated from actual LiDAR data
|
||||
/// </summary>
|
||||
public double MaxAngleRad => _maxAngleRad;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum measurement range (meters)
|
||||
/// Hardware specification (no filtering)
|
||||
/// </summary>
|
||||
public double MinRangeM => HARDWARE_MIN_RANGE_M;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum measurement range (meters)
|
||||
/// Hardware specification (no filtering)
|
||||
/// </summary>
|
||||
public double MaxRangeM => HARDWARE_MAX_RANGE_M;
|
||||
|
||||
/// <summary>
|
||||
/// Angular resolution (radians)
|
||||
/// Calculated from actual LiDAR data
|
||||
/// </summary>
|
||||
public double? AngularResolutionRad => _angularResolutionRad;
|
||||
|
||||
/// <summary>
|
||||
/// Scan frequency (Hz)
|
||||
/// Typically 10-20 Hz for Olei LiDAR
|
||||
/// </summary>
|
||||
public double? ScanFrequencyHz { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Field of View (radians)
|
||||
/// </summary>
|
||||
public double FieldOfViewRad => MaxAngleRad - MinAngleRad;
|
||||
|
||||
/// <summary>
|
||||
/// Supports intensity measurements
|
||||
/// </summary>
|
||||
public bool SupportsIntensity => true;
|
||||
|
||||
/// <summary>
|
||||
/// Measurement accuracy (meters)
|
||||
/// </summary>
|
||||
public double? AccuracyM => DEFAULT_ACCURACY_M;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when new scan data is received
|
||||
/// </summary>
|
||||
public event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeviceBase Implementation
|
||||
|
||||
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
// Initialize LiDAR server
|
||||
_lidarServer = new OleiLidarServer(_config.UdpPort);
|
||||
|
||||
// Subscribe to events
|
||||
_lidarServer.DataReceived += OnLidarDataReceived;
|
||||
_lidarServer.ErrorOccurred += OnLidarErrorOccurred;
|
||||
|
||||
SetProperty("UdpPort", _config.UdpPort.ToString());
|
||||
SetProperty("FrameId", _config.FrameId);
|
||||
SetProperty("MinRange", $"{HARDWARE_MIN_RANGE_M:F2} m");
|
||||
SetProperty("MaxRange", $"{HARDWARE_MAX_RANGE_M:F2} m");
|
||||
SetProperty("FOV", "N/A"); // Will be calculated from actual data
|
||||
SetProperty("AngularResolution", "N/A"); // Will be calculated from actual data
|
||||
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_lidarServer == null)
|
||||
throw new InvalidOperationException("LiDAR server not initialized. Call InitializeAsync first.");
|
||||
|
||||
// Start LiDAR server
|
||||
_lidarServer.Start();
|
||||
|
||||
SetProperty("ServerStatus", "Running");
|
||||
SetProperty("Statistics", _lidarServer.GetStatistics());
|
||||
}
|
||||
|
||||
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_lidarServer?.Stop();
|
||||
|
||||
SetProperty("ServerStatus", "Stopped");
|
||||
SetProperty("Statistics", _lidarServer?.GetStatistics() ?? "N/A");
|
||||
}
|
||||
|
||||
protected override async Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Reset statistics
|
||||
_lidarServer?.ResetStatistics();
|
||||
|
||||
// Reset scan frequency tracking
|
||||
lock (_scanFrequencyLock)
|
||||
{
|
||||
Interlocked.Exchange(ref _scansGeneratedInCurrentSecond, 0);
|
||||
_scanFrequencyStopwatch.Restart();
|
||||
ScanFrequencyHz = null;
|
||||
}
|
||||
|
||||
SetProperty("Statistics", _lidarServer?.GetStatistics() ?? "N/A");
|
||||
SetProperty("ScanFrequency", "N/A");
|
||||
}
|
||||
|
||||
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if server is running and receiving data
|
||||
await Task.Delay(500, cancellationToken);
|
||||
if (_lidarServer == null || !_lidarServer.IsRunning)
|
||||
return false;
|
||||
|
||||
// Check if we received data recently (within last 5 seconds)
|
||||
if (_lastScanDataTimestamp.HasValue)
|
||||
{
|
||||
/*var timeSinceLastScan = DateTime.UtcNow - _lastScanDataTimestamp.Value;
|
||||
Console.WriteLine($"timeSinceLastScan.TotalSeconds = {timeSinceLastScan.TotalSeconds}");
|
||||
return timeSinceLastScan.TotalSeconds < 5.0;*/
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override List<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PropertyDescription("UdpPort", "UDP Port", "UDP port for receiving LiDAR data"),
|
||||
new PropertyDescription("FrameId", "Frame ID", "ROS-style frame identifier"),
|
||||
new PropertyDescription("MinRange", "Min Range", "Minimum measurement range"),
|
||||
new PropertyDescription("MaxRange", "Max Range", "Maximum measurement range"),
|
||||
new PropertyDescription("FOV", "Field of View", "Total scanning angle"),
|
||||
new PropertyDescription("AngularResolution", "Angular Resolution", "Angle between scan points"),
|
||||
new PropertyDescription("ServerStatus", "Server Status", "UDP server status"),
|
||||
new PropertyDescription("Statistics", "Statistics", "Server statistics"),
|
||||
new PropertyDescription("LastScanTime", "Last Scan Time", "Timestamp of last scan"),
|
||||
new PropertyDescription("ScanFrequency", "Scan Frequency", "Actual scan rate (Hz)"),
|
||||
];
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _lidarServer != null)
|
||||
{
|
||||
_lidarServer.DataReceived -= OnLidarDataReceived;
|
||||
_lidarServer.ErrorOccurred -= OnLidarErrorOccurred;
|
||||
_lidarServer.Dispose();
|
||||
_lidarServer = null;
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Handle data received from LiDAR server
|
||||
/// Accumulates packets until full 360° scan is complete
|
||||
/// </summary>
|
||||
private void OnLidarDataReceived(object? sender, LidarDataPacket e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var packet = e;
|
||||
|
||||
// Validate packet
|
||||
if (!packet.Header.IsValidFrame)
|
||||
return;
|
||||
|
||||
// Get first valid block's angle to detect scan wrap
|
||||
double packetStartAngle = -1.0;
|
||||
for (int i = 0; i < LidarDataPacket.DATA_BLOCK_COUNT; i++)
|
||||
{
|
||||
if (packet.DataBlocks[i].IsValid)
|
||||
{
|
||||
packetStartAngle = packet.DataBlocks[i].GetAngleDegrees();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (packetStartAngle < 0)
|
||||
return; // No valid blocks in packet
|
||||
|
||||
lock (_accumulationLock)
|
||||
{
|
||||
// Detect scan completion: angle wrapped back to near 0° after being > 300°
|
||||
bool isScanComplete = false;
|
||||
if (_lastPacketStartAngle > 300.0 && packetStartAngle < 50.0)
|
||||
{
|
||||
// Scan wrapped around - complete current scan
|
||||
isScanComplete = true;
|
||||
}
|
||||
|
||||
if (isScanComplete && _accumulatedBlocks.Count > 0)
|
||||
{
|
||||
// Create LaserScan from accumulated blocks
|
||||
var laserScan = CreateLaserScanFromAccumulated(packet.Header);
|
||||
|
||||
// Update cached data
|
||||
_currentMeasurementData = laserScan;
|
||||
_lastScanDataTimestamp = laserScan.Header.Stamp;
|
||||
|
||||
// Increment scan count and update frequency
|
||||
Interlocked.Increment(ref _scansGeneratedInCurrentSecond);
|
||||
UpdateScanFrequency();
|
||||
|
||||
// Update properties (once per second)
|
||||
if (_frequencyUpdateStopwatch.ElapsedMilliseconds > 1000)
|
||||
{
|
||||
SetProperty("LastScanTime", _lastScanDataTimestamp.Value.ToString("HH:mm:ss.fff"));
|
||||
SetProperty("ScanFrequency", ScanFrequencyHz.HasValue ? $"{ScanFrequencyHz.Value:F2} Hz" : "N/A");
|
||||
SetProperty("FOV", $"{FieldOfViewRad * 180 / Math.PI:F1}°");
|
||||
SetProperty("AngularResolution", $"{_angularResolutionRad * 180 / Math.PI:F3}°");
|
||||
_frequencyUpdateStopwatch.Restart();
|
||||
}
|
||||
|
||||
// Raise event
|
||||
ScanDataReceived?.Invoke(this, new LidarScanDataEventArgs(
|
||||
laserScan.Header.Stamp,
|
||||
laserScan
|
||||
));
|
||||
|
||||
// Clear accumulated blocks for next scan
|
||||
_accumulatedBlocks.Clear();
|
||||
}
|
||||
|
||||
// Add current packet's blocks to accumulation buffer
|
||||
byte distanceScale = packet.Header.DistanceScale;
|
||||
for (int i = 0; i < LidarDataPacket.DATA_BLOCK_COUNT; i++)
|
||||
{
|
||||
var block = packet.DataBlocks[i];
|
||||
if (block.IsValid)
|
||||
{
|
||||
// Normalize angle to 0-360° range
|
||||
double angleDeg = block.GetAngleDegrees();
|
||||
if (angleDeg >= 360.0)
|
||||
{
|
||||
angleDeg %= 360.0;
|
||||
}
|
||||
|
||||
// Store block (will be sorted later)
|
||||
_accumulatedBlocks.Add(block);
|
||||
}
|
||||
}
|
||||
|
||||
_lastPacketStartAngle = packetStartAngle;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnErrorOccurred(new Exception($"Error processing LiDAR data: {ex.Message}", ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle errors from LiDAR server
|
||||
/// </summary>
|
||||
private void OnLidarErrorOccurred(object? sender, LidarErrorEventArgs e)
|
||||
{
|
||||
OnErrorOccurred(e.Exception ?? new Exception(e.Message));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Update scan frequency based on LaserScan generation rate
|
||||
/// Calculates how many scans are generated per second
|
||||
/// </summary>
|
||||
private void UpdateScanFrequency()
|
||||
{
|
||||
// Check if 1 second has elapsed
|
||||
if (_scanFrequencyStopwatch.ElapsedMilliseconds >= 1000)
|
||||
{
|
||||
lock (_scanFrequencyLock)
|
||||
{
|
||||
// Double-check inside lock to prevent race condition
|
||||
if (_scanFrequencyStopwatch.ElapsedMilliseconds >= 1000)
|
||||
{
|
||||
// Calculate frequency (scans per second)
|
||||
long scanCount = Interlocked.Read(ref _scansGeneratedInCurrentSecond);
|
||||
double elapsedSeconds = _scanFrequencyStopwatch.ElapsedMilliseconds / 1000.0;
|
||||
ScanFrequencyHz = scanCount / elapsedSeconds;
|
||||
|
||||
// Reset for next measurement period
|
||||
Interlocked.Exchange(ref _scansGeneratedInCurrentSecond, 0);
|
||||
_scanFrequencyStopwatch.Restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create LaserScan from accumulated blocks (full 360° scan)
|
||||
/// </summary>
|
||||
private LaserScan CreateLaserScanFromAccumulated(LidarHeader lastHeader)
|
||||
{
|
||||
var header = new Header(
|
||||
seq: _scanSequenceNumber++,
|
||||
stamp: DateTime.UtcNow,
|
||||
frameId: _config.FrameId
|
||||
);
|
||||
|
||||
// Get distance scale from last header
|
||||
byte distanceScale = lastHeader.DistanceScale;
|
||||
double distanceScaleToMeters = distanceScale / 1000.0;
|
||||
|
||||
// Calculate actual angle and range from accumulated data
|
||||
double minAngleDeg = double.MaxValue;
|
||||
double maxAngleDeg = double.MinValue;
|
||||
double minDistanceM = double.MaxValue;
|
||||
double maxDistanceM = double.MinValue;
|
||||
|
||||
// Create sorted list of angles to calculate angular resolution
|
||||
var sortedAngles = new List<double>(_accumulatedBlocks.Count);
|
||||
|
||||
// First pass: Find actual min/max values from data and collect angles
|
||||
foreach (var block in _accumulatedBlocks)
|
||||
{
|
||||
double angleDeg = block.GetAngleDegrees();
|
||||
|
||||
// Normalize angle to 0-360° range
|
||||
if (angleDeg >= 360.0)
|
||||
angleDeg %= 360.0;
|
||||
|
||||
minAngleDeg = Math.Min(minAngleDeg, angleDeg);
|
||||
maxAngleDeg = Math.Max(maxAngleDeg, angleDeg);
|
||||
sortedAngles.Add(angleDeg);
|
||||
|
||||
double distanceM = block.DistanceRaw * distanceScaleToMeters;
|
||||
if (distanceM > 0) // Only consider valid distances
|
||||
{
|
||||
minDistanceM = Math.Min(minDistanceM, distanceM);
|
||||
maxDistanceM = Math.Max(maxDistanceM, distanceM);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to defaults if no valid data
|
||||
if (minAngleDeg == double.MaxValue || maxAngleDeg == double.MinValue)
|
||||
{
|
||||
minAngleDeg = 0.0;
|
||||
maxAngleDeg = 360.0;
|
||||
}
|
||||
|
||||
if (minDistanceM == double.MaxValue || maxDistanceM == double.MinValue)
|
||||
{
|
||||
minDistanceM = HARDWARE_MIN_RANGE_M;
|
||||
maxDistanceM = HARDWARE_MAX_RANGE_M;
|
||||
}
|
||||
|
||||
// Calculate angular resolution from actual data
|
||||
double angularResolutionDeg = 0.225; // Default fallback
|
||||
if (sortedAngles.Count > 1)
|
||||
{
|
||||
sortedAngles.Sort();
|
||||
|
||||
// Find minimum difference between consecutive angles
|
||||
double minAngleDiff = double.MaxValue;
|
||||
for (int i = 1; i < sortedAngles.Count; i++)
|
||||
{
|
||||
double diff = sortedAngles[i] - sortedAngles[i - 1];
|
||||
if (diff > 0.01) // Ignore very small differences (noise/duplicates)
|
||||
{
|
||||
minAngleDiff = Math.Min(minAngleDiff, diff);
|
||||
}
|
||||
}
|
||||
|
||||
if (minAngleDiff < double.MaxValue)
|
||||
{
|
||||
angularResolutionDeg = minAngleDiff;
|
||||
}
|
||||
}
|
||||
|
||||
// Update cached angular resolution for property reporting
|
||||
_angularResolutionRad = angularResolutionDeg * Math.PI / 180.0;
|
||||
|
||||
// Convert angles to radians
|
||||
double angleMinRad = minAngleDeg * Math.PI / 180.0;
|
||||
double angleMaxRad = maxAngleDeg * Math.PI / 180.0;
|
||||
|
||||
// Calculate expected number of points based on calculated angular resolution
|
||||
double angleSpanDeg = maxAngleDeg - minAngleDeg;
|
||||
int expectedPoints = (int)Math.Ceiling(angleSpanDeg / angularResolutionDeg) + 1;
|
||||
|
||||
// Initialize arrays with expected size
|
||||
double[] ranges = new double[expectedPoints];
|
||||
double[] intensities = new double[expectedPoints];
|
||||
|
||||
// Initialize all to -1 (no data, JSON-safe)
|
||||
Array.Fill(ranges, -1.0);
|
||||
Array.Fill(intensities, 0.0);
|
||||
|
||||
// Second pass: Map accumulated blocks to array indices based on angle
|
||||
int validCount = 0;
|
||||
foreach (var block in _accumulatedBlocks)
|
||||
{
|
||||
// Reflect angle across Y-axis (vertical axis)
|
||||
double angleDeg = 180 - block.GetAngleDegrees();
|
||||
|
||||
// Normalize angle to 0-360° range
|
||||
if (angleDeg < 0)
|
||||
angleDeg += 360.0;
|
||||
else if (angleDeg >= 360.0)
|
||||
angleDeg %= 360.0;
|
||||
|
||||
// Calculate array index from relative angle position using calculated angular resolution
|
||||
double relativeAngle = angleDeg - minAngleDeg;
|
||||
int index = (int)Math.Round(relativeAngle / angularResolutionDeg);
|
||||
|
||||
// Clamp index to valid range
|
||||
if (index >= 0 && index < expectedPoints)
|
||||
{
|
||||
double distanceM = block.DistanceRaw * distanceScaleToMeters;
|
||||
|
||||
// Store all distance values (no range filtering)
|
||||
ranges[index] = distanceM;
|
||||
intensities[index] = block.SignalStrength;
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate angle increment from actual data
|
||||
double angleIncrementRad = expectedPoints > 1
|
||||
? (angleMaxRad - angleMinRad) / (expectedPoints - 1)
|
||||
: angularResolutionDeg * Math.PI / 180.0; // Use calculated resolution
|
||||
|
||||
// Calculate scan timing (estimate based on rotation rate if available)
|
||||
double scanTime = ScanFrequencyHz.HasValue && ScanFrequencyHz.Value > 0
|
||||
? 1.0 / ScanFrequencyHz.Value
|
||||
: 0.1; // Default 10 Hz
|
||||
double timeIncrement = scanTime / expectedPoints;
|
||||
|
||||
return new LaserScan
|
||||
{
|
||||
Header = header,
|
||||
AngleMin = angleMinRad, // From actual data
|
||||
AngleMax = angleMaxRad, // From actual data
|
||||
AngleIncrement = angleIncrementRad, // Calculated from data
|
||||
TimeIncrement = timeIncrement,
|
||||
ScanTime = scanTime,
|
||||
RangeMin = minDistanceM, // From actual data
|
||||
RangeMax = maxDistanceM, // From actual data
|
||||
Ranges = ranges,
|
||||
Intensities = intensities
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Lidar;
|
||||
|
||||
/// <summary>
|
||||
/// UDP connection for Olei GS1-5 (V3 protocol). Packet size 1136 bytes, not 1240.
|
||||
/// </summary>
|
||||
internal sealed class OleiGS15UdpConnection
|
||||
{
|
||||
public const int ExpectedPacketSize = 1136;
|
||||
|
||||
private readonly Queue<Gs15Packet> _packetQueue = new();
|
||||
private readonly object _packetLock = new();
|
||||
|
||||
public string device_IP { get; set; } = "192.168.110.22";
|
||||
public int device_port { get; set; } = 2468;
|
||||
public string local_ip { get; set; } = "192.168.110.114";
|
||||
public UdpClient? udp;
|
||||
public IPEndPoint check_ip_device = new(IPAddress.Any, 0);
|
||||
|
||||
public bool openPort()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IpExists(local_ip))
|
||||
{
|
||||
Console.WriteLine($"[ERROR] OleiGS15: local_ip {local_ip} does NOT exist on any NIC.");
|
||||
return false;
|
||||
}
|
||||
if (IsPortBusy(device_port))
|
||||
{
|
||||
Console.WriteLine($"[ERROR] OleiGS15: Port {device_port} is already in use!");
|
||||
return false;
|
||||
}
|
||||
udp = new UdpClient(new IPEndPoint(IPAddress.Parse(local_ip), device_port));
|
||||
check_ip_device = new IPEndPoint(IPAddress.Parse(local_ip), device_port);
|
||||
udp.Client.ReceiveTimeout = 1000;
|
||||
Console.WriteLine("[INFO] OleiGS15 UDP port opened.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] OleiGS15: Cannot open UDP {local_ip}:{device_port} - {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void readRawdata()
|
||||
{
|
||||
if (udp == null) return;
|
||||
try
|
||||
{
|
||||
byte[] data = udp.Receive(ref check_ip_device);
|
||||
if (check_ip_device.Address == null || check_ip_device.Address.Equals(IPAddress.Any) || check_ip_device.Address.Equals(IPAddress.None) || check_ip_device.Port == 0)
|
||||
return;
|
||||
if (check_ip_device.Address.ToString() != device_IP)
|
||||
return;
|
||||
if (data.Length != ExpectedPacketSize)
|
||||
return;
|
||||
|
||||
var packet = new Gs15Packet { stamp = DateTime.UtcNow, data = data };
|
||||
lock (_packetLock)
|
||||
{
|
||||
_packetQueue.Enqueue(packet);
|
||||
}
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
if (ex.SocketErrorCode != SocketError.TimedOut)
|
||||
Console.WriteLine($"[ERROR] OleiGS15 socket: {ex.Message}");
|
||||
}
|
||||
catch (Exception) { }
|
||||
}
|
||||
|
||||
public Gs15Packet? TryDequeuePacket()
|
||||
{
|
||||
lock (_packetLock)
|
||||
{
|
||||
if (_packetQueue.Count == 0) return null;
|
||||
return _packetQueue.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IpExists(string ip)
|
||||
{
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.ToString() == ip) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPortBusy(int port)
|
||||
{
|
||||
var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners();
|
||||
return listeners.Any(ep => ep.Port == port);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Gs15Packet
|
||||
{
|
||||
public DateTime stamp;
|
||||
public byte[] data = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public sealed class OleiGS15DriverConfig
|
||||
{
|
||||
public int Version { get; set; } = 3;
|
||||
public string ScannerIp { get; set; } = "192.168.110.22";
|
||||
public string LocalIp { get; set; } = "192.168.110.114";
|
||||
public int Port { get; set; } = 2468;
|
||||
public string Transport { get; set; } = "udp";
|
||||
public string FrameId { get; set; } = "olelidar";
|
||||
public string ScanTopic { get; set; } = "scan";
|
||||
public bool Inverted { get; set; } = false;
|
||||
public bool TimeFromLidar { get; set; } = true;
|
||||
public bool Ntp { get; set; } = false;
|
||||
public string PacketType { get; set; } = "B";
|
||||
public double RangeMin { get; set; } = 0.08;
|
||||
public double RangeMax { get; set; } = 50.0;
|
||||
public bool AutoReconnectEnabled { get; set; } = true;
|
||||
public int ReconnectDelayMs { get; set; } = 3000;
|
||||
public int MaxReconnectAttempts { get; set; } = 0;
|
||||
|
||||
public static OleiGS15DriverConfig Parse(IConfigurationSection connection)
|
||||
{
|
||||
var cfg = new OleiGS15DriverConfig
|
||||
{
|
||||
Version = connection.GetValue<int?>("Version")
|
||||
?? connection.GetValue<int?>("version")
|
||||
?? 3,
|
||||
ScannerIp = connection["ScannerIp"]
|
||||
?? connection["scanner_ip"]
|
||||
?? connection["DeviceIp"]
|
||||
?? "192.168.110.22",
|
||||
LocalIp = connection["LocalIp"]
|
||||
?? connection["local_ip"]
|
||||
?? "192.168.110.114",
|
||||
Transport = connection["Transport"]
|
||||
?? connection["transport"]
|
||||
?? "udp",
|
||||
FrameId = connection["FrameId"]
|
||||
?? connection["frame_id"]
|
||||
?? "olelidar",
|
||||
ScanTopic = connection["ScanTopic"]
|
||||
?? connection["scan_topic"]
|
||||
?? "scan",
|
||||
PacketType = connection["PacketType"]
|
||||
?? connection["packet_type"]
|
||||
?? "B",
|
||||
Inverted = connection.GetValue<bool?>("Inverted")
|
||||
?? connection.GetValue<bool?>("inverted")
|
||||
?? false,
|
||||
TimeFromLidar = connection.GetValue<bool?>("TimeFromLidar")
|
||||
?? connection.GetValue<bool?>("timeFromLidar")
|
||||
?? true,
|
||||
Ntp = connection.GetValue<bool?>("Ntp")
|
||||
?? connection.GetValue<bool?>("ntp")
|
||||
?? false,
|
||||
RangeMin = connection.GetValue<double?>("RangeMin")
|
||||
?? connection.GetValue<double?>("range_min")
|
||||
?? 0.08,
|
||||
RangeMax = connection.GetValue<double?>("RangeMax")
|
||||
?? connection.GetValue<double?>("range_max")
|
||||
?? 50.0,
|
||||
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true,
|
||||
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 3000,
|
||||
MaxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts") ?? 0
|
||||
};
|
||||
|
||||
var port = connection.GetValue<int?>("Port")
|
||||
?? connection.GetValue<int?>("port")
|
||||
?? connection.GetValue<int?>("DevicePort")
|
||||
?? 2368;
|
||||
if (port is < 1 or > 65535)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid Port: {port}. Must be between 1 and 65535");
|
||||
}
|
||||
|
||||
cfg.Port = port;
|
||||
|
||||
if (!string.Equals(cfg.Transport, "udp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("OleiGS15Driver currently supports transport=udp only");
|
||||
}
|
||||
|
||||
if (cfg.RangeMin < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"RangeMin must be >= 0. Current value: {cfg.RangeMin}");
|
||||
}
|
||||
|
||||
if (cfg.RangeMax <= cfg.RangeMin)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"RangeMax must be greater than RangeMin. RangeMin={cfg.RangeMin}, RangeMax={cfg.RangeMax}");
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
|
||||
[Device(DeviceType.Lidar, "Olei", "OleiGS15Driver", "1.0.0",
|
||||
Description = "Olei GS1-5 LiDAR driver (ROS version3-compatible UDP parser)")]
|
||||
public class OleiGS15Driver : DeviceBase, ILidar
|
||||
{
|
||||
private const int V3HeaderSize = 48;
|
||||
private const ushort V3Magic = 0xFEAC;
|
||||
|
||||
private readonly OleiGS15DriverConfig _config;
|
||||
private readonly OleiGS15UdpConnection _connection = new();
|
||||
private readonly Lock _dataLock = new();
|
||||
|
||||
private CancellationTokenSource? _updateCts;
|
||||
private Task? _updateTask;
|
||||
|
||||
private LaserScan? _lastLaserScan;
|
||||
private DateTime? _lastScanDataTimestamp;
|
||||
private int _lastPointCount;
|
||||
private double? _lastScanFrequencyHz;
|
||||
private bool _portOpened;
|
||||
|
||||
private V3ScanAccumulator? _scanAccumulator;
|
||||
private ushort? _lastFirstIndex;
|
||||
|
||||
public OleiGS15Driver(string deviceId, string deviceName, IConfigurationSection connection)
|
||||
: base(deviceId, deviceName, DeviceType.Lidar)
|
||||
{
|
||||
_config = OleiGS15DriverConfig.Parse(connection);
|
||||
|
||||
_connection.device_IP = _config.ScannerIp;
|
||||
_connection.local_ip = _config.LocalIp;
|
||||
_connection.device_port = _config.Port;
|
||||
|
||||
AutoReconnectEnabled = _config.AutoReconnectEnabled;
|
||||
ReconnectDelayMs = _config.ReconnectDelayMs;
|
||||
MaxReconnectAttempts = _config.MaxReconnectAttempts;
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
yield return new PropertyDescription("Version", "Version", "Phiên bản protocol")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 1,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "3"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("PacketType", "Packet Type", "Loại packet A/B/C")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 2,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "B"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("ScannerIp", "Scanner IP", "IP của Olei GS1-5")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 3,
|
||||
Category = "Kết nối",
|
||||
DefaultValue = ""
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("LocalIp", "Local IP", "IP local nhận UDP packet")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 4,
|
||||
Category = "Kết nối",
|
||||
DefaultValue = ""
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Port", "Port", "UDP port của lidar")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 5,
|
||||
Category = "Kết nối",
|
||||
DefaultValue = "2368"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("FrameId", "Frame ID", "Frame ID của LaserScan")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 6,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "olelidar"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("RangeMin", "Range Min (m)", "Khoảng cách tối thiểu")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 7,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "0.08"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("RangeMax", "Range Max (m)", "Khoảng cách tối đa")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 8,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "50.0"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("ConnectionStatus", "Connection Status", "Trạng thái kết nối lidar")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 9,
|
||||
Category = "Trạng thái",
|
||||
DefaultValue = "Disconnected"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("PointCount", "Point Count", "Số điểm scan gần nhất")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 10,
|
||||
Category = "Trạng thái",
|
||||
DefaultValue = "0"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("LastScanTime", "Last Scan Time", "Thời gian scan gần nhất")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 11,
|
||||
Category = "Trạng thái",
|
||||
DefaultValue = ""
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_portOpened = _connection.openPort();
|
||||
if (!_portOpened)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to open Olei GS1-5 UDP port at {_config.LocalIp}:{_config.Port}");
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartUpdateLoop();
|
||||
SetProperty("ConnectionStatus", "Connected");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopUpdateLoop();
|
||||
CloseUdpPort();
|
||||
SetProperty("ConnectionStatus", "Disconnected");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_lastLaserScan = null;
|
||||
_lastScanDataTimestamp = null;
|
||||
_lastPointCount = 0;
|
||||
_lastScanFrequencyHz = null;
|
||||
_scanAccumulator = null;
|
||||
_lastFirstIndex = null;
|
||||
}
|
||||
|
||||
UpdateProperties();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (!_portOpened)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_updateTask == null || _updateTask.IsCompleted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_lastScanDataTimestamp == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (DateTime.UtcNow - _lastScanDataTimestamp.Value).TotalSeconds < 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartUpdateLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_updateTask != null && !_updateTask.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updateCts = new CancellationTokenSource();
|
||||
_updateTask = Task.Run(() => UpdateLoopAsync(_updateCts.Token), _updateCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopUpdateLoop()
|
||||
{
|
||||
Task? updateTask;
|
||||
CancellationTokenSource? cts;
|
||||
|
||||
lock (_dataLock)
|
||||
{
|
||||
updateTask = _updateTask;
|
||||
cts = _updateCts;
|
||||
_updateTask = null;
|
||||
_updateCts = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cts?.Cancel();
|
||||
updateTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
cts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_connection.readRawdata();
|
||||
|
||||
var processedAnyPacket = false;
|
||||
while (TryProcessOnePacket())
|
||||
{
|
||||
processedAnyPacket = true;
|
||||
}
|
||||
|
||||
if (!processedAnyPacket)
|
||||
{
|
||||
await Task.Delay(5, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "OleiGS15 update loop error");
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryProcessOnePacket()
|
||||
{
|
||||
var packet = _connection.TryDequeuePacket();
|
||||
if (packet == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseV3Packet(packet.data, out var header, out var rangesMm, out var intensities, out var hasIntensity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var packetTimestamp = _config.TimeFromLidar
|
||||
? (packet.stamp.Kind == DateTimeKind.Utc ? packet.stamp : packet.stamp.ToUniversalTime())
|
||||
: DateTime.UtcNow;
|
||||
|
||||
V3ScanAccumulator? completedScan = null;
|
||||
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_lastFirstIndex.HasValue && _lastFirstIndex.Value > header.FirstIndex)
|
||||
{
|
||||
_scanAccumulator = null;
|
||||
}
|
||||
_lastFirstIndex = header.FirstIndex;
|
||||
|
||||
if (_scanAccumulator == null)
|
||||
{
|
||||
_scanAccumulator = new V3ScanAccumulator(header, _config);
|
||||
}
|
||||
|
||||
_scanAccumulator.AddPacketData(rangesMm, intensities, hasIntensity, _config);
|
||||
|
||||
if (_scanAccumulator.IsCompleted)
|
||||
{
|
||||
completedScan = _scanAccumulator;
|
||||
_scanAccumulator = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (completedScan != null)
|
||||
{
|
||||
PublishCompletedScan(completedScan, packetTimestamp);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void PublishCompletedScan(V3ScanAccumulator completed, DateTime packetTimestamp)
|
||||
{
|
||||
var scanTime = completed.ScanTimeSeconds > 0 ? completed.ScanTimeSeconds : 0.1f;
|
||||
var scanStamp = packetTimestamp - TimeSpan.FromSeconds(scanTime);
|
||||
|
||||
var laserScan = new LaserScan
|
||||
{
|
||||
Header = new RobotNet10.Shared.Header
|
||||
{
|
||||
Seq = 0,
|
||||
Stamp = scanStamp,
|
||||
FrameId = _config.FrameId
|
||||
},
|
||||
AngleMin = completed.AngleMin,
|
||||
AngleMax = completed.AngleMax,
|
||||
AngleIncrement = completed.AngleIncrement,
|
||||
TimeIncrement = completed.TimeIncrement,
|
||||
ScanTime = scanTime,
|
||||
RangeMin = _config.RangeMin,
|
||||
RangeMax = _config.RangeMax,
|
||||
Ranges = Array.ConvertAll(completed.Ranges, x => (double)x),
|
||||
Intensities = Array.ConvertAll(completed.Intensities, x => (double)x)
|
||||
};
|
||||
|
||||
lock (_dataLock)
|
||||
{
|
||||
_lastLaserScan = laserScan;
|
||||
_lastScanDataTimestamp = scanStamp;
|
||||
_lastPointCount = completed.Ranges.Length;
|
||||
_lastScanFrequencyHz = completed.ScanFrequencyHz > 0 ? completed.ScanFrequencyHz : null;
|
||||
}
|
||||
|
||||
ScanDataReceived?.Invoke(this, new LidarScanDataEventArgs(scanStamp, laserScan));
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
private static bool TryParseV3Packet(
|
||||
byte[] data,
|
||||
out V3HeaderLite header,
|
||||
out ushort[] rangesMm,
|
||||
out ushort[] intensities,
|
||||
out bool hasIntensity)
|
||||
{
|
||||
header = default;
|
||||
rangesMm = Array.Empty<ushort>();
|
||||
intensities = Array.Empty<ushort>();
|
||||
hasIntensity = false;
|
||||
|
||||
if (data.Length < V3HeaderSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var magic = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2));
|
||||
if (magic != V3Magic)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
header = new V3HeaderLite
|
||||
{
|
||||
Magic = magic,
|
||||
Version = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(2, 2)),
|
||||
PacketSize = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(4, 4)),
|
||||
HeaderSize = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(8, 2)),
|
||||
DistanceRatio = data[10],
|
||||
Types = data[11],
|
||||
ScanNumber = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(12, 2)),
|
||||
PacketNumber = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(14, 2)),
|
||||
TimestampDecimal = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(16, 4)),
|
||||
TimestampInteger = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(20, 4)),
|
||||
ScanFrequencyRaw = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(24, 2)),
|
||||
NumPointsScan = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(26, 2)),
|
||||
InputStatus = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(28, 2)),
|
||||
OutputStatus = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(30, 2)),
|
||||
FieldStatus = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(32, 4)),
|
||||
StartIndex = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(36, 2)),
|
||||
EndIndex = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(38, 2)),
|
||||
FirstIndex = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(40, 2)),
|
||||
NumPointsPacket = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(42, 2)),
|
||||
StatusFlags = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(44, 4))
|
||||
};
|
||||
|
||||
var packetSize = header.PacketSize == 0 ? data.Length : (int)Math.Min(header.PacketSize, data.Length);
|
||||
var headerSize = header.HeaderSize == 0 ? V3HeaderSize : header.HeaderSize;
|
||||
if (headerSize < V3HeaderSize || headerSize > packetSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytesPerPoint = header.Types switch
|
||||
{
|
||||
0x00 => 2,
|
||||
0x01 => 4,
|
||||
0x10 => 4,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
if (bytesPerPoint == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var payloadBytes = packetSize - headerSize;
|
||||
var pointCount = header.NumPointsPacket;
|
||||
if (pointCount == 0 || pointCount * bytesPerPoint > payloadBytes)
|
||||
{
|
||||
pointCount = (ushort)(payloadBytes / bytesPerPoint);
|
||||
}
|
||||
|
||||
if (pointCount == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
rangesMm = new ushort[pointCount];
|
||||
|
||||
if (header.Types == 0x01)
|
||||
{
|
||||
hasIntensity = true;
|
||||
intensities = new ushort[pointCount];
|
||||
}
|
||||
|
||||
var offset = headerSize;
|
||||
for (int i = 0; i < pointCount; i++)
|
||||
{
|
||||
switch (header.Types)
|
||||
{
|
||||
case 0x00:
|
||||
rangesMm[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset, 2));
|
||||
offset += 2;
|
||||
break;
|
||||
case 0x01:
|
||||
rangesMm[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset, 2));
|
||||
intensities[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset + 2, 2));
|
||||
offset += 4;
|
||||
break;
|
||||
case 0x10:
|
||||
rangesMm[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset + 2, 2));
|
||||
offset += 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateProperties()
|
||||
{
|
||||
DateTime? timestamp;
|
||||
int pointCount;
|
||||
|
||||
lock (_dataLock)
|
||||
{
|
||||
timestamp = _lastScanDataTimestamp;
|
||||
pointCount = _lastPointCount;
|
||||
}
|
||||
|
||||
SetProperty("Version", _config.Version.ToString());
|
||||
SetProperty("PacketType", _config.PacketType);
|
||||
SetProperty("ScannerIp", _config.ScannerIp);
|
||||
SetProperty("LocalIp", _config.LocalIp);
|
||||
SetProperty("Port", _config.Port.ToString());
|
||||
SetProperty("FrameId", _config.FrameId);
|
||||
SetProperty("RangeMin", _config.RangeMin.ToString("F3"));
|
||||
SetProperty("RangeMax", _config.RangeMax.ToString("F3"));
|
||||
SetProperty("PointCount", pointCount.ToString());
|
||||
SetProperty("LastScanTime", timestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "");
|
||||
}
|
||||
|
||||
private void CloseUdpPort()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_portOpened = false;
|
||||
|
||||
try
|
||||
{
|
||||
_connection.udp?.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_connection.udp?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LaserScan? CurrentMeasurementData
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
return _lastLaserScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime? LastScanDataTimestamp
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
return _lastScanDataTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double MinAngleRad
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
return _lastLaserScan?.AngleMin ?? -Math.PI;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double MaxAngleRad
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
return _lastLaserScan?.AngleMax ?? Math.PI;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double MinRangeM => _config.RangeMin;
|
||||
public double MaxRangeM => _config.RangeMax;
|
||||
|
||||
public double? AngularResolutionRad
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
var scan = _lastLaserScan;
|
||||
if (scan?.Ranges == null || scan.Value.Ranges.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return scan.Value.AngleIncrement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double? ScanFrequencyHz
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
return _lastScanFrequencyHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double FieldOfViewRad => Math.Abs(MaxAngleRad - MinAngleRad);
|
||||
public bool SupportsIntensity => true;
|
||||
public double? AccuracyM => null;
|
||||
|
||||
public event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
StopUpdateLoop();
|
||||
CloseUdpPort();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private readonly struct V3HeaderLite
|
||||
{
|
||||
public ushort Magic { get; init; }
|
||||
public ushort Version { get; init; }
|
||||
public uint PacketSize { get; init; }
|
||||
public ushort HeaderSize { get; init; }
|
||||
public byte DistanceRatio { get; init; }
|
||||
public byte Types { get; init; }
|
||||
public ushort ScanNumber { get; init; }
|
||||
public ushort PacketNumber { get; init; }
|
||||
public uint TimestampDecimal { get; init; }
|
||||
public uint TimestampInteger { get; init; }
|
||||
public ushort ScanFrequencyRaw { get; init; }
|
||||
public ushort NumPointsScan { get; init; }
|
||||
public ushort InputStatus { get; init; }
|
||||
public ushort OutputStatus { get; init; }
|
||||
public uint FieldStatus { get; init; }
|
||||
public ushort StartIndex { get; init; }
|
||||
public ushort EndIndex { get; init; }
|
||||
public ushort FirstIndex { get; init; }
|
||||
public ushort NumPointsPacket { get; init; }
|
||||
public uint StatusFlags { get; init; }
|
||||
}
|
||||
|
||||
private sealed class V3ScanAccumulator
|
||||
{
|
||||
private int _writeIndex;
|
||||
|
||||
public float[] Ranges { get; }
|
||||
public float[] Intensities { get; }
|
||||
public bool IsCompleted => _writeIndex >= Ranges.Length;
|
||||
public float AngleMin { get; }
|
||||
public float AngleMax { get; }
|
||||
public float AngleIncrement { get; }
|
||||
public float TimeIncrement { get; }
|
||||
public float ScanTimeSeconds { get; }
|
||||
public double ScanFrequencyHz { get; }
|
||||
|
||||
public V3ScanAccumulator(V3HeaderLite header, OleiGS15DriverConfig config)
|
||||
{
|
||||
var actualNum = Math.Max(1, (int)(header.EndIndex - header.StartIndex));
|
||||
var pointsPerScan = Math.Max(1, (int)header.NumPointsScan);
|
||||
|
||||
Ranges = new float[actualNum];
|
||||
for (int i = 0; i < Ranges.Length; i++)
|
||||
{
|
||||
Ranges[i] = 0.0f;
|
||||
}
|
||||
|
||||
Intensities = new float[actualNum];
|
||||
|
||||
AngleIncrement = (float)((360.0 / pointsPerScan) * Math.PI / 180.0);
|
||||
AngleMin = (float)(header.StartIndex * AngleIncrement - Math.PI);
|
||||
AngleMax = (float)(header.EndIndex * AngleIncrement - Math.PI);
|
||||
|
||||
var rpm = header.ScanFrequencyRaw & 0x7FFF;
|
||||
ScanFrequencyHz = rpm > 0 ? rpm / 60.0 : 0.0;
|
||||
ScanTimeSeconds = rpm > 0 ? (float)(60.0 / rpm) : 0.1f;
|
||||
TimeIncrement = ScanTimeSeconds / pointsPerScan;
|
||||
}
|
||||
|
||||
public void AddPacketData(ushort[] rangesMm, ushort[] intensities, bool hasIntensity, OleiGS15DriverConfig config)
|
||||
{
|
||||
for (int i = 0; i < rangesMm.Length && _writeIndex < Ranges.Length; i++)
|
||||
{
|
||||
var rangeM = rangesMm[i] / 1000.0f;
|
||||
if (rangeM < config.RangeMin || rangeM > config.RangeMax || rangeM <= 0)
|
||||
{
|
||||
Ranges[_writeIndex] = 0.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Ranges[_writeIndex] = rangeM;
|
||||
}
|
||||
|
||||
if (hasIntensity && i < intensities.Length)
|
||||
{
|
||||
Intensities[_writeIndex] = intensities[i];
|
||||
}
|
||||
else
|
||||
{
|
||||
Intensities[_writeIndex] = 0.0f;
|
||||
}
|
||||
|
||||
_writeIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using Microsoft.AspNetCore.Mvc.Diagnostics;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
//using static RobotNet10.RobotApp.Drivers.Lidar.OleiLidarDecode;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Lidar
|
||||
{
|
||||
[Device(DeviceType.Lidar, "Olei", "OleiLidarDriver", "1.0.0")]
|
||||
public class OleiLidarDriver : DeviceBase, ILidar
|
||||
{
|
||||
private readonly object _scanDataLock = new();
|
||||
|
||||
|
||||
// Cached scan data
|
||||
//private LidarMeasurementData? _cachedMeasurementData = null;
|
||||
|
||||
// Device specifications
|
||||
private readonly double _minRangeM;
|
||||
private readonly double _maxRangeM;
|
||||
private readonly double? _angularResolutionRad;
|
||||
private readonly double? _scanFrequencyHz;
|
||||
private readonly bool _supportsIntensity;
|
||||
private readonly double? _accuracyM;
|
||||
private readonly int _numberOfPoints;
|
||||
private readonly double _startAngleRad;
|
||||
private readonly double _endAngleRad;
|
||||
private DateTime? _lastScanDataTimestamp;
|
||||
private LaserScan? _lastLaserScan;
|
||||
private oleiPackage? _pkt;
|
||||
Connect oleiConnect = new Connect();
|
||||
DecodeLidar oleiDecode = new DecodeLidar();
|
||||
|
||||
DecoderConfig decoderConfig = new DecoderConfig();
|
||||
private readonly Lock _dataLock = new();
|
||||
private CancellationTokenSource? _updateCts;
|
||||
private Task? _updateTask;
|
||||
public float maxAngle;
|
||||
public float minAngle;
|
||||
public UdpClient udp;
|
||||
public IPEndPoint check_ip_device;
|
||||
bool portOpened;
|
||||
|
||||
//Event
|
||||
public event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
|
||||
|
||||
//Cached data
|
||||
//private LidarMeasurementData? _currentMeasurementData;
|
||||
public OleiLidarDriver(string deviceId, string deviceName, IConfigurationSection connection)
|
||||
: base(deviceId, deviceName, DeviceType.Lidar)
|
||||
{
|
||||
// Đọc cấu hình kết nối (IP và Port)
|
||||
string deviceIp = connection.GetValue<string>("DeviceIp") ?? "192.168.254.13";
|
||||
int devicePort = connection.GetValue<int?>("DevicePort") ?? 2368;
|
||||
string localIp = connection.GetValue<string>("LocalIp") ?? "192.168.254.10";
|
||||
|
||||
// Cấu hình Connect object với IP và Port từ config
|
||||
oleiConnect.device_IP = deviceIp;
|
||||
oleiConnect.device_port = devicePort;
|
||||
oleiConnect.local_ip = localIp;
|
||||
// Đọc cấu hình
|
||||
minAngle = connection.GetValue<float?>("MinAngle") ?? -130.0f;
|
||||
maxAngle = connection.GetValue<float?>("MaxAngle") ?? 130.0f;
|
||||
_maxRangeM = connection.GetValue<double?>("MaxRangeM") ?? 50.0;
|
||||
_minRangeM = connection.GetValue<double?>("MinRangeM") ?? 0.1; // Default 20cm
|
||||
_scanFrequencyHz = connection.GetValue<double?>("ScanFrequencyHz") ?? 10.0; // Default 10Hz
|
||||
_supportsIntensity = connection.GetValue<bool?>("SupportsIntensity") ?? true;
|
||||
_accuracyM = connection.GetValue<double?>("AccuracyM") ?? 0.02; // Default 2cm accuracy
|
||||
|
||||
// Set decoder config from JSON
|
||||
decoderConfig.Inverted = connection.GetValue<bool?>("Inverted") ?? false;
|
||||
|
||||
// Apply config to decoder
|
||||
oleiDecode.SetConfig(decoderConfig);
|
||||
|
||||
var autoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled");
|
||||
var reconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs");
|
||||
var maxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts");
|
||||
|
||||
if (autoReconnectEnabled.HasValue)
|
||||
AutoReconnectEnabled = autoReconnectEnabled.Value;
|
||||
else
|
||||
AutoReconnectEnabled = false; // Không cần reconnect cho simulation
|
||||
|
||||
if (reconnectDelayMs.HasValue)
|
||||
ReconnectDelayMs = reconnectDelayMs.Value;
|
||||
|
||||
if (maxReconnectAttempts.HasValue)
|
||||
MaxReconnectAttempts = maxReconnectAttempts.Value;
|
||||
|
||||
// Khởi tạo giá trị properties
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
yield return new PropertyDescription("NumberOfPoints", "Number of Points", "Số điểm scan")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 1,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "2000"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("StartAngle", "Start Angle (deg)", "Góc bắt đầu (độ)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 2,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "-180"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("EndAngle", "End Angle (deg)", "Góc kết thúc (độ)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 3,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "180"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("MaxRange", "Max Range (m)", "Tầm quét tối đa (m)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 4,
|
||||
Category = "Cấu hình",
|
||||
DefaultValue = "20"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("LastScanTime", "Last Scan Time", "Thời gian scan cuối cùng")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 5,
|
||||
Category = "Trạng thái",
|
||||
DefaultValue = ""
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Mở cổng UDP - kiểm tra kết quả và throw exception nếu fail
|
||||
lock (_dataLock)
|
||||
{
|
||||
portOpened = oleiConnect.openPort();
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Bắt đầu vòng lặp cập nhật dữ liệu với tần số 1Hz
|
||||
StartUpdateLoop();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Dừng vòng lặp cập nhật
|
||||
StopUpdateLoop();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Reset về giá trị mặc định
|
||||
lock (_dataLock)
|
||||
{
|
||||
_lastLaserScan = null;
|
||||
_lastScanDataTimestamp = null;
|
||||
_pkt = null;
|
||||
}
|
||||
UpdateProperties();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Simulation luôn connected
|
||||
if (!portOpened)
|
||||
{
|
||||
return await Task.FromResult(false);
|
||||
}
|
||||
return await Task.FromResult(true);
|
||||
}
|
||||
|
||||
private void StartUpdateLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_updateTask != null && !_updateTask.IsCompleted)
|
||||
return;
|
||||
|
||||
_updateCts = new CancellationTokenSource();
|
||||
_updateTask = Task.Run(() => UpdateLoopAsync(_updateCts.Token));
|
||||
}
|
||||
}
|
||||
|
||||
private void StopUpdateLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_updateCts?.Cancel();
|
||||
_updateCts?.Dispose();
|
||||
_updateCts = null;
|
||||
_updateTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
//_connect.openPort();
|
||||
//Console.WriteLine("[INFO] START READ DATA");
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Đọc dữ liệu từ UDP
|
||||
lock (_dataLock)
|
||||
{
|
||||
oleiConnect.readRawdata();
|
||||
|
||||
if (oleiConnect.GetPacketCount() > 0)
|
||||
{
|
||||
GenerateScanData();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "Update loop error");
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void GenerateScanData()
|
||||
{
|
||||
// Dequeue packet an toàn (thread-safe)
|
||||
oleiPackage? pkt = oleiConnect.TryDequeuePacket();
|
||||
|
||||
if (pkt == null)
|
||||
{
|
||||
// Không log để tránh spam khi queue rỗng
|
||||
return;
|
||||
}
|
||||
|
||||
var timestamp = DateTime.UtcNow;
|
||||
|
||||
// Decode packet
|
||||
oleiDecode.PacketCb(pkt);
|
||||
|
||||
// Kiểm tra xem có dữ liệu không
|
||||
if (oleiDecode.scanRadAngleInVec.Count == 0 ||
|
||||
oleiDecode.scanRadAngleInVec.Count != oleiDecode.scanRangeInVec.Count ||
|
||||
oleiDecode.scanRadAngleInVec.Count != oleiDecode.scanIntensityInVec.Count)
|
||||
{
|
||||
// Không có dữ liệu hoặc dữ liệu không đồng bộ
|
||||
return;
|
||||
}
|
||||
|
||||
var numberOfPoints = oleiDecode.scanRadAngleInVec.Count;
|
||||
var ranges = new double[numberOfPoints];
|
||||
var intensities = new double[numberOfPoints];
|
||||
// Convert data to arrays (LaserScan format)
|
||||
for(int i = 0; i < numberOfPoints; i++)
|
||||
{
|
||||
if((oleiDecode.scanAngleInVec[i] >= (minAngle + 180)) && (oleiDecode.scanAngleInVec[i] <= (maxAngle + 180)))
|
||||
{
|
||||
var distanceM = oleiDecode.scanRangeInVec[i] * 0.001f; // mm to meters
|
||||
var isValid = distanceM >= _minRangeM && distanceM <= _maxRangeM;
|
||||
ranges[i] = isValid ? distanceM : 0.0f;
|
||||
intensities[i] = oleiDecode.scanIntensityInVec[i]; // normalize 0-1
|
||||
}
|
||||
}
|
||||
float _minAngle = (float)(minAngle*Math.PI)/180;
|
||||
float _maxAngle = (float)(maxAngle*Math.PI)/180;
|
||||
float angleIncrement = numberOfPoints > 1 ?
|
||||
(_maxAngle - _minAngle) / (numberOfPoints - 1) : 0.0f;
|
||||
float _scanTime = 1.0f / oleiDecode.Frequency;
|
||||
float _TimeIncrement = _scanTime / numberOfPoints;
|
||||
|
||||
var laserScan = new LaserScan
|
||||
{
|
||||
Header = new RobotNet10.Shared.Header
|
||||
{
|
||||
Seq = 0,
|
||||
Stamp = timestamp,
|
||||
FrameId = "olei_lidar_frame"
|
||||
},
|
||||
AngleMin = _minAngle,
|
||||
AngleMax = _maxAngle,
|
||||
AngleIncrement = angleIncrement,
|
||||
TimeIncrement = _TimeIncrement,
|
||||
ScanTime = _scanTime, // Convert Hz to seconds
|
||||
RangeMin = (float)_minRangeM,
|
||||
RangeMax = (float)_maxRangeM,
|
||||
Ranges = ranges,
|
||||
Intensities = intensities
|
||||
};
|
||||
|
||||
// Cache LaserScan và packet
|
||||
lock (_dataLock)
|
||||
{
|
||||
_lastLaserScan = laserScan;
|
||||
_lastScanDataTimestamp = timestamp;
|
||||
_pkt = pkt;
|
||||
}
|
||||
|
||||
// Fire event
|
||||
ScanDataReceived?.Invoke(this, new LidarScanDataEventArgs(timestamp: timestamp, measurementData: laserScan));
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
private void UpdateProperties()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
SetProperty("NumberOfPoints", _numberOfPoints.ToString());
|
||||
SetProperty("StartAngle", (_startAngleRad * 180.0 / Math.PI).ToString("F1"));
|
||||
SetProperty("EndAngle", (_endAngleRad * 180.0 / Math.PI).ToString("F1"));
|
||||
SetProperty("MaxRange", _maxRangeM.ToString("F1"));
|
||||
SetProperty("LastScanTime", _lastScanDataTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
#region ILidar Implementation
|
||||
|
||||
public LaserScan? CurrentMeasurementData
|
||||
{
|
||||
get { lock (_dataLock) { return _lastLaserScan; } }
|
||||
}
|
||||
|
||||
public DateTime? LastScanDataTimestamp
|
||||
{
|
||||
get { lock (_dataLock) { return _lastScanDataTimestamp; } }
|
||||
}
|
||||
|
||||
// ILidar Device Specifications
|
||||
public double MinAngleRad => _startAngleRad;
|
||||
public double MaxAngleRad => _endAngleRad;
|
||||
public double MinRangeM => _minRangeM;
|
||||
public double MaxRangeM => _maxRangeM;
|
||||
public double? AngularResolutionRad => _angularResolutionRad;
|
||||
public double? ScanFrequencyHz => _scanFrequencyHz;
|
||||
public double FieldOfViewRad => Math.Abs(_endAngleRad - _startAngleRad);
|
||||
public bool SupportsIntensity => _supportsIntensity;
|
||||
public double? AccuracyM => _accuracyM;
|
||||
|
||||
#endregion
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
StopUpdateLoop();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Lidar
|
||||
{
|
||||
public class oleiScan
|
||||
{
|
||||
public Header header { get; set; } = new Header();
|
||||
public List<oleiPackage> oleiPackage { get; set; } = new List<oleiPackage>();
|
||||
}
|
||||
|
||||
public class oleiPackage
|
||||
{
|
||||
public DateTime stamp { get; set; }
|
||||
public byte[] data { get; set; } = new byte[1240];
|
||||
}
|
||||
|
||||
public class Header
|
||||
{
|
||||
public uint Seq { get; set; } // uint32
|
||||
public DateTime Stamp { get; set; } // time
|
||||
public string FrameId { get; set; } // string
|
||||
}
|
||||
|
||||
public class Connect
|
||||
{
|
||||
public Queue<oleiPackage> packetList = new Queue<oleiPackage>();
|
||||
private readonly object _packetListLock = new object(); // Lock object cho thread-safe access
|
||||
|
||||
public static readonly int kPacketSize = new oleiPackage().data.Length;
|
||||
public const int kError = -1;
|
||||
|
||||
public string device_IP { get; set; } = "192.168.254.13"; // Lidar
|
||||
public int device_port { get; set; } = 2368; // Lidar port
|
||||
public string local_ip { get; set; } = "192.168.254.10"; // PC/VM IP
|
||||
|
||||
public UdpClient udp;
|
||||
public IPEndPoint check_ip_device;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// CHECK LOCAL IP TỒN TẠI TRÊN NIC THẬT
|
||||
// ----------------------------------------------------------
|
||||
private bool IpExists(string ip)
|
||||
{
|
||||
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.ToString() == ip)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// CHECK PORT CÓ ĐANG ĐƯỢC SỬ DỤNG KHÔNG
|
||||
// ----------------------------------------------------------
|
||||
private bool IsPortBusy(int port)
|
||||
{
|
||||
var props = IPGlobalProperties.GetIPGlobalProperties();
|
||||
var listeners = props.GetActiveUdpListeners();
|
||||
|
||||
foreach (var ep in listeners)
|
||||
if (ep.Port == port)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MỞ CỔNG UDP
|
||||
// ----------------------------------------------------------
|
||||
public bool openPort()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1) Check local_ip có tồn tại trên NIC thật không
|
||||
if (!IpExists(local_ip))
|
||||
{
|
||||
Console.WriteLine($"[ERROR] local_ip {local_ip} does NOT exist on any NIC.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2) Check port có đang bận không
|
||||
if (IsPortBusy(device_port))
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Port {device_port} is already in use!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3) Bind vào local_ip + port
|
||||
|
||||
udp = new UdpClient(new IPEndPoint(IPAddress.Parse(local_ip), device_port));
|
||||
check_ip_device = new IPEndPoint(IPAddress.Parse(local_ip), device_port);
|
||||
udp.Client.ReceiveTimeout = 1000;
|
||||
Console.WriteLine($"[INFO] CONNECT SUCCESSFULLY!");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Cannot open UDP port {local_ip}:{device_port}");
|
||||
Console.WriteLine($"Reason: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ĐỌC RAW DATA
|
||||
// ----------------------------------------------------------
|
||||
public void readRawdata()
|
||||
{
|
||||
// Kiểm tra UDP client đã được khởi tạo chưa
|
||||
if (udp == null)
|
||||
{
|
||||
// Console.WriteLine("[ERROR] UDP client is not initialized. Call openPort() first.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Reset check_ip_device về giá trị ban đầu trước khi receive
|
||||
//check_ip_device = new IPEndPoint(IPAddress.Any, 0);
|
||||
byte[] data = udp.Receive(ref check_ip_device);
|
||||
// Console.WriteLine($"[INFO] check ip: {check_ip_device}");
|
||||
// Kiểm tra xem có nhận được dữ liệu từ địa chỉ hợp lệ không
|
||||
if (check_ip_device.Address == null ||
|
||||
check_ip_device.Address.Equals(IPAddress.Any) ||
|
||||
check_ip_device.Address.Equals(IPAddress.None) ||
|
||||
check_ip_device.Port == 0)
|
||||
{
|
||||
Console.WriteLine("[WARN] Received data from invalid endpoint (0.0.0.0:0), ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
// Console.WriteLine($"[INFO] Received from {check_ip_device.Address}:{check_ip_device.Port}");
|
||||
|
||||
// 1) Kiểm tra đúng IP lidar hay chưa
|
||||
if (check_ip_device.Address.ToString() != device_IP)
|
||||
{
|
||||
Console.WriteLine($"[WARN] Ignored packet from {check_ip_device.Address}, expected {device_IP}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Check size đúng 1240 bytes
|
||||
if (data.Length != kPacketSize)
|
||||
{
|
||||
Console.WriteLine($"[WARN] Wrong packet size {data.Length}, expected {kPacketSize}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Đổ dữ liệu vào queue (thread-safe)
|
||||
oleiPackage packet = new oleiPackage();
|
||||
Array.Copy(data, packet.data, kPacketSize);
|
||||
packet.stamp = DateTime.Now;
|
||||
|
||||
lock (_packetListLock)
|
||||
{
|
||||
packetList.Enqueue(packet);
|
||||
}
|
||||
}
|
||||
|
||||
catch (SocketException ex)
|
||||
{
|
||||
// Reset check_ip_device khi timeout hoặc lỗi
|
||||
check_ip_device = new IPEndPoint(IPAddress.Any, 0);
|
||||
Console.WriteLine($"SocketException caught!, IP checked: {check_ip_device}");
|
||||
if (ex.SocketErrorCode == SocketError.TimedOut)
|
||||
{
|
||||
// Không log timeout để tránh spam log
|
||||
// Console.WriteLine("[INFO] No data received (timeout)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Socket error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Reset check_ip_device khi có lỗi
|
||||
check_ip_device = new IPEndPoint(IPAddress.Any, 0);
|
||||
Console.WriteLine("[ERROR] Unexpected error: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// THREAD-SAFE METHODS CHO PACKET QUEUE
|
||||
// ----------------------------------------------------------
|
||||
public int GetPacketCount()
|
||||
{
|
||||
lock (_packetListLock)
|
||||
{
|
||||
return packetList.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public oleiPackage? TryDequeuePacket()
|
||||
{
|
||||
lock (_packetListLock)
|
||||
{
|
||||
if (packetList.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return packetList.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class DecoderConfig
|
||||
{
|
||||
public double AngleMin { get; set; } = -180.0; // degrees
|
||||
public double AngleMax { get; set; } = 180.0; // degrees
|
||||
public double RangeMin { get; set; } = 0.1; // meters
|
||||
public double RangeMax { get; set; } = 50.0; // meters
|
||||
public int Poly { get; set; } = 1;
|
||||
public bool Inverted { get; set; } = false;
|
||||
public double StepHundredthDeg { get; set; } = 225.0; // 0.225 deg * 100
|
||||
public string FrameId { get; set; } = "olelidar";
|
||||
}
|
||||
|
||||
public static class OleiConstants
|
||||
{
|
||||
public const int DataHeadSize = 40;
|
||||
public const int PointBytes = 8;
|
||||
public const int BlocksPerPacket = 150;
|
||||
public const int PacketSize = DataHeadSize + BlocksPerPacket * PointBytes; // 1240
|
||||
public const float DistanceResolution = 0.001f; // meters
|
||||
public const float AzimuthResolutionDeg = 0.01f; // degrees
|
||||
public const float DistanceMax = 20.0f;
|
||||
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public readonly struct DataPoint
|
||||
{
|
||||
public readonly ushort Azimuth; // hundredth degree
|
||||
public readonly ushort Distance; // mm
|
||||
public readonly ushort Reflectivity; // unitless
|
||||
public readonly ushort Distance2; // reserved
|
||||
|
||||
public DataPoint(ReadOnlySpan<byte> span)
|
||||
{
|
||||
Azimuth = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(0, 2));
|
||||
Distance = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(2, 2));
|
||||
Reflectivity = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(4, 2));
|
||||
Distance2 = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(6, 2));
|
||||
}
|
||||
|
||||
public float DistanceMeters => Distance * OleiConstants.DistanceResolution;
|
||||
public double AzimuthDegrees => Azimuth * OleiConstants.AzimuthResolutionDeg;
|
||||
}
|
||||
|
||||
public readonly struct FiringSequence
|
||||
{
|
||||
public readonly DataPoint Point;
|
||||
public FiringSequence(ReadOnlySpan<byte> span) => Point = new DataPoint(span);
|
||||
}
|
||||
|
||||
public readonly struct DataHeader
|
||||
{
|
||||
public readonly byte[] Magic; // 4 bytes
|
||||
public readonly byte[] Version; // 2 bytes
|
||||
public readonly byte Scale; // 1 byte
|
||||
public readonly byte[] Oem; // 3 bytes
|
||||
public readonly byte[] Model; // 12 bytes
|
||||
public readonly byte[] Code; // 2 bytes
|
||||
public readonly byte[] Hw; // 2 bytes
|
||||
public readonly byte[] Sw; // 2 bytes
|
||||
|
||||
public readonly uint Timestamp; // 4 bytes (device ms)
|
||||
public readonly ushort Rpm; // 2 bytes
|
||||
public readonly byte[] Flag; // 2 bytes
|
||||
public readonly uint Rsv; // 4 bytes (NTP seconds maybe)
|
||||
|
||||
public DataHeader(ReadOnlySpan<byte> span)
|
||||
{
|
||||
Magic = span.Slice(0, 4).ToArray();
|
||||
Version = span.Slice(4, 2).ToArray();
|
||||
Scale = span[6];
|
||||
Oem = span.Slice(7, 3).ToArray();
|
||||
Model = span.Slice(10, 12).ToArray();
|
||||
Code = span.Slice(22, 2).ToArray();
|
||||
Hw = span.Slice(24, 2).ToArray();
|
||||
Sw = span.Slice(26, 2).ToArray();
|
||||
|
||||
Timestamp = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(28, 4));
|
||||
Rpm = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(32, 2));
|
||||
|
||||
Flag = span.Slice(34, 2).ToArray();
|
||||
Rsv = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(36, 4));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct DataBlock
|
||||
{
|
||||
public readonly FiringSequence sequence;
|
||||
public DataBlock(ReadOnlySpan<byte> span) => sequence = new FiringSequence(span);
|
||||
}
|
||||
|
||||
public readonly struct Packet
|
||||
{
|
||||
public readonly DataHeader Header;
|
||||
public readonly DataBlock[] Block;
|
||||
|
||||
public Packet(ReadOnlySpan<byte> span)
|
||||
{
|
||||
if (span.Length < OleiConstants.PacketSize)
|
||||
throw new ArgumentException($"packet must be at least {OleiConstants.PacketSize} bytes");
|
||||
|
||||
Header = new DataHeader(span.Slice(0, OleiConstants.DataHeadSize));
|
||||
|
||||
Block = new DataBlock[OleiConstants.BlocksPerPacket];
|
||||
int offset = OleiConstants.DataHeadSize;
|
||||
int blockBytes = OleiConstants.PointBytes;
|
||||
for (int i = 0; i < OleiConstants.BlocksPerPacket; i++)
|
||||
{
|
||||
Block[i] = new DataBlock(span.Slice(offset, blockBytes));
|
||||
offset += blockBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main decoder
|
||||
public class DecodeLidar
|
||||
{
|
||||
// buffers
|
||||
public readonly List<double> scanRadAngleVec = new();
|
||||
public readonly List<double> scanAngleVec = new();
|
||||
public readonly List<ushort> scanRangeVec = new();
|
||||
public readonly List<ushort> scanIntensityVec = new();
|
||||
|
||||
public readonly List<double> scanAngleInVec = new();
|
||||
public readonly List<ushort> scanRangeInVec = new();
|
||||
public readonly List<ushort> scanIntensityInVec = new();
|
||||
public readonly List<double> scanRadAngleInVec = new();
|
||||
|
||||
// state
|
||||
public ushort AzimuthLast { get; private set; } = 0;
|
||||
public ushort AzimuthNow { get; private set; } = 0;
|
||||
public ushort AzimuthFirst { get; private set; } = 0xFFFF;
|
||||
|
||||
private DateTime machineTimeBase;
|
||||
private uint innerTimestampBase; // ms
|
||||
private bool isTimeBase = false;
|
||||
private uint laststamp = 0;
|
||||
|
||||
public float Frequency { get; private set; } = 0.0f;
|
||||
public byte LidarType { get; private set; } = 0x01;
|
||||
public int Direction { get; private set; } = 0;
|
||||
|
||||
// private uint scanMsgSeq = 0;
|
||||
private double _stepHundredthDeg = 225.0; // default 0.225*100
|
||||
|
||||
private readonly DecoderConfig _config = new DecoderConfig();
|
||||
|
||||
// event to notify a completed scan
|
||||
|
||||
private readonly object locker = new();
|
||||
|
||||
/// <summary>
|
||||
/// Set configuration for the decoder
|
||||
/// </summary>
|
||||
public void SetConfig(DecoderConfig config)
|
||||
{
|
||||
_config.Inverted = config.Inverted;
|
||||
}
|
||||
|
||||
public void PacketCb(oleiPackage data_msg)
|
||||
{
|
||||
if (data_msg == null || data_msg.data == null || data_msg.data.Length < OleiConstants.PacketSize)
|
||||
return;
|
||||
|
||||
// parse packet
|
||||
Packet pkt = new Packet(new ReadOnlySpan<byte>(data_msg.data));
|
||||
|
||||
// update azimuth
|
||||
AzimuthNow = pkt.Block[0].sequence.Point.Azimuth;
|
||||
|
||||
// first packet initialization
|
||||
if (AzimuthFirst == 0xFFFF)
|
||||
{
|
||||
LidarType = pkt.Header.Code.Length > 1 ? pkt.Header.Code[1] : (byte)0x01;
|
||||
AzimuthFirst = AzimuthNow;
|
||||
int rpm = pkt.Header.Rpm & 0x7FFF;
|
||||
//Console.WriteLine($"[DecodeLidar] LidarType: 0x{LidarType:X2}, Initial RPM: {rpm}");
|
||||
Direction = (pkt.Header.Rpm >> 15) & 1;
|
||||
if (_config.Inverted) Direction = 1 - Direction;
|
||||
if (Frequency < 0.001f && rpm > 0)
|
||||
{
|
||||
Frequency = rpm / 60.0f;
|
||||
// if (LidarType == 0x01) _stepHundredthDeg = 225.0; // 0.225 *100
|
||||
}
|
||||
}
|
||||
|
||||
// if azimuth increased -> decode into buffer and return (same behavior as original)
|
||||
if (AzimuthLast < AzimuthNow)
|
||||
{
|
||||
DecodeAndFill(pkt);
|
||||
AzimuthLast = AzimuthNow;
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
AzimuthLast = AzimuthNow;
|
||||
}
|
||||
|
||||
// guard
|
||||
if (AzimuthFirst >= 200)
|
||||
{
|
||||
AzimuthFirst = AzimuthNow;
|
||||
return;
|
||||
}
|
||||
|
||||
// timestamp handling: lidar internal timestamp in ms
|
||||
uint nowstamp = pkt.Header.Timestamp; // ms
|
||||
|
||||
byte[] safetySignal = pkt.Header.Flag;
|
||||
byte inputIOSignal = (byte)((safetySignal[0] >> 4) & 0x0F); // 4 bits cao
|
||||
byte outputIOSignal = (byte)(safetySignal[0] & 0x0F); // 4 bits thấp
|
||||
byte errorStatus = (byte)(safetySignal[0] & 0x01);
|
||||
byte dangerZone = (byte)((safetySignal[0] >> 1) & 0x01);
|
||||
byte protectZone = (byte)((safetySignal[0] >> 2) & 0x01);
|
||||
byte warningZone = (byte)((safetySignal[0] >> 3) & 0x01);
|
||||
DateTime lidarTimeUtc;
|
||||
|
||||
if (!isTimeBase)
|
||||
{
|
||||
// set base mapping: machineTimeBase corresponds to innerTimestampBase
|
||||
machineTimeBase = data_msg.stamp.Kind == DateTimeKind.Utc ? data_msg.stamp : data_msg.stamp.ToUniversalTime();
|
||||
innerTimestampBase = nowstamp;
|
||||
lidarTimeUtc = machineTimeBase;
|
||||
isTimeBase = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
uint deltaMs = nowstamp - innerTimestampBase; // wrap-around handled naturally with unsigned
|
||||
TimeSpan delta = TimeSpan.FromMilliseconds(deltaMs);
|
||||
lidarTimeUtc = machineTimeBase + delta;
|
||||
}
|
||||
|
||||
// scan time for frame (delta between internal timestamps)
|
||||
uint scantime = nowstamp - laststamp;
|
||||
laststamp = nowstamp;
|
||||
|
||||
// compute frequency if unknown (fallback)
|
||||
|
||||
// move accumulated to in-vec (thread-safe)
|
||||
lock (locker)
|
||||
{
|
||||
scanAngleInVec.Clear();
|
||||
scanRangeInVec.Clear();
|
||||
scanIntensityInVec.Clear();
|
||||
scanRadAngleInVec.Clear();
|
||||
|
||||
scanAngleInVec.AddRange(scanAngleVec);
|
||||
scanRangeInVec.AddRange(scanRangeVec);
|
||||
scanIntensityInVec.AddRange(scanIntensityVec);
|
||||
scanRadAngleInVec.AddRange(scanRadAngleVec);
|
||||
|
||||
if(Direction == 0){
|
||||
scanRangeInVec.Reverse();
|
||||
scanIntensityInVec.Reverse();
|
||||
}
|
||||
scanAngleVec.Clear();
|
||||
scanRadAngleVec.Clear();
|
||||
scanRangeVec.Clear();
|
||||
scanIntensityVec.Clear();
|
||||
}
|
||||
|
||||
// publish one frame
|
||||
//PublishScan(lidarTimeUtc);
|
||||
|
||||
// after publishing, decode current packet to start next frame
|
||||
DecodeAndFill(pkt);
|
||||
}
|
||||
public void DecodeAndFill(Packet pkt)
|
||||
{
|
||||
// pkt.Block[] already filled in constructor
|
||||
// Note: Range filtering is done in OleiLidarDriver based on its own config
|
||||
// We only filter obviously invalid data here (range = 0 or invalid azimuth)
|
||||
for (int iblk = 0; iblk < OleiConstants.BlocksPerPacket; iblk++)
|
||||
{
|
||||
var dp = pkt.Block[iblk].sequence.Point;
|
||||
double az = (double)(dp.Azimuth * 0.01); // dp.Azimuth unit: 0.01 deg/LSB, convert to degrees
|
||||
ushort range = dp.Distance; // mm
|
||||
var radaz = Deg2Rad(az); // Convert to radians
|
||||
ushort intensity = dp.Reflectivity; // Range: 0 - 65535
|
||||
|
||||
// Invalid azimuth guard: skip if azimuth is invalid (>= 0xFF00)
|
||||
// 0xFF00 = 65280 in decimal, which is used as invalid marker
|
||||
if (dp.Azimuth >= 0xFF00)
|
||||
{
|
||||
continue; // Skip invalid azimuth points
|
||||
}
|
||||
|
||||
// Skip points with zero range (no valid measurement)
|
||||
// Range filtering based on MinRangeM/MaxRangeM is done in OleiLidarDriver
|
||||
if (range == 0)
|
||||
{
|
||||
continue; // Skip zero-range points
|
||||
}
|
||||
|
||||
// Add valid point to buffers
|
||||
lock (locker)
|
||||
{
|
||||
scanAngleVec.Add(az); // Angle in degrees
|
||||
scanRangeVec.Add(range); // Distance in mm
|
||||
scanIntensityVec.Add(intensity); // Intensity 0-65535
|
||||
scanRadAngleVec.Add(radaz); // Angle in radians
|
||||
}
|
||||
// Console.WriteLine($"[DecodeLidar] pointCloud added azimuth:{az}° range:{range}mm intensity:{intensity}");
|
||||
}
|
||||
}
|
||||
private static double Deg2Rad(double deg) => deg * Math.PI / 180.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
namespace RobotNet10.RobotApp.Drivers.PhenikaaX;
|
||||
|
||||
public static class CiA402Helper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Extract value từ PDO data theo bit offset và bit length
|
||||
/// </summary>
|
||||
public static byte[] ExtractValueFromPdoData(byte[] pdoData, int bitOffset, byte bitLength)
|
||||
{
|
||||
int byteOffset = bitOffset / 8;
|
||||
int bitOffsetInByte = bitOffset % 8;
|
||||
int byteLength = (bitLength + 7) / 8; // Round up
|
||||
|
||||
if (byteOffset + byteLength > pdoData.Length)
|
||||
{
|
||||
throw new ArgumentException($"PDO data too short for extraction at bit offset {bitOffset}, length {bitLength}");
|
||||
}
|
||||
|
||||
byte[] result = new byte[byteLength];
|
||||
|
||||
if (bitOffsetInByte == 0 && bitLength % 8 == 0)
|
||||
{
|
||||
// Aligned extraction - simple copy
|
||||
Array.Copy(pdoData, byteOffset, result, 0, byteLength);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unaligned extraction - need bit manipulation
|
||||
ulong value = 0;
|
||||
int bitsRead = 0;
|
||||
int currentByteOffset = byteOffset;
|
||||
int currentBitOffset = bitOffsetInByte;
|
||||
|
||||
while (bitsRead < bitLength && currentByteOffset < pdoData.Length)
|
||||
{
|
||||
int bitsToRead = Math.Min(8 - currentBitOffset, bitLength - bitsRead);
|
||||
byte mask = (byte)((1 << bitsToRead) - 1);
|
||||
byte byteValue = (byte)((pdoData[currentByteOffset] >> currentBitOffset) & mask);
|
||||
|
||||
value |= (ulong)byteValue << bitsRead;
|
||||
|
||||
bitsRead += bitsToRead;
|
||||
currentByteOffset++;
|
||||
currentBitOffset = 0;
|
||||
}
|
||||
|
||||
// Convert ulong to byte array
|
||||
for (int i = 0; i < byteLength; i++)
|
||||
{
|
||||
result[i] = (byte)(value >> (i * 8));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parse object index từ string (hỗ trợ hex format: 0x6040 hoặc decimal: 24640)
|
||||
/// </summary>
|
||||
public static bool TryParseObjectIndex(string indexStr, out ushort index)
|
||||
{
|
||||
index = 0;
|
||||
if (string.IsNullOrWhiteSpace(indexStr))
|
||||
return false;
|
||||
|
||||
indexStr = indexStr.Trim();
|
||||
if (indexStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ushort.TryParse(indexStr.AsSpan(2), System.Globalization.NumberStyles.HexNumber, null, out index);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ushort.TryParse(indexStr, out index);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert bytes to value type với sign extension nếu cần
|
||||
/// </summary>
|
||||
public static T ConvertBytesToValue<T>(byte[] bytes, byte bitLength) where T : struct
|
||||
{
|
||||
ulong value = 0;
|
||||
for (int i = 0; i < Math.Min(bytes.Length, 8); i++)
|
||||
{
|
||||
value |= (ulong)bytes[i] << (i * 8);
|
||||
}
|
||||
|
||||
var type = typeof(T);
|
||||
if (type == typeof(ushort))
|
||||
return (T)(object)(ushort)value;
|
||||
if (type == typeof(short))
|
||||
{
|
||||
if (bitLength < 16 && (value & (1UL << (bitLength - 1))) != 0)
|
||||
value |= 0xFFFFUL << bitLength;
|
||||
return (T)(object)(short)value;
|
||||
}
|
||||
if (type == typeof(uint))
|
||||
return (T)(object)(uint)value;
|
||||
if (type == typeof(int))
|
||||
{
|
||||
if (bitLength < 32 && (value & (1UL << (bitLength - 1))) != 0)
|
||||
value |= 0xFFFFFFFFUL << bitLength;
|
||||
return (T)(object)(int)value;
|
||||
}
|
||||
if (type == typeof(byte))
|
||||
return (T)(object)(byte)value;
|
||||
if (type == typeof(sbyte))
|
||||
{
|
||||
if (bitLength < 8 && (value & (1UL << (bitLength - 1))) != 0)
|
||||
value |= 0xFFUL << bitLength;
|
||||
return (T)(object)(sbyte)value;
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Type {type.Name} is not supported");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert value to bytes theo bitLength được map trong PDO
|
||||
/// Mục đích của bitLength:
|
||||
/// 1. Truncate/mask giá trị nếu vượt quá số bits được map
|
||||
/// 2. Đảm bảo chỉ gửi đúng số bytes cần thiết
|
||||
/// 3. Xử lý các trường hợp partial mapping (ví dụ: map 8 bits của ushort 16 bits)
|
||||
/// </summary>
|
||||
public static byte[] ConvertValueToBytes<T>(T value, byte bitLength) where T : struct
|
||||
{
|
||||
var type = typeof(T);
|
||||
int byteLength = (bitLength + 7) / 8; // Round up to nearest byte
|
||||
|
||||
// Convert value to ulong để xử lý mask
|
||||
ulong rawValue = 0;
|
||||
if (type == typeof(ushort))
|
||||
rawValue = (ushort)(object)value!;
|
||||
else if (type == typeof(short))
|
||||
rawValue = (ulong)(ushort)(short)(object)value!; // Convert signed to unsigned
|
||||
else if (type == typeof(uint))
|
||||
rawValue = (uint)(object)value!;
|
||||
else if (type == typeof(int))
|
||||
rawValue = (ulong)(uint)(int)(object)value!; // Convert signed to unsigned
|
||||
else if (type == typeof(byte))
|
||||
rawValue = (byte)(object)value!;
|
||||
else if (type == typeof(sbyte))
|
||||
rawValue = (ulong)(byte)(sbyte)(object)value!;
|
||||
else
|
||||
throw new NotSupportedException($"Type {type.Name} is not supported");
|
||||
|
||||
// Mask để chỉ lấy số bits được map
|
||||
if (bitLength < 64)
|
||||
{
|
||||
ulong mask = (1UL << bitLength) - 1;
|
||||
rawValue &= mask;
|
||||
}
|
||||
|
||||
// Convert to byte array với đúng số bytes
|
||||
byte[] result = new byte[byteLength];
|
||||
for (int i = 0; i < byteLength && i < 8; i++)
|
||||
{
|
||||
result[i] = (byte)(rawValue >> (i * 8));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/Sick.zip
Normal file
BIN
srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Drivers/Sick.zip
Normal file
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1. In ra log LaserScan
|
||||
$./run-quiet.sh
|
||||
$./run.sh 2>&1 | grep -A 30 "===== LaserScan"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
||||
using System.Timers;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
using RobotNet10.Shared;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Tada;
|
||||
|
||||
/// <summary>
|
||||
/// BMU driver cho TADA RS485
|
||||
/// </summary>
|
||||
[Device(DeviceType.Battery, "Tada", "TadaBattery", "1.0.0", Description = "Battery Tada Driver")]
|
||||
public class TadaBattery : DeviceBase, IBattery
|
||||
{
|
||||
private readonly ILogger<TadaBattery> _logger;
|
||||
private readonly Lock _dataLock = new();
|
||||
|
||||
private TadaRs485Client? _client;
|
||||
|
||||
// Polling
|
||||
private System.Timers.Timer? _pollingTimer;
|
||||
|
||||
// Cached (THEO TÀI LIỆU RS485)
|
||||
private double _cachedChargeLevel; // SOC %
|
||||
private double _cachedVoltage; // V
|
||||
private double _cachedCurrent; // A
|
||||
private bool _cachedCharging; // Current > 0
|
||||
|
||||
private double? _cachedHealth; // SOH %
|
||||
private double? _cachedTemperature; // Celsius
|
||||
private double? _cachedRemainingCapacity; // Ah
|
||||
private double? _cachedFullCapacity; // Wh (RemainEnergy)
|
||||
|
||||
private int? _cachedChargeTime; // minutes (time to full)
|
||||
private int? _cachedDischargeTime; // minutes (time to empty)
|
||||
private int? _cachedStatusRaw; // raw bit flags
|
||||
|
||||
private DateTime _lastUpdateTime = DateTime.MinValue;
|
||||
private BatteryState? _cachedBatteryState;
|
||||
|
||||
// IBattery Implementation
|
||||
public BatteryState? CurrentBatteryState
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedBatteryState; } }
|
||||
}
|
||||
|
||||
private readonly string _portName;
|
||||
private readonly int _baud;
|
||||
|
||||
public TadaBattery(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider)
|
||||
: base(deviceId, deviceName, DeviceType.Battery)
|
||||
{
|
||||
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
_logger = loggerFactory.CreateLogger<TadaBattery>();
|
||||
|
||||
_portName = connection.GetValue<string>("Port") ?? throw new Exception("Port is required");
|
||||
_baud = connection.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
|
||||
|
||||
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true;
|
||||
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 2000;
|
||||
MaxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts") ?? 0;
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
// DeviceBase overrides
|
||||
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
_client?.Dispose();
|
||||
_client = new TadaRs485Client(_logger, _portName, _baud);
|
||||
}
|
||||
UpdateProperties();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartPollingLoop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopPollingLoop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopPollingLoop();
|
||||
lock (_dataLock)
|
||||
{
|
||||
_client?.ForceReconnect(); // now exists
|
||||
ResetCache();
|
||||
}
|
||||
UpdateProperties();
|
||||
StartPollingLoop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_dataLock)
|
||||
return Task.FromResult(_client != null && !_client.IsFaulted);
|
||||
}
|
||||
|
||||
// Polling
|
||||
private void StartPollingLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_pollingTimer != null && _pollingTimer.Enabled)
|
||||
return;
|
||||
|
||||
StopPollingLoop(); // Đảm bảo không có timer nào đang chạy
|
||||
|
||||
_pollingTimer = new System.Timers.Timer(500) // 2Hz = 500ms interval
|
||||
{
|
||||
AutoReset = true,
|
||||
Enabled = true
|
||||
};
|
||||
_pollingTimer.Elapsed += PollTimer_Elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void StopPollingLoop()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_pollingTimer != null)
|
||||
{
|
||||
_pollingTimer.Stop();
|
||||
_pollingTimer.Elapsed -= PollTimer_Elapsed;
|
||||
_pollingTimer.Dispose();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PollTimer_Elapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_client?.RequestStatus();
|
||||
var data = _client?.ReadResponse();
|
||||
if (data != null)
|
||||
UpdateCache(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "RS485 polling error");
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCache
|
||||
private void UpdateCache(Dictionary<string, double> data)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// CHARGE LEVEL
|
||||
if (data.TryGetValue("SOC", out var soc))
|
||||
{
|
||||
if (Math.Abs(soc - _cachedChargeLevel) > 0.1)
|
||||
{
|
||||
_cachedChargeLevel = soc;
|
||||
}
|
||||
}
|
||||
|
||||
// VOLTAGE
|
||||
if (data.TryGetValue("Voltage", out var volt))
|
||||
{
|
||||
if (Math.Abs(volt - _cachedVoltage) > 0.05)
|
||||
{
|
||||
_cachedVoltage = volt;
|
||||
}
|
||||
}
|
||||
|
||||
// CURRENT
|
||||
if (data.TryGetValue("Current", out var curr))
|
||||
{
|
||||
if (Math.Abs(curr - _cachedCurrent) > 0.05)
|
||||
{
|
||||
_cachedCurrent = curr;
|
||||
}
|
||||
}
|
||||
|
||||
// CHARGING
|
||||
bool newCharging = _cachedCurrent > 0;
|
||||
if (newCharging != _cachedCharging)
|
||||
{
|
||||
_cachedCharging = newCharging;
|
||||
}
|
||||
|
||||
// TEMPERATURE
|
||||
if (data.TryGetValue("Temp", out var t))
|
||||
{
|
||||
if (_cachedTemperature == null || Math.Abs(t - _cachedTemperature.Value) > 0.1)
|
||||
{
|
||||
_cachedTemperature = t;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields (per document)
|
||||
_cachedHealth = data.TryGetValue("SOH", out var soh) ? soh : null;
|
||||
_cachedRemainingCapacity = data.TryGetValue("RemainCapacity", out var rc) ? rc : null;
|
||||
_cachedFullCapacity = data.TryGetValue("RemainEnergy", out var re) ? re : null;
|
||||
|
||||
_cachedChargeTime = data.TryGetValue("ChargeTime", out var ct) ? (int)ct : null;
|
||||
_cachedDischargeTime = data.TryGetValue("DischargeTime", out var dt) ? (int)dt : null;
|
||||
_cachedStatusRaw = data.TryGetValue("Status", out var st) ? (int)st : null;
|
||||
|
||||
_lastUpdateTime = now;
|
||||
|
||||
// Update cached BatteryState
|
||||
_cachedBatteryState = CreateBatteryStateFromCache();
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetCache()
|
||||
{
|
||||
_cachedChargeLevel = 0;
|
||||
_cachedVoltage = 0;
|
||||
_cachedCurrent = 0;
|
||||
_cachedCharging = false;
|
||||
|
||||
_cachedHealth = null;
|
||||
_cachedTemperature = null;
|
||||
_cachedRemainingCapacity = null;
|
||||
_cachedFullCapacity = null;
|
||||
|
||||
_cachedChargeTime = null;
|
||||
_cachedDischargeTime = null;
|
||||
_cachedStatusRaw = null;
|
||||
|
||||
_lastUpdateTime = DateTime.MinValue;
|
||||
_cachedBatteryState = null;
|
||||
}
|
||||
|
||||
// IBattery Implementation
|
||||
public Task<BatteryState> ReadBatteryStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
if (_cachedBatteryState.HasValue)
|
||||
{
|
||||
return Task.FromResult(_cachedBatteryState.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult(CreateBatteryStateFromCache());
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to create BatteryState from cached values
|
||||
private BatteryState CreateBatteryStateFromCache()
|
||||
{
|
||||
// Map PowerSupplyStatus from charging state
|
||||
byte powerSupplyStatus = BatteryState.PowerSupplyStatusUnknown;
|
||||
if (_cachedCharging)
|
||||
powerSupplyStatus = BatteryState.PowerSupplyStatusCharging;
|
||||
else if (_cachedCurrent < 0)
|
||||
powerSupplyStatus = BatteryState.PowerSupplyStatusDischarging;
|
||||
else if (_cachedCurrent == 0)
|
||||
powerSupplyStatus = BatteryState.PowerSupplyStatusNotCharging;
|
||||
|
||||
// Map PowerSupplyHealth from health percentage
|
||||
byte powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown;
|
||||
if (_cachedHealth.HasValue)
|
||||
{
|
||||
if (_cachedHealth.Value >= 80)
|
||||
powerSupplyHealth = BatteryState.PowerSupplyHealthGood;
|
||||
else if (_cachedHealth.Value < 20)
|
||||
powerSupplyHealth = BatteryState.PowerSupplyHealthDead;
|
||||
}
|
||||
|
||||
return new BatteryState
|
||||
{
|
||||
Header = new Header
|
||||
{
|
||||
Stamp = _lastUpdateTime != DateTime.MinValue ? _lastUpdateTime : DateTime.UtcNow,
|
||||
FrameId = "battery_frame"
|
||||
},
|
||||
Voltage = _cachedVoltage,
|
||||
Current = _cachedCurrent,
|
||||
Charge = _cachedRemainingCapacity.HasValue ? _cachedRemainingCapacity.Value : double.NaN,
|
||||
Capacity = _cachedFullCapacity.HasValue ? _cachedFullCapacity.Value : double.NaN,
|
||||
DesignCapacity = double.NaN, // Not provided by TADA BMU
|
||||
Percentage = _cachedChargeLevel,
|
||||
PowerSupplyStatus = powerSupplyStatus,
|
||||
PowerSupplyHealth = powerSupplyHealth,
|
||||
PowerSupplyTechnology = BatteryState.PowerSupplyTechnologyUnknown, // Not provided by TADA BMU
|
||||
Present = true,
|
||||
CellVoltage = Array.Empty<double>(), // Not provided by TADA BMU
|
||||
CellTemperature = _cachedTemperature.HasValue ? new[] { _cachedTemperature.Value } : Array.Empty<double>(),
|
||||
Location = string.Empty,
|
||||
SerialNumber = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
// UpdateProperties
|
||||
private void UpdateProperties()
|
||||
{
|
||||
lock (_dataLock)
|
||||
{
|
||||
SetProperty("ChargeLevel", _cachedChargeLevel.ToString("F1"));
|
||||
SetProperty("Voltage", _cachedVoltage.ToString("F2"));
|
||||
SetProperty("Current", _cachedCurrent.ToString("F2"));
|
||||
SetProperty("Charging", _cachedCharging.ToString());
|
||||
SetProperty("Temperature", _cachedTemperature?.ToString("F1") ?? "0");
|
||||
SetProperty("Health", _cachedHealth?.ToString("F0") ?? "0");
|
||||
SetProperty("RemainCapacity", _cachedRemainingCapacity?.ToString("F2") ?? "0");
|
||||
SetProperty("FullCapacity", _cachedFullCapacity?.ToString("F2") ?? "0");
|
||||
|
||||
SetProperty("ChargeTime", _cachedChargeTime?.ToString() ?? "0");
|
||||
SetProperty("DischargeTime", _cachedDischargeTime?.ToString() ?? "0");
|
||||
SetProperty("StatusRaw", _cachedStatusRaw?.ToString() ?? "0");
|
||||
}
|
||||
}
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
yield return new PropertyDescription("ChargeLevel", "Charge Level (%)", "Mức pin (%)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 1,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Voltage", "Voltage (V)", "Điện áp (V)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 2,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Current", "Current (A)", "Dòng điện (A)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 3,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Charging", "Charging", "Đang sạc?")
|
||||
{
|
||||
DataType = "boolean",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 4,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Temperature", "Temperature (°C)", "Nhiệt độ")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 5,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Health", "SOH (%)", "Sức khỏe pin")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 6,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("RemainCapacity", "Remaining Capacity (Ah)", "Dung lượng còn lại")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 7,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("FullCapacity", "Remaining Energy (Wh)", "Năng lượng còn lại (Wh)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 8,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("ChargeTime", "Charge Time (min)", "Thời gian còn lại để sạc đầy")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 9,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("DischargeTime", "Discharge Time (min)", "Thời gian còn lại để xả hết")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 10,
|
||||
Category = "Status"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("StatusRaw", "Status Flags", "Trạng thái bit (BMU Flags)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 11,
|
||||
Category = "Status"
|
||||
};
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
StopPollingLoop();
|
||||
_client?.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Ports;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Tada;
|
||||
|
||||
[Flags]
|
||||
public enum DataKind1 : byte
|
||||
{
|
||||
Voltage = 1 << 0,
|
||||
Current = 1 << 1,
|
||||
SOC = 1 << 2,
|
||||
Status = 1 << 3,
|
||||
ChargeTime = 1 << 4,
|
||||
DischargeTime = 1 << 5,
|
||||
Temp = 1 << 6
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum DataKind2 : byte
|
||||
{
|
||||
SOH = 1 << 0,
|
||||
RemainCapacity = 1 << 1,
|
||||
RemainEnergy = 1 << 2
|
||||
}
|
||||
public class TadaRs485Client : IDisposable
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private SerialPort _port;
|
||||
private readonly string _portName;
|
||||
private readonly int _baud;
|
||||
private readonly Parity _parity;
|
||||
private readonly int _dataBits;
|
||||
private readonly StopBits _stopBits;
|
||||
private readonly int _readTimeoutMs;
|
||||
private readonly int _writeTimeoutMs;
|
||||
public bool IsFaulted => _faulted;
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private bool _faulted = false;
|
||||
private DateTime _lastRetry = DateTime.MinValue;
|
||||
private int _retryDelayMs = 1000; // backoff min = 1s
|
||||
private DateTime _lastDataTime = DateTime.MinValue;
|
||||
private readonly int _dataTimeoutMs = 10_000; // 10 giây
|
||||
private int _consecutiveFails = 0;
|
||||
private readonly int _maxFails = 3; // sau 3 lần fail liên tiếp thì coi như lost
|
||||
|
||||
public TadaRs485Client(
|
||||
ILogger logger,
|
||||
string portName,
|
||||
int baud = 19200,
|
||||
Parity parity = Parity.None,
|
||||
int dataBits = 8,
|
||||
StopBits stopBits = StopBits.One,
|
||||
int readTimeoutMs = 2000,
|
||||
int writeTimeoutMs = 1000)
|
||||
{
|
||||
_logger = logger;
|
||||
_portName = portName;
|
||||
_baud = baud;
|
||||
_parity = parity;
|
||||
_dataBits = dataBits;
|
||||
_stopBits = stopBits;
|
||||
_readTimeoutMs = readTimeoutMs;
|
||||
_writeTimeoutMs = writeTimeoutMs;
|
||||
_lastDataTime = DateTime.Now;
|
||||
_consecutiveFails = 0;
|
||||
_port = null!;
|
||||
EnsureConnected();
|
||||
}
|
||||
|
||||
private void CheckDataTimeout()
|
||||
{
|
||||
if (_lastDataTime != DateTime.MinValue &&
|
||||
(DateTime.Now - _lastDataTime).TotalMilliseconds > _dataTimeoutMs)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Communication lost (data timeout).");
|
||||
_faulted = true;
|
||||
_lastDataTime = DateTime.MinValue; // reset để tránh spam log
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_port != null && _port.IsOpen && !_faulted) return;
|
||||
|
||||
if ((DateTime.Now - _lastRetry).TotalMilliseconds < _retryDelayMs)
|
||||
return;
|
||||
_lastRetry = DateTime.Now;
|
||||
|
||||
try
|
||||
{
|
||||
_port?.Dispose();
|
||||
_port = new SerialPort(_portName, _baud, _parity, _dataBits, _stopBits)
|
||||
{
|
||||
ReadTimeout = _readTimeoutMs,
|
||||
WriteTimeout = _writeTimeoutMs
|
||||
};
|
||||
_port.Open();
|
||||
_faulted = false;
|
||||
_retryDelayMs = 1000;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Connect failed: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
// exponential backoff up to 30s
|
||||
_retryDelayMs = Math.Min(_retryDelayMs * 2, 30_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte Checksum(byte[] data, int start, int len)
|
||||
{
|
||||
int sum = 0;
|
||||
for (int i = start; i < start + len; i++) sum += data[i];
|
||||
return (byte)(sum & 0xFF);
|
||||
}
|
||||
|
||||
private static string ToHex(byte[] data, int len)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
sb.Append(data[i].ToString("X2"));
|
||||
if (i < len - 1) sb.Append('-');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void RequestStatus(byte address = 0x60,
|
||||
DataKind1 kind1 = DataKind1.Voltage | DataKind1.Current | DataKind1.SOC | DataKind1.Status |
|
||||
DataKind1.ChargeTime | DataKind1.DischargeTime | DataKind1.Temp,
|
||||
DataKind2 kind2 = DataKind2.SOH | DataKind2.RemainCapacity | DataKind2.RemainEnergy)
|
||||
{
|
||||
EnsureConnected();
|
||||
if (_port == null || !_port.IsOpen || _faulted) return;
|
||||
|
||||
try
|
||||
{
|
||||
byte kind1Byte = (byte)kind1;
|
||||
byte kind2Byte = (byte)kind2;
|
||||
|
||||
byte[] frame =
|
||||
[
|
||||
0xAF, 0xFA,
|
||||
address,
|
||||
0x05,
|
||||
0x01,
|
||||
address,
|
||||
kind1Byte, kind2Byte,
|
||||
0x00,
|
||||
0xAF, 0xA0
|
||||
];
|
||||
|
||||
frame[8] = Checksum(frame, 2, 6);
|
||||
_port.DiscardInBuffer();
|
||||
_port.DiscardOutBuffer();
|
||||
_port.Write(frame, 0, frame.Length);
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Write error: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] ReadFrame()
|
||||
{
|
||||
EnsureConnected();
|
||||
if (_port == null || !_port.IsOpen || _faulted) return [];
|
||||
|
||||
var buffer = new List<byte>();
|
||||
int expectedLen = -1;
|
||||
var start = DateTime.Now;
|
||||
|
||||
try
|
||||
{
|
||||
while ((DateTime.Now - start).TotalMilliseconds < _readTimeoutMs)
|
||||
{
|
||||
int bytesAvailable = _port.BytesToRead;
|
||||
if (bytesAvailable > 0)
|
||||
{
|
||||
byte[] tempBuffer = new byte[bytesAvailable];
|
||||
int bytesRead = _port.Read(tempBuffer, 0, bytesAvailable);
|
||||
buffer.AddRange(tempBuffer.Take(bytesRead));
|
||||
|
||||
// check start marker (chuẩn AF FA hoặc bản partial 4D)
|
||||
if (buffer.Count >= 3 && expectedLen == -1)
|
||||
{
|
||||
if (buffer[0] == 0xAF && buffer[1] == 0xFA)
|
||||
{
|
||||
expectedLen = buffer[3] + 6;
|
||||
}
|
||||
else if (buffer[0] == 0x4D)
|
||||
{
|
||||
// frame thiếu AF FA -> vẫn tính chiều dài như thường
|
||||
expectedLen = buffer[2] + 5; // vì mất 2 byte start
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedLen > 0 && buffer.Count >= expectedLen)
|
||||
{
|
||||
if (buffer[^2] == 0xAF && buffer[^1] == 0xA0)
|
||||
{
|
||||
return [.. buffer];
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer.Clear();
|
||||
expectedLen = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(2);
|
||||
}
|
||||
}
|
||||
|
||||
// hết thời gian chờ
|
||||
if (buffer.Count > 0)
|
||||
_logger.LogWarning("[TadaRs485Client] Timeout / partial frame ({buffer.Count} bytes): {frame}", buffer.Count, ToHex([.. buffer], buffer.Count));
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Read error: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, double>? ReadResponse()
|
||||
{
|
||||
var frame = ReadFrame();
|
||||
if (frame == null || frame.Length < 9)
|
||||
{
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Communication lost (too many failed reads).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (frame[0] == 0x4D)
|
||||
{
|
||||
// Partial frame - fix it by prepending AF FA
|
||||
var newFrame = new byte[frame.Length + 1];
|
||||
newFrame[0] = 0xAF; newFrame[1] = 0xFA;
|
||||
Array.Copy(frame, 1, newFrame, 2, frame.Length - 1);
|
||||
frame = newFrame;
|
||||
}
|
||||
else if (frame[0] == 0xAF && frame.Length > 1 && frame[1] == 0xFA)
|
||||
{
|
||||
// Valid full frame - continue processing
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid frame format
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Communication lost (invalid frame format).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (frame[^2] != 0xAF || frame[^1] != 0xA0)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Footer mismatch");
|
||||
throw new Exception("invalid frame: footer");
|
||||
}
|
||||
if (frame[4] != 0x03)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Command code mismatch");
|
||||
throw new Exception("invalid frame: command");
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, double>();
|
||||
int dataLen = frame[3] - 3;
|
||||
int dataStart = 6;
|
||||
|
||||
for (int i = 0; i + 1 < dataLen; i += 2)
|
||||
{
|
||||
int idx = dataStart + i;
|
||||
if (idx + 1 >= frame.Length) break;
|
||||
|
||||
ushort raw = (ushort)((frame[idx] << 8) | frame[idx + 1]);
|
||||
int index = i / 2;
|
||||
|
||||
switch (index)
|
||||
{
|
||||
case 0: result["Voltage"] = raw / 100.0; break;
|
||||
case 1: result["Current"] = (short)raw / 10.0; break;
|
||||
case 2: result["SOC"] = raw; break;
|
||||
case 3: result["Status"] = raw; break;
|
||||
case 4: result["ChargeTime"] = raw; break;
|
||||
case 5: result["DischargeTime"] = raw; break;
|
||||
case 6: result["Temp"] = (short)raw / 10.0; break;
|
||||
case 7: result["SOH"] = raw; break;
|
||||
case 8: result["RemainCapacity"] = raw / 100.0; break;
|
||||
case 9: result["RemainEnergy"] = raw / 10.0; break;
|
||||
}
|
||||
}
|
||||
|
||||
_lastDataTime = DateTime.Now; // reset watchdog
|
||||
_consecutiveFails = 0; // reset fail counter
|
||||
return result;
|
||||
}
|
||||
|
||||
public void ForceReconnect()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
_port?.Close();
|
||||
}
|
||||
catch { }
|
||||
_faulted = false;
|
||||
_lastRetry = DateTime.MinValue;
|
||||
_retryDelayMs = 1000;
|
||||
EnsureConnected();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_port != null)
|
||||
{
|
||||
if (_port.IsOpen)
|
||||
{
|
||||
_port.Close();
|
||||
}
|
||||
_port.Dispose();
|
||||
_port = null!;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[TadaRs485Client] Error in Dispose: {ex.Message}", ex.Message);
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
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<VartaBattery> _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<ILoggerFactory>();
|
||||
_logger = loggerFactory.CreateLogger<VartaBattery>();
|
||||
_canOpenManager = serviceProvider.GetRequiredService<ICanOpenManager>();
|
||||
|
||||
_canInterface = connection.GetValue<string>("CanInterface")
|
||||
?? connection.GetValue<string>("Interface")
|
||||
?? "can0";
|
||||
|
||||
_readTimeoutMs = connection.GetValue<int?>("ReadTimeoutMs") ?? 200;
|
||||
_pollingIntervalMs = connection.GetValue<int?>("PollingIntervalMs") ?? 500;
|
||||
_connectionTimeoutMs = connection.GetValue<int?>("ConnectionTimeoutMs")
|
||||
?? Math.Max(5000, _pollingIntervalMs * 10);
|
||||
|
||||
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true;
|
||||
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 2000;
|
||||
MaxReconnectAttempts = connection.GetValue<int?>("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<bool> 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<string, double> 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<BatteryState> 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<double>();
|
||||
|
||||
// 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<PropertyDescription> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.CANOpen;
|
||||
using RobotNet10.CANOpen.Interfaces;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// using
|
||||
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
|
||||
|
||||
/// <summary>
|
||||
/// Varta CAN client — đọc dữ liệu pin qua CANopen PDO.
|
||||
/// Dùng SocketCAN transport có sẵn trong RobotNet10.CANOpen.
|
||||
/// Protocol (CAN 11-bit):
|
||||
/// 0x19B -> Voltage, Current
|
||||
/// 0x281 -> FetTemp, CellTemp, ChargeReqVoltage, ChargeReqCurrent
|
||||
/// 0x381 -> NominalCapacity, FullCapacity, RemainingCapacity, SOC
|
||||
/// 0x481/0x581 -> Info, Warn, Error, ChargeCtrl
|
||||
/// </summary>
|
||||
public sealed class VartaCanClient : IDisposable
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly ICanOpenManager _canOpenManager;
|
||||
private readonly string _canInterface;
|
||||
private readonly int _readTimeoutMs;
|
||||
private readonly Lock _stateLock = new();
|
||||
private readonly ConcurrentQueue<CanFrameReceivedEventArgs> _rxQueue = new();
|
||||
private readonly SemaphoreSlim _rxSignal = new(0);
|
||||
private ICanBus? _bus;
|
||||
private bool _disposed;
|
||||
private bool _isFaulted;
|
||||
|
||||
public bool IsFaulted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _isFaulted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public VartaCanClient(ILogger logger, ICanOpenManager canOpenManager, string canInterface, int readTimeoutMs = 200)
|
||||
{
|
||||
_logger = logger;
|
||||
_canOpenManager = canOpenManager;
|
||||
_canInterface = string.IsNullOrWhiteSpace(canInterface) ? "can0" : canInterface;
|
||||
_readTimeoutMs = Math.Max(10, readTimeoutMs);
|
||||
|
||||
OpenBus();
|
||||
}
|
||||
|
||||
|
||||
private void OpenBus()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isFaulted = false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Xóa bus cũ khỏi cache của CanOpenManager trước khi tạo lại,
|
||||
// tránh GetOrCreateCanBusAsync trả về bus đã chết do caching.
|
||||
_logger.LogInformation("[VartaCanClient] Removing old CAN bus from cache for {Iface}", _canInterface);
|
||||
_canOpenManager.RemoveCanBusAsync(_canInterface).GetAwaiter().GetResult();
|
||||
|
||||
var bus = _canOpenManager.GetOrCreateCanBusAsync(_canInterface).GetAwaiter().GetResult();
|
||||
_logger.LogInformation("[VartaCanClient] CAN bus recreated for {Iface}, IsConnected={IsConnected}", _canInterface, bus.IsConnected);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_bus != null)
|
||||
{
|
||||
_bus.FrameReceived -= OnFrameReceived;
|
||||
}
|
||||
|
||||
_bus = bus;
|
||||
_bus.FrameReceived += OnFrameReceived;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isFaulted = true;
|
||||
}
|
||||
// _logger.LogError(ex, "[VartaCanClient] Không thể mở SocketCAN trên interface {Iface}", _canInterface);
|
||||
}
|
||||
}
|
||||
|
||||
public void ForceReconnect()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[VartaCanClient] ForceReconnect start {Iface}", _canInterface);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_bus != null)
|
||||
{
|
||||
_bus.FrameReceived -= OnFrameReceived;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[VartaCanClient] No data in {Iface}", _canInterface);
|
||||
}
|
||||
|
||||
while (_rxQueue.TryDequeue(out _)) { }
|
||||
while (_rxSignal.Wait(0)) { }
|
||||
}
|
||||
|
||||
OpenBus();
|
||||
_logger.LogInformation("[VartaCanClient] ForceReconnect Finished, IsFaulted={IsFaulted}", _isFaulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Đọc frame mới nhất từ queue receive và decode theo protocol Varta.
|
||||
/// Drain toàn bộ queue, chỉ giữ frame mới nhất cho mỗi CAN ID để tránh trễ dữ liệu.
|
||||
/// </summary>
|
||||
public Dictionary<string, double>? ReadResponse(int maxFrames = 30)
|
||||
{
|
||||
if (_disposed || IsFaulted || _bus == null || !_bus.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nếu queue rỗng, chờ frame mới đến
|
||||
if (_rxQueue.IsEmpty)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_rxSignal.Wait(_readTimeoutMs))
|
||||
{
|
||||
return null;
|
||||
ForceReconnect();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain toàn bộ queue, chỉ giữ frame mới nhất cho mỗi CAN ID
|
||||
var latestFrames = new Dictionary<uint, CanFrameReceivedEventArgs>();
|
||||
while (_rxQueue.TryDequeue(out var frame))
|
||||
{
|
||||
latestFrames[frame.CanId] = frame;
|
||||
// Drain semaphore để khớp với số frame bị loại bỏ
|
||||
_rxSignal.Wait(0);
|
||||
}
|
||||
|
||||
if (latestFrames.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var frame in latestFrames.Values)
|
||||
{
|
||||
DecodeFrame(frame, result);
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private void OnFrameReceived(object? sender, CanFrameReceivedEventArgs e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rxQueue.Enqueue(e);
|
||||
try
|
||||
{
|
||||
_rxSignal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void DecodeFrame(CanFrameReceivedEventArgs frame, Dictionary<string, double> result)
|
||||
{
|
||||
// uint canId = frame.CanId & 0x7FFu;
|
||||
var d = frame.Data;
|
||||
|
||||
if (d == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// var canBase = canId & 0x780u;
|
||||
|
||||
switch (frame.CanId)
|
||||
{
|
||||
case 0x181: // TPDO1: 0x180 + NodeId
|
||||
{
|
||||
uint voltage = BitConverter.ToUInt32(d, 0);
|
||||
int current = BitConverter.ToInt32(d, 4);
|
||||
double volts = voltage / 1000.0;
|
||||
double amps = current / 1000.0;
|
||||
result["Voltage"] = Math.Round(volts, 1, MidpointRounding.ToZero);
|
||||
result["Current"] = Math.Round(amps, 1, MidpointRounding.ToZero);
|
||||
// Console.WriteLine($"[VartaCanClient] Received TPDO1: Voltage={volts} V, Current={amps} A, Timestamps: {DateTime.Now:HH:mm:ss.fff} s");
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x281: // TPDO2: 0x280 + NodeId
|
||||
result["FetTemp"] = BitConverter.ToInt16(d, 0) / 10.0;
|
||||
result["CellTemp"] = BitConverter.ToInt16(d, 2) / 10.0;
|
||||
result["ChargeReqVoltage"] = BitConverter.ToUInt16(d, 4) / 1000.0;
|
||||
result["ChargeReqCurrent"] = BitConverter.ToUInt16(d, 6) / 1000.0;
|
||||
// Console.WriteLine($"[VartaCanClient] Received TPDO2: FetTemp={result["FetTemp"]} °C, CellTemp={result["CellTemp"]} °C, ChargeReqVoltage={result["ChargeReqVoltage"]} V, ChargeReqCurrent={result["ChargeReqCurrent"]} A");
|
||||
break;
|
||||
|
||||
case 0x381: // TPDO3: 0x380 + NodeId
|
||||
{
|
||||
var nominal = BitConverter.ToUInt16(d, 0);
|
||||
var full = BitConverter.ToUInt16(d, 2);
|
||||
var remaining = BitConverter.ToUInt16(d, 4);
|
||||
result["NominalCapacityMah"] = nominal;
|
||||
result["FullCapacityMah"] = full;
|
||||
result["RemainingCapacityMah"] = remaining;
|
||||
result["SOC"] = full == 0 ? 0 : remaining * 100.0 / full;
|
||||
result["SOH"] = nominal == 0 ? 0 : full * 100.0 / nominal;
|
||||
// Console.WriteLine($"[VartaCanClient] Received TPDO3: Nominal={nominal} mAh, Full={full} mAh, Remaining={remaining} mAh, SOC={result["SOC"]} %, SOH={result["SOH"]} %");
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x481: // TPDO4: 0x480 + NodeId
|
||||
|
||||
case 0x581: // SDO response: 0x580 + NodeId (some firmware puts status words here)
|
||||
result["Info"] = BitConverter.ToUInt16(d, 0);
|
||||
result["Warn"] = BitConverter.ToUInt16(d, 2);
|
||||
result["Error"] = BitConverter.ToUInt16(d, 4);
|
||||
result["ChargeCtrl"] = BitConverter.ToUInt16(d, 6);
|
||||
// Console.WriteLine($"[VartaCanClient] Received TPDO4/SDO: Info={result["Info"]}, Warn={result["Warn"]}, Error={result["Error"]}, ChargeCtrl={result["ChargeCtrl"]}");
|
||||
break;
|
||||
|
||||
case 0x264:
|
||||
result["ChargeControl"] = d[0]; // byte 0: uint8
|
||||
result["SOC"] = d[1]; // byte 1: uint8, %
|
||||
// byte 2: không sử dụng
|
||||
result["ChargeVoltageRequest"] = BitConverter.ToUInt16(d, 3) / 256.0; // bytes 3-4: uint16, 1/256 V
|
||||
result["ChargeCurrentRequest"] = BitConverter.ToUInt16(d, 5) / 16.0; // bytes 5-6: uint16, 1/16 A
|
||||
result["BatteryStatus"] = d[7]; // byte 7: uint8
|
||||
// Console.WriteLine($"[VartaCanClient] Received 0x264: ChargeControl={result["ChargeControl"]}, SOC={result["SOC"]} %, ChargeVoltageRequest={result["ChargeVoltageRequest"]:F4} V, ChargeCurrentRequest={result["ChargeCurrentRequest"]:F4} A, BatteryStatus={result["BatteryStatus"]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_bus?.FrameReceived -= OnFrameReceived;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rxSignal.Dispose();
|
||||
while (_rxQueue.TryDequeue(out _)) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.CANOpen;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
|
||||
|
||||
public sealed class VartaChargerSimulatorService : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ILogger<VartaChargerSimulatorService> _logger;
|
||||
private readonly ICanOpenManager _canOpenManager;
|
||||
private readonly VartaChargerSimulatorOptions _options;
|
||||
private readonly SemaphoreSlim _controlLock = new(1, 1);
|
||||
|
||||
private Task? _runTask;
|
||||
private CancellationTokenSource? _runCts;
|
||||
private string _currentInterface = "can0";
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsRunning => _runTask is { IsCompleted: false };
|
||||
public string CurrentInterface => _currentInterface;
|
||||
|
||||
public VartaChargerSimulatorService(
|
||||
IConfiguration configuration,
|
||||
ILogger<VartaChargerSimulatorService> logger,
|
||||
ICanOpenManager canOpenManager)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_canOpenManager = canOpenManager ?? throw new ArgumentNullException(nameof(canOpenManager));
|
||||
|
||||
_options = new VartaChargerSimulatorOptions();
|
||||
configuration.GetSection("Varta:Charger59VSimulator").Bind(_options);
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.Enabled)
|
||||
{
|
||||
await StartSimulatorAsync(_options.CanInterface, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Varta Charger59V simulator is disabled at startup.");
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await StopSimulatorAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> StartSimulatorAsync(string? canInterface = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
await _controlLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_runTask is { IsCompleted: false })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_currentInterface = string.IsNullOrWhiteSpace(canInterface) ? "can0" : canInterface;
|
||||
_runCts = new CancellationTokenSource();
|
||||
_runTask = RunSimulatorAsync(_currentInterface, _runCts.Token);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_controlLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> StopSimulatorAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
Task? runTask;
|
||||
CancellationTokenSource? runCts;
|
||||
|
||||
await _controlLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_runTask is not { IsCompleted: false } || _runCts is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
runTask = _runTask;
|
||||
runCts = _runCts;
|
||||
_runTask = null;
|
||||
_runCts = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_controlLock.Release();
|
||||
}
|
||||
|
||||
runCts.Cancel();
|
||||
try
|
||||
{
|
||||
await runTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
runCts.Dispose();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RunSimulatorAsync(string canInterface, CancellationToken token)
|
||||
{
|
||||
_logger.LogInformation("Starting Varta Charger59V simulator on CAN interface {CanInterface}", canInterface);
|
||||
|
||||
try
|
||||
{
|
||||
var simulator = new Charger59V(_canOpenManager, canInterface, token);
|
||||
await simulator.RunAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Varta Charger59V simulator stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Varta Charger59V simulator crashed.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_runCts?.Cancel();
|
||||
_runCts?.Dispose();
|
||||
_controlLock.Dispose();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(VartaChargerSimulatorService));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class VartaChargerSimulatorOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string CanInterface { get; set; } = "can0";
|
||||
}
|
||||
|
||||
public sealed class VartaChargerSimulatorStartRequest
|
||||
{
|
||||
public string? CanInterface { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
using RobotNet10.CANOpen;
|
||||
using RobotNet10.CANOpen.Interfaces;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
|
||||
/// <summary>
|
||||
/// Charger Simulator cho VARTA EasyBlade 59V
|
||||
/// Máy tính đóng vai Charger, giao tiếp với pin thật qua USB-CAN adapter.
|
||||
///
|
||||
/// ── Thông số cố định (theo Technical Spec V1.8) ──────────────────────
|
||||
/// Baud rate : 250 kbit/s
|
||||
/// Charger Node ID : 100 (0x64)
|
||||
/// Max voltage : 58.8 V (4 × 12V × 1.225 cells)
|
||||
/// Max current : 25 A
|
||||
/// Heartbeat : mỗi 1000 ms → COB-ID 0x764
|
||||
/// RPDO1 : mỗi 200 ms → COB-ID 0x1E4
|
||||
/// ─────────────────────────────────────────────────────────────────────
|
||||
/// </summary>
|
||||
public class Charger59V
|
||||
{
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// ⚙️ THÔNG SỐ HARDCODE – CHỈNH TẠI ĐÂY NẾU CẦN
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
// Điện áp tối đa charger có thể cung cấp (V)
|
||||
// EasyBlade 59V: pin lithium 13S → max 54.6V, để an toàn dùng 58.8V
|
||||
private const double MAX_VOLTAGE_V = 58.8;
|
||||
|
||||
// Dòng tối đa charger có thể cung cấp (A)
|
||||
private const double MAX_CURRENT_A = 25.0;
|
||||
|
||||
// Điện áp thực đo được (báo lại pin trong RPDO1, byte 2-3)
|
||||
// Lúc chưa sạc thực thì đặt bằng Max hoặc giá trị đo thực của nguồn
|
||||
private const double ACTUAL_VOLTAGE_V = 54.0;
|
||||
|
||||
// Dòng thực đo được (báo lại pin trong RPDO1, byte 0-1)
|
||||
private const double ACTUAL_CURRENT_A = 10.0;
|
||||
|
||||
// COB-ID (không đổi theo spec)
|
||||
private const uint COB_HEARTBEAT = 0x764; // gửi
|
||||
private const uint COB_RPDO1 = 0x1E4; // gửi
|
||||
private const uint COB_SDO_TX = 0x5E4; // gửi (response về battery)
|
||||
private const uint COB_SDO_RX = 0x664; // nhận (request từ battery)
|
||||
private const uint COB_TPDO9 = 0x264; // nhận (SoC, VReq, IReq)
|
||||
private const uint COB_TPDO8 = 0x49B; // nhận (charge control status)
|
||||
|
||||
// ── Giá trị raw (Q8 = ×256, Q4 = ×16) ───────────────────────────
|
||||
private static readonly ushort RAW_MAX_VOLTAGE = (ushort)(MAX_VOLTAGE_V * 256);
|
||||
private static readonly ushort RAW_MAX_CURRENT = (ushort)(MAX_CURRENT_A * 16);
|
||||
private static readonly ushort RAW_ACT_VOLTAGE = (ushort)(ACTUAL_VOLTAGE_V * 256);
|
||||
private static readonly ushort RAW_ACT_CURRENT = (ushort)(ACTUAL_CURRENT_A * 256);
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// State
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private bool _sdoInitDone = false;
|
||||
private bool _chargeActive = false; // true sau khi set Bit12
|
||||
private bool _relayOpen = true; // true = relay mở, không có điện ra
|
||||
private bool _batteryCharging = false; // true khi pin báo đang vào trạng thái sạc
|
||||
|
||||
// Lưu lại giá trị SDO battery ghi vào charger
|
||||
private byte _batteryStatus = 0; // Object 0x6000
|
||||
private byte _chargeControl = 0; // Object 0x4200
|
||||
private ushort _voltageReqRaw = 0; // Object 0x2276
|
||||
private ushort _currentReqRaw = 0; // Object 0x6070
|
||||
|
||||
private readonly ICanOpenManager _canOpenManager;
|
||||
private readonly string _canInterface;
|
||||
private ICanBus? _can;
|
||||
private readonly CancellationToken _ct;
|
||||
private readonly ConcurrentQueue<CanFrameReceivedEventArgs> _rxQueue = new();
|
||||
private readonly SemaphoreSlim _rxSignal = new(0);
|
||||
|
||||
public Charger59V(ICanOpenManager canOpenManager, string canInterface, CancellationToken ct)
|
||||
{
|
||||
_canOpenManager = canOpenManager ?? throw new ArgumentNullException(nameof(canOpenManager));
|
||||
_canInterface = string.IsNullOrWhiteSpace(canInterface) ? "can0" : canInterface;
|
||||
_ct = ct;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
public async Task RunAsync()
|
||||
{
|
||||
_can = await _canOpenManager.GetOrCreateCanBusAsync(_canInterface, _ct);
|
||||
_can.FrameReceived += OnFrameReceived;
|
||||
|
||||
// Log($"Max Voltage : {MAX_VOLTAGE_V} V (raw Q8 = {RAW_MAX_VOLTAGE})");
|
||||
// Log($"Max Current : {MAX_CURRENT_A} A (raw Q4 = {RAW_MAX_CURRENT})");
|
||||
// Log($"Gửi Heartbeat 0x{COB_HEARTBEAT:X3} mỗi 1000ms...");
|
||||
// Log("Đang chờ pin kết nối...\n");
|
||||
|
||||
try
|
||||
{
|
||||
// Chạy song song 3 vòng lặp
|
||||
await Task.WhenAll(
|
||||
HeartbeatLoopAsync(), // gửi HB mỗi 1000ms
|
||||
ReceiveLoopAsync(), // nhận SDO + TPDO từ pin
|
||||
Rpdo1LoopAsync() // gửi RPDO1 sau khi SDO init xong
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_can.FrameReceived -= OnFrameReceived;
|
||||
while (_rxQueue.TryDequeue(out _)) { }
|
||||
while (_rxSignal.Wait(0)) { }
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// VÒNG LẶP 1 – Heartbeat (mỗi 1000ms)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private async Task HeartbeatLoopAsync()
|
||||
{
|
||||
while (!_ct.IsCancellationRequested)
|
||||
{
|
||||
// NMT Heartbeat: 1 byte [0x05] = Operational state
|
||||
SendFrame(COB_HEARTBEAT, [0x05]);
|
||||
// Dim($"♥ HB → 0x{COB_HEARTBEAT:X3}");
|
||||
await Task.Delay(1000, _ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// VÒNG LẶP 2 – Nhận frame từ pin
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private async Task ReceiveLoopAsync()
|
||||
{
|
||||
while (!_ct.IsCancellationRequested)
|
||||
{
|
||||
if (TryReceive(out var frame))
|
||||
{
|
||||
switch (frame.CanId)
|
||||
{
|
||||
case COB_SDO_RX: HandleSdo(frame.Data); break;
|
||||
case COB_TPDO9: HandleTpdo9(frame.Data); break;
|
||||
case COB_TPDO8: HandleTpdo8(frame.Data); break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(1, _ct); // yield CPU khi không có frame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// VÒNG LẶP 3 – Gửi RPDO1 (mỗi 200ms, sau khi SDO init xong)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private async Task Rpdo1LoopAsync()
|
||||
{
|
||||
// Chờ SDO init hoàn tất
|
||||
while (!_sdoInitDone && !_ct.IsCancellationRequested)
|
||||
await Task.Delay(50, _ct);
|
||||
|
||||
if (_ct.IsCancellationRequested) return;
|
||||
|
||||
// Delay 1s trước khi kích hoạt Bit12 (cho pin ổn định)
|
||||
LogOk("SDO init xong! Chờ 1s rồi bật Bit12...");
|
||||
await Task.Delay(1000, _ct);
|
||||
|
||||
// Bật relay và charge mode
|
||||
_relayOpen = false;
|
||||
_chargeActive = true;
|
||||
LogOk("==> Bit12 SET – Pin đang chuyển sang CHARGE MODE!");
|
||||
|
||||
// Gửi RPDO1 mỗi 200ms
|
||||
while (!_ct.IsCancellationRequested)
|
||||
{
|
||||
SendRpdo1();
|
||||
await Task.Delay(200, _ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// GỬI RPDO1 (COB-ID 0x1E4)
|
||||
//
|
||||
// Byte 0-1: Charging Current [1/256 A, Q8]
|
||||
// Byte 2-3: Charging Voltage [1/256 V, Q8]
|
||||
// Byte 4-5: Max avail Current [1/16 A, Q4]
|
||||
// Byte 6-7: Extended Charger Status
|
||||
// → Bit12 (0x1000) = kích hoạt charge mode
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private void SendRpdo1()
|
||||
{
|
||||
ushort extStatus = (_chargeActive && !_relayOpen)
|
||||
? (ushort)0x1000 // Bit12 set
|
||||
: (ushort)0x0000;
|
||||
|
||||
byte[] data =
|
||||
[
|
||||
(byte)(RAW_ACT_CURRENT & 0xFF), (byte)(RAW_ACT_CURRENT >> 8), // Byte 0-1
|
||||
(byte)(RAW_ACT_VOLTAGE & 0xFF), (byte)(RAW_ACT_VOLTAGE >> 8), // Byte 2-3
|
||||
(byte)(RAW_MAX_CURRENT & 0xFF), (byte)(RAW_MAX_CURRENT >> 8), // Byte 4-5
|
||||
(byte)(extStatus & 0xFF), (byte)(extStatus >> 8), // Byte 6-7
|
||||
];
|
||||
|
||||
SendFrame(COB_RPDO1, data);
|
||||
// Dim($"→ RPDO1 0x{COB_RPDO1:X3} [{string.Join(" ", data.Select(b => $"{b:X2}"))}] " +
|
||||
// $"ExtStat=0x{extStatus:X4}");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// XỬ LÝ SDO REQUEST TỪ PIN (COB-ID 0x664)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private void HandleSdo(byte[] d)
|
||||
{
|
||||
if (d.Length < 8) return;
|
||||
byte cmd = d[0];
|
||||
ushort index = (ushort)(d[1] | (d[2] << 8));
|
||||
byte sub = d[3];
|
||||
|
||||
switch (cmd)
|
||||
{
|
||||
// Pin GHI vào object của charger
|
||||
case 0x2F: // Write 1 byte
|
||||
OnWrite(index, sub, d[4], 0);
|
||||
break;
|
||||
case 0x2B: // Write 2 bytes
|
||||
OnWrite(index, sub, d[4], (ushort)(d[4] | (d[5] << 8)));
|
||||
break;
|
||||
case 0x23: // Write 4 bytes
|
||||
SdoWriteOk(index, sub); // phản hồi OK, bỏ qua giá trị
|
||||
break;
|
||||
|
||||
// Pin ĐỌC object từ charger
|
||||
case 0x40: // Read request
|
||||
OnRead(index, sub);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWrite(ushort index, byte sub, byte val8, ushort val16)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0x6000: // Battery Status
|
||||
_batteryStatus = val8;
|
||||
// Log($" [SDO] 0x6000 Battery Status ← {val8} " +
|
||||
// (val8 == 1 ? "→ Relay CLOSED (power ON)" : "→ Relay OPEN (power OFF)"));
|
||||
_relayOpen = (val8 == 0);
|
||||
break;
|
||||
|
||||
case 0x4200: // Charge Control
|
||||
_chargeControl = val8;
|
||||
// Log($" [SDO] 0x4200 Charge Control ← {val8} " +
|
||||
// (val8 == 1 ? "→ Battery READY" : "→ Battery NOT ready"));
|
||||
// ChargeControl=0 → pin báo full/lỗi → tắt relay
|
||||
if (val8 == 0 && _sdoInitDone)
|
||||
{
|
||||
LogWarn("ChargeControl=0 → TẮT RELAY (pin đầy hoặc lỗi)");
|
||||
_relayOpen = true;
|
||||
_chargeActive = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x2276: // Voltage Request
|
||||
_voltageReqRaw = val16;
|
||||
// Log($" [SDO] 0x2276 Voltage Request ← {val16 / 256.0:F3} V");
|
||||
break;
|
||||
|
||||
case 0x6070: // Current Request
|
||||
_currentReqRaw = val16;
|
||||
// Log($" [SDO] 0x6070 Current Request ← {val16 / 16.0:F3} A");
|
||||
break;
|
||||
|
||||
default:
|
||||
// Log($" [SDO] Write idx=0x{index:X4}.{sub} val=0x{val16:X4}");
|
||||
break;
|
||||
}
|
||||
SdoWriteOk(index, sub);
|
||||
CheckInitComplete();
|
||||
}
|
||||
|
||||
private void OnRead(ushort index, byte sub)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0x4208: // Max Charging Voltage
|
||||
SdoReadOk2(index, sub, RAW_MAX_VOLTAGE);
|
||||
// Log($" [SDO] 0x4208 Max Voltage → {MAX_VOLTAGE_V} V (raw=0x{RAW_MAX_VOLTAGE:X4})");
|
||||
break;
|
||||
|
||||
case 0x4212: // Max Charging Current
|
||||
SdoReadOk2(index, sub, RAW_MAX_CURRENT);
|
||||
// Log($" [SDO] 0x4212 Max Current → {MAX_CURRENT_A} A (raw=0x{RAW_MAX_CURRENT:X4})");
|
||||
break;
|
||||
|
||||
default:
|
||||
// Abort: object does not exist
|
||||
byte[] abort = [0x80,
|
||||
(byte)(index & 0xFF), (byte)(index >> 8), sub,
|
||||
0x00, 0x00, 0x02, 0x06];
|
||||
SendFrame(COB_SDO_TX, abort);
|
||||
break;
|
||||
}
|
||||
CheckInitComplete();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// XỬ LÝ TPDO9 (COB-ID 0x264) – Pin gửi mỗi 100ms
|
||||
// Byte 0: ChargeControl Byte 1: SoC
|
||||
// Byte 3-4: Volt Request Byte 5-6: Curr Request Byte 7: BattStatus
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private void HandleTpdo9(byte[] d)
|
||||
{
|
||||
if (d.Length < 7) return;
|
||||
byte cc = d[0];
|
||||
byte soc = d[1];
|
||||
ushort vReq = (ushort)(d[3] | (d[4] << 8));
|
||||
ushort iReq = (ushort)(d[5] | (d[6] << 8));
|
||||
byte bs = d.Length > 7 ? d[7] : (byte)0;
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
// Console.WriteLine(
|
||||
// $"[{Now}] 📦 PIN " +
|
||||
// $"SoC={soc,3}% " +
|
||||
// $"VReq={vReq / 256.0,6:F2}V " +
|
||||
// $"IReq={iReq / 16.0,6:F2}A " +
|
||||
// $"ChargeCtrl={cc} BattStat={bs}");
|
||||
// Console.ResetColor();
|
||||
|
||||
// Pin gửi ChargeControl=0 → pin đầy hoặc có lỗi → dừng sạc
|
||||
if (cc == 0 && _sdoInitDone && _chargeActive)
|
||||
{
|
||||
LogWarn("ChargeControl=0 → PIN ĐẦY hoặc LỖI → Tắt relay!");
|
||||
_chargeActive = false;
|
||||
_relayOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// XỬ LÝ TPDO8 (COB-ID 0x49B) – Battery Charge Control Status
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private void HandleTpdo8(byte[] d)
|
||||
{
|
||||
if (d.Length < 2) return;
|
||||
ushort s = (ushort)(d[0] | (d[1] << 8));
|
||||
bool chargingNow = s == 0xC011 || s == 0xC033;
|
||||
|
||||
if (chargingNow && !_batteryCharging)
|
||||
{
|
||||
_batteryCharging = true;
|
||||
LogOk($"✅ PIN ĐÃ VÀO TRẠNG THÁI SẠC (TPDO8=0x{s:X4})");
|
||||
}
|
||||
else if (!chargingNow && _batteryCharging)
|
||||
{
|
||||
_batteryCharging = false;
|
||||
LogWarn($"PIN THOÁT TRẠNG THÁI SẠC (TPDO8=0x{s:X4})");
|
||||
}
|
||||
|
||||
string desc = s switch
|
||||
{
|
||||
0x0033 => "SDO init OK – chờ Bit12",
|
||||
0x4033 => "Standby – chờ Bit12",
|
||||
0xC011 => "⚡ CHARGING ACTIVE",
|
||||
0xC033 => "⚡ Charging (normal)",
|
||||
0xC000 => "Pin đầy – về standby",
|
||||
0xD000 => "Keep-power hết – SHUTDOWN",
|
||||
_ => $"bits={s:X4}"
|
||||
};
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
// Console.WriteLine($"[{Now}] 📊 STATUS 0x{s:X4} → {desc}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// Kiểm tra SDO init sequence đã đủ 4 bước chưa
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private void CheckInitComplete()
|
||||
{
|
||||
if (_sdoInitDone) return;
|
||||
if (_batteryStatus == 1
|
||||
&& _chargeControl == 1
|
||||
&& _voltageReqRaw > 0
|
||||
&& _currentReqRaw > 0)
|
||||
{
|
||||
_sdoInitDone = true;
|
||||
// Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
// Console.WriteLine($"\n[{Now}] ══════════════════════════════════════");
|
||||
// Console.WriteLine($"[{Now}] ✅ SDO INITIALIZATION HOÀN TẤT!");
|
||||
// Console.WriteLine($"[{Now}] BatteryStatus={_batteryStatus} ChargeControl={_chargeControl}");
|
||||
// Console.WriteLine($"[{Now}] VoltReq={_voltageReqRaw / 256.0:F3}V CurrReq={_currentReqRaw / 16.0:F3}A");
|
||||
// Console.WriteLine($"[{Now}] ══════════════════════════════════════\n");
|
||||
// Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// SDO helpers
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private void SdoWriteOk(ushort index, byte sub)
|
||||
{
|
||||
byte[] d = [0x60, (byte)(index & 0xFF), (byte)(index >> 8), sub, 0, 0, 0, 0];
|
||||
SendFrame(COB_SDO_TX, d);
|
||||
// Dim($"← SDO OK 0x{COB_SDO_TX:X3} idx=0x{index:X4}");
|
||||
}
|
||||
|
||||
private void SdoReadOk2(ushort index, byte sub, ushort value)
|
||||
{
|
||||
byte[] d = [0x4B,
|
||||
(byte)(index & 0xFF), (byte)(index >> 8), sub,
|
||||
(byte)(value & 0xFF), (byte)(value >> 8), 0, 0];
|
||||
SendFrame(COB_SDO_TX, d);
|
||||
// Dim($"← SDO RSP 0x{COB_SDO_TX:X3} idx=0x{index:X4} val=0x{value:X4}");
|
||||
}
|
||||
|
||||
private void SendFrame(uint canId, byte[] data)
|
||||
{
|
||||
var bus = _can;
|
||||
if (bus is null || !bus.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bus.SendFrameAsync(canId, data, _ct).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private bool TryReceive(out CanFrameReceivedEventArgs frame)
|
||||
{
|
||||
if (_rxQueue.TryDequeue(out frame!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!_rxSignal.Wait(10, _ct))
|
||||
{
|
||||
frame = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
frame = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_rxQueue.TryDequeue(out frame!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
frame = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OnFrameReceived(object? sender, CanFrameReceivedEventArgs e)
|
||||
{
|
||||
_rxQueue.Enqueue(e);
|
||||
try
|
||||
{
|
||||
_rxSignal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// Logging
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
private static string Now => DateTime.Now.ToString("HH:mm:ss.fff");
|
||||
|
||||
private static void Log(string msg)
|
||||
=> Console.WriteLine($"[{Now}] {msg}");
|
||||
|
||||
private static void LogOk(string msg)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[{Now}] {msg}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
private static void LogWarn(string msg)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"[{Now}] ⚠️ {msg}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
private static void Dim(string msg)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine($"[{Now}] {msg}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
namespace RobotNet10.RobotApp.Drivers.WheeltecIMU
|
||||
{
|
||||
public class CRCTable
|
||||
{
|
||||
public static readonly byte[] CRC8Table =
|
||||
{
|
||||
0, 94, 188, 226, 97, 63, 221, 131, 194, 156, 126, 32, 163, 253, 31, 65,
|
||||
157, 195, 33, 127, 252, 162, 64, 30, 95, 1, 227, 189, 62, 96, 130, 220,
|
||||
35, 125, 159, 193, 66, 28, 254, 160, 225, 191, 93, 3, 128, 222, 60, 98,
|
||||
190, 224, 2, 92, 223, 129, 99, 61, 124, 34, 192, 158, 29, 67, 161, 255,
|
||||
70, 24, 250, 164, 39, 121, 155, 197, 132, 218, 56, 102, 229, 187, 89, 7,
|
||||
219, 133, 103, 57, 186, 228, 6, 88, 25, 71, 165, 251, 120, 38, 196, 154,
|
||||
101, 59, 217, 135, 4, 90, 184, 230, 167, 249, 27, 69, 198, 152, 122, 36,
|
||||
248, 166, 68, 26, 153, 199, 37, 123, 58, 100, 134, 216, 91, 5, 231, 185,
|
||||
140, 210, 48, 110, 237, 179, 81, 15, 78, 16, 242, 172, 47, 113, 147, 205,
|
||||
17, 79, 173, 243, 112, 46, 204, 146, 211, 141, 111, 49, 178, 236, 14, 80,
|
||||
175, 241, 19, 77, 206, 144, 114, 44, 109, 51, 209, 143, 12, 82, 176, 238,
|
||||
50, 108, 142, 208, 83, 13, 239, 177, 240, 174, 76, 18, 145, 207, 45, 115,
|
||||
202, 148, 118, 40, 171, 245, 23, 73, 8, 86, 180, 234, 105, 55, 213, 139,
|
||||
87, 9, 235, 181, 54, 104, 138, 212, 149, 203, 41, 119, 244, 170, 72, 22,
|
||||
233, 183, 85, 11, 136, 214, 52, 106, 43, 117, 151, 201, 74, 20, 246, 168,
|
||||
116, 42, 200, 150, 21, 75, 169, 247, 182, 232, 10, 84, 215, 137, 107, 53
|
||||
};
|
||||
public static readonly ushort[] CRC16Table =
|
||||
{
|
||||
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
|
||||
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
|
||||
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
|
||||
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
|
||||
0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
|
||||
0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
|
||||
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
|
||||
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
|
||||
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
|
||||
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
|
||||
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
|
||||
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
|
||||
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
|
||||
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
|
||||
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
|
||||
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
|
||||
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
|
||||
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
|
||||
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
|
||||
0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
|
||||
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
|
||||
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
|
||||
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
|
||||
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
|
||||
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
|
||||
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
|
||||
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
|
||||
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
|
||||
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
|
||||
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
|
||||
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
|
||||
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0
|
||||
};
|
||||
|
||||
public static readonly uint[] CRC32Table =
|
||||
{
|
||||
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
|
||||
0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
|
||||
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
|
||||
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
|
||||
0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de,
|
||||
0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
|
||||
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,
|
||||
0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5,
|
||||
0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
|
||||
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,
|
||||
0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940,
|
||||
0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
|
||||
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116,
|
||||
0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
|
||||
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
|
||||
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,
|
||||
0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a,
|
||||
0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
|
||||
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818,
|
||||
0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,
|
||||
0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
|
||||
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457,
|
||||
0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c,
|
||||
0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
|
||||
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2,
|
||||
0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb,
|
||||
0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
|
||||
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
|
||||
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086,
|
||||
0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
|
||||
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4,
|
||||
0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad,
|
||||
0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
|
||||
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683,
|
||||
0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,
|
||||
0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
|
||||
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe,
|
||||
0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7,
|
||||
0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
|
||||
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,
|
||||
0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252,
|
||||
0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
|
||||
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60,
|
||||
0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79,
|
||||
0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
|
||||
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f,
|
||||
0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04,
|
||||
0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
|
||||
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a,
|
||||
0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,
|
||||
0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
|
||||
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21,
|
||||
0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e,
|
||||
0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
|
||||
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c,
|
||||
0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
|
||||
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
|
||||
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db,
|
||||
0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0,
|
||||
0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
|
||||
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6,
|
||||
0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf,
|
||||
0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
|
||||
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
|
||||
};
|
||||
public static byte CRC8_Table(byte[] buffer, int counter)
|
||||
{
|
||||
return CRC8_Table(buffer.AsSpan(0, counter));
|
||||
}
|
||||
|
||||
public static byte CRC8_Table(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
byte crc8 = 0;
|
||||
foreach (byte value in buffer)
|
||||
{
|
||||
byte new_index = (byte)(crc8 ^ value);
|
||||
crc8 = CRC8Table[new_index];
|
||||
}
|
||||
return crc8;
|
||||
}
|
||||
|
||||
// Fixed CRC16_Table method - accepts byte[] and int counter parameter
|
||||
public static ushort CRC16_Table(byte[] buffer, int counter)
|
||||
{
|
||||
return CRC16_Table(buffer.AsSpan(0, counter));
|
||||
}
|
||||
|
||||
public static ushort CRC16_Table(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
ushort crc16 = 0;
|
||||
foreach (byte value in buffer)
|
||||
{
|
||||
crc16 = (ushort)(CRC16Table[((crc16 >> 8) ^ value) & 0xFF] ^ (crc16 << 8));
|
||||
}
|
||||
return crc16;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
1.Config USB
|
||||
$sudo chmod 666 /dev/ttyUSB0
|
||||
$sudo nano /etc/udev/rules.d/99-usb-serial.rules
|
||||
``SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", MODE="0666", GROUP="dialout"``
|
||||
2. Kill process robotnet10
|
||||
$ps aux | grep -i robotnet | grep -v grep
|
||||
$lsof /dev/ttyUSB0 2>&1 || echo "Port is free"
|
||||
$kill 2381 4242 8011
|
||||
$kill -9 2381 4242 8011
|
||||
$lsof /dev/ttyUSB0
|
||||
$./run.sh
|
||||
3. HTML test
|
||||
# Option A: Dùng default browser
|
||||
xdg-open /home/robotics/sonvh/odometry_comparison_test.html
|
||||
|
||||
# Option B: Dùng specific browser
|
||||
firefox /home/robotics/sonvh/odometry_comparison_test.html
|
||||
# hoặc
|
||||
google-chrome /home/robotics/sonvh/odometry_comparison_test.html
|
||||
@@ -0,0 +1,822 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.Shared;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.WheeltecIMU
|
||||
{
|
||||
[Device(DeviceType.Imu, "WheeltecN100IMU", "WheeltecN100IMU", "1.0.0", Description = "IMU Simulation Driver")]
|
||||
public class WheeltecN100IMU : DeviceBase, IInertialMeasurementUnit
|
||||
{
|
||||
private readonly WheeltecReader IMU;
|
||||
|
||||
// Cached data
|
||||
private AccelStamped _cachedAcceleration;
|
||||
private Vector3Stamped _cachedAngularVelocity;
|
||||
private Vector3Stamped _cachedMagnetometer;
|
||||
private Vector3Stamped _cachedOrientation;
|
||||
private QuaternionStamped _cachedQuaternion;
|
||||
private double _cachedTemperature = 25.0;
|
||||
private bool _isCalibrated = false;
|
||||
private DateTime _lastUpdateTime = DateTime.UtcNow;
|
||||
private double _sampleRate = 0.0;
|
||||
private readonly string _portName;
|
||||
private readonly int _timeOut;
|
||||
private readonly int _baudRate;
|
||||
|
||||
// Sample rate calculation
|
||||
private int _sampleCount = 0;
|
||||
private DateTime _sampleRateStartTime = DateTime.UtcNow;
|
||||
private readonly TimeSpan _sampleRateWindow = TimeSpan.FromSeconds(1.0);
|
||||
|
||||
// Calibration: 2 giay dau sau lan nhan du lieu dau tien de thu thap bias (robot dung yen tuyet doi)
|
||||
private static readonly TimeSpan CalibrationDuration = TimeSpan.FromSeconds(2.0);
|
||||
private static readonly TimeSpan CalibrationWaitTimeout = TimeSpan.FromSeconds(5.0);
|
||||
private const double GravityMps2 = 9.81;
|
||||
private const double GravityToleranceMps2 = 2.0;
|
||||
|
||||
// IMU outlier validation thresholds
|
||||
private const double MaxValidAccelerationMps2 = 50.0;
|
||||
private const double MaxValidAngularVelocityRadS = 20.0;
|
||||
|
||||
// Thread-safety: Lock for cached sensor data (struct assignments are NOT atomic)
|
||||
private readonly object _dataLock = new();
|
||||
|
||||
// High-precision timestamp using Stopwatch (DateTime.UtcNow has ~10-15ms precision)
|
||||
private readonly Stopwatch _highPrecisionTimer = Stopwatch.StartNew();
|
||||
private DateTime _timerStartUtc = DateTime.UtcNow;
|
||||
private DateTime? _calibrationStartUtc;
|
||||
private bool _calibrationDone;
|
||||
private TaskCompletionSource<bool>? _calibrationCompletedTcs;
|
||||
private double _calibrationSumAccX, _calibrationSumAccY, _calibrationSumAccZ;
|
||||
private double _calibrationSumGx, _calibrationSumGy, _calibrationSumGz;
|
||||
private double _calibrationSumRoll, _calibrationSumPitch, _calibrationSumYaw;
|
||||
private int _calibrationCount;
|
||||
private double _accBiasX, _accBiasY, _accBiasZ;
|
||||
private double _gyroBiasX, _gyroBiasY, _gyroBiasZ;
|
||||
private double _orientationBiasRoll, _orientationBiasPitch, _orientationBiasYaw;
|
||||
|
||||
// Yaw integration: tich phan CalibratedGz thay vi dung firmware AHRS Yaw (firmware drift ~0.009 rad/s)
|
||||
// Roll/Pitch van dung firmware AHRS vi co gravity reference (khong drift)
|
||||
private double _integratedYaw;
|
||||
private DateTime _lastIntegrationTime;
|
||||
|
||||
// Diagnostic: log drift moi 5 giay
|
||||
private DateTime _lastDiagnosticLog = DateTime.MinValue;
|
||||
private static readonly TimeSpan DiagnosticLogInterval = TimeSpan.FromSeconds(5.0);
|
||||
|
||||
// Events (interface)
|
||||
public event EventHandler<AccelerationChangedEventArgs>? AccelerationChanged;
|
||||
public event EventHandler<AngularVelocityChangedEventArgs>? AngularVelocityChanged;
|
||||
public event EventHandler<MagnetometerChangedEventArgs>? MagnetometerChanged;
|
||||
public event EventHandler<OrientationChangedEventArgs>? OrientationChanged;
|
||||
|
||||
// Event thong nhat cho SensorPipeline
|
||||
public event EventHandler<ImuDataChangedEventArgs>? ImuDataChanged;
|
||||
|
||||
public WheeltecN100IMU(string deviceId, string deviceName, IConfigurationSection connection)
|
||||
: base(deviceId, deviceName, DeviceType.Imu)
|
||||
{
|
||||
_portName = connection.GetValue<string>("Port") ?? throw new Exception("Port is required");
|
||||
_baudRate = connection.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
|
||||
_timeOut = connection.GetValue<int?>("TimeOut") ?? throw new Exception("Timeout is required");
|
||||
|
||||
IMU = new WheeltecReader(_portName, _baudRate, _timeOut);
|
||||
|
||||
// Doc cau hinh neu co
|
||||
var autoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled");
|
||||
var reconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs");
|
||||
var maxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts");
|
||||
|
||||
if (autoReconnectEnabled.HasValue)
|
||||
AutoReconnectEnabled = autoReconnectEnabled.Value;
|
||||
else
|
||||
AutoReconnectEnabled = true;
|
||||
|
||||
if (reconnectDelayMs.HasValue)
|
||||
ReconnectDelayMs = reconnectDelayMs.Value;
|
||||
|
||||
if (maxReconnectAttempts.HasValue)
|
||||
MaxReconnectAttempts = maxReconnectAttempts.Value;
|
||||
|
||||
_cachedAcceleration = CreateAccelStamped(0, 0, 9.81, DateTime.UtcNow);
|
||||
_cachedAngularVelocity = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
|
||||
_cachedMagnetometer = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
|
||||
_cachedOrientation = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
|
||||
_cachedQuaternion = CreateQuaternionStamped(1, 0, 0, 0, DateTime.UtcNow);
|
||||
|
||||
// Khoi tao gia tri properties
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
yield return new PropertyDescription("IsCalibrated", "Calibrated", "Trang thai calibrate")
|
||||
{
|
||||
DataType = "boolean",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 1,
|
||||
Category = "Trang thai",
|
||||
DefaultValue = "true"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("SampleRate", "Sample Rate (Hz)", "Tan so lay mau (Hz)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 2,
|
||||
Category = "Cau hinh",
|
||||
DefaultValue = "100"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Acceleration", "Acceleration (m/s²)", "Gia toc 3 truc (m/s²)")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 3,
|
||||
Category = "Du lieu",
|
||||
DefaultValue = "0, 0, 9.81"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("AngularVelocity", "Angular Velocity (rad/s)", "Van toc goc 3 truc (rad/s)")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 4,
|
||||
Category = "Du lieu",
|
||||
DefaultValue = "0, 0, 0"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Orientation", "Orientation (rad)", "Huong Euler angles (rad)")
|
||||
{
|
||||
DataType = "string",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 5,
|
||||
Category = "Du lieu",
|
||||
DefaultValue = "0, 0, 0"
|
||||
};
|
||||
|
||||
yield return new PropertyDescription("Temperature", "Temperature (°C)", "Nhiet do cam bien (°C)")
|
||||
{
|
||||
DataType = "number",
|
||||
IsReadOnly = true,
|
||||
DisplayOrder = 6,
|
||||
Category = "Du lieu",
|
||||
DefaultValue = "25"
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IMU.Connect();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Dam bao port da mo (sau Disconnect can Connect lai)
|
||||
if (!IMU.IsConnected)
|
||||
IMU.Connect();
|
||||
|
||||
// Reset high-precision timer for accurate timestamps
|
||||
_timerStartUtc = DateTime.UtcNow;
|
||||
_highPrecisionTimer.Restart();
|
||||
|
||||
// Reset calibration de moi lan connect thu thap lai 2 giay dau
|
||||
ResetCalibrationState();
|
||||
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
|
||||
|
||||
// Dang ky event handler cho DataReceived tu WheeltecReader
|
||||
IMU.DataReceived += IMU_DataReceived;
|
||||
|
||||
// Doi xu ly _calibrationDone (toi da CalibrationWaitTimeout), de connect chi hoan tat sau khi da calibrate
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(
|
||||
_calibrationCompletedTcs.Task,
|
||||
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// cancellationToken bi huy
|
||||
}
|
||||
_calibrationCompletedTcs = null;
|
||||
}
|
||||
|
||||
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Huy dang ky event handler
|
||||
IMU.DataReceived -= IMU_DataReceived;
|
||||
|
||||
// Disconnect IMU de dung processing thread va serial port
|
||||
IMU.Disconnect();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Huy event va disconnect
|
||||
IMU.DataReceived -= IMU_DataReceived;
|
||||
IMU.Disconnect();
|
||||
|
||||
// Reset high-precision timer
|
||||
_timerStartUtc = DateTime.UtcNow;
|
||||
_highPrecisionTimer.Restart();
|
||||
|
||||
lock (_dataLock)
|
||||
{
|
||||
_cachedAcceleration = CreateAccelStamped(0, 0, 9.81, DateTime.UtcNow);
|
||||
_cachedAngularVelocity = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
|
||||
_cachedMagnetometer = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
|
||||
_cachedOrientation = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
|
||||
_cachedQuaternion = CreateQuaternionStamped(1, 0, 0, 0, DateTime.UtcNow);
|
||||
_cachedTemperature = 25.0;
|
||||
_lastUpdateTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Reset calibration de sau khi connect lai thu thap 2 giay dau
|
||||
ResetCalibrationState();
|
||||
|
||||
// Reset sample rate
|
||||
_sampleCount = 0;
|
||||
_sampleRate = 0.0;
|
||||
_sampleRateStartTime = DateTime.UtcNow;
|
||||
|
||||
// Reconnect va bat dau calibration lai
|
||||
IMU.Connect();
|
||||
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
|
||||
IMU.DataReceived += IMU_DataReceived;
|
||||
|
||||
// Doi calibration hoan tat
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(
|
||||
_calibrationCompletedTcs.Task,
|
||||
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// cancellationToken bi huy
|
||||
}
|
||||
_calibrationCompletedTcs = null;
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(IMU.IsConnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset toan bo trang thai calibration ve gia tri ban dau
|
||||
/// </summary>
|
||||
private void ResetCalibrationState()
|
||||
{
|
||||
_calibrationStartUtc = null;
|
||||
_calibrationDone = false;
|
||||
_isCalibrated = false;
|
||||
_calibrationSumAccX = _calibrationSumAccY = _calibrationSumAccZ = 0;
|
||||
_calibrationSumGx = _calibrationSumGy = _calibrationSumGz = 0;
|
||||
_calibrationSumRoll = _calibrationSumPitch = _calibrationSumYaw = 0;
|
||||
_calibrationCount = 0;
|
||||
_accBiasX = _accBiasY = _accBiasZ = 0;
|
||||
_gyroBiasX = _gyroBiasY = _gyroBiasZ = 0;
|
||||
_orientationBiasRoll = _orientationBiasPitch = _orientationBiasYaw = 0;
|
||||
_integratedYaw = 0;
|
||||
_lastIntegrationTime = DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event handler cho DataReceived tu WheeltecReader.
|
||||
/// 2 giay dau ke tu lan nhan du lieu dau tien: chi thu thap mau de tinh bias (robot dung yen tuyet doi).
|
||||
/// Sau 2 giay: ap dung bias de calibrate, roi moi cap nhat _cached*, fire events, _sampleCount.
|
||||
/// </summary>
|
||||
private void IMU_DataReceived(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = IMU.GetSnapshot();
|
||||
// Use high-precision timer instead of DateTime.UtcNow (which has ~10-15ms precision)
|
||||
var timestamp = _timerStartUtc + _highPrecisionTimer.Elapsed;
|
||||
|
||||
// Bat dau cua so calibration khi nhan du lieu lan dau
|
||||
if (_calibrationStartUtc == null)
|
||||
{
|
||||
_calibrationStartUtc = timestamp;
|
||||
_calibrationSumAccX = _calibrationSumAccY = _calibrationSumAccZ = 0;
|
||||
_calibrationSumGx = _calibrationSumGy = _calibrationSumGz = 0;
|
||||
_calibrationSumRoll = _calibrationSumPitch = _calibrationSumYaw = 0;
|
||||
_calibrationCount = 0;
|
||||
}
|
||||
|
||||
if (!_calibrationDone)
|
||||
{
|
||||
var calElapsed = timestamp - _calibrationStartUtc.Value;
|
||||
if (calElapsed < CalibrationDuration)
|
||||
{
|
||||
// Trong 2 giay dau: chi tich luy mau, khong cap nhat cache / fire events / sampleCount
|
||||
_calibrationSumAccX += snapshot.AccX;
|
||||
_calibrationSumAccY += snapshot.AccY;
|
||||
_calibrationSumAccZ += snapshot.AccZ;
|
||||
_calibrationSumGx += snapshot.Gx;
|
||||
_calibrationSumGy += snapshot.Gy;
|
||||
_calibrationSumGz += snapshot.Gz;
|
||||
_calibrationSumRoll += snapshot.Roll;
|
||||
_calibrationSumPitch += snapshot.Pitch;
|
||||
_calibrationSumYaw += snapshot.Yaw;
|
||||
_calibrationCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Het 2 giay: tinh bias va danh dau da calibrate
|
||||
if (_calibrationCount > 0)
|
||||
{
|
||||
double n = _calibrationCount;
|
||||
double meanAccX = _calibrationSumAccX / n;
|
||||
double meanAccY = _calibrationSumAccY / n;
|
||||
double meanAccZ = _calibrationSumAccZ / n;
|
||||
|
||||
// Validate gravity magnitude — neu lech qua xa 9.81 thi robot bi rung/di chuyen
|
||||
double gravityMagnitude = Math.Sqrt(meanAccX * meanAccX + meanAccY * meanAccY + meanAccZ * meanAccZ);
|
||||
if (Math.Abs(gravityMagnitude - GravityMps2) > GravityToleranceMps2)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecN100IMU] Calibration REJECTED: " +
|
||||
$"GravityMag={gravityMagnitude:F4} (expected ~{GravityMps2}, tolerance ±{GravityToleranceMps2}). " +
|
||||
$"Robot co the dang rung/di chuyen. Thu lai...");
|
||||
_calibrationStartUtc = null;
|
||||
_calibrationCount = 0;
|
||||
_calibrationSumAccX = _calibrationSumAccY = _calibrationSumAccZ = 0;
|
||||
_calibrationSumGx = _calibrationSumGy = _calibrationSumGz = 0;
|
||||
_calibrationSumRoll = _calibrationSumPitch = _calibrationSumYaw = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXED: Khong tru gravity khoi acceleration data!
|
||||
// ImuTracker trong Cartographer CAN gravity de estimate orientation.
|
||||
// Khi dung yen (Z up): acc ~ (0, 0, +9.81) — day la luc phan ung tu mat dat
|
||||
//
|
||||
// Chi calibrate bias nho (sensor offset) cho X va Y.
|
||||
// Voi Z: tinh bias = meanAccZ - expected_gravity
|
||||
double expectedGravityZ = meanAccZ > 0 ? GravityMps2 : -GravityMps2;
|
||||
|
||||
// Bias X,Y: offset khi dung yen (nen ~ 0 neu robot dat phang)
|
||||
_accBiasX = meanAccX;
|
||||
_accBiasY = meanAccY;
|
||||
// Bias Z: chi tru phan offset, GIU NGUYEN gravity
|
||||
_accBiasZ = meanAccZ - expectedGravityZ;
|
||||
|
||||
// Gyro bias: dung — khi dung yen angular velocity = 0
|
||||
_gyroBiasX = _calibrationSumGx / n;
|
||||
_gyroBiasY = _calibrationSumGy / n;
|
||||
_gyroBiasZ = _calibrationSumGz / n;
|
||||
|
||||
// Orientation bias: giu lai cho display purposes
|
||||
_orientationBiasRoll = _calibrationSumRoll / n;
|
||||
_orientationBiasPitch = _calibrationSumPitch / n;
|
||||
_orientationBiasYaw = _calibrationSumYaw / n;
|
||||
|
||||
_isCalibrated = true;
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecN100IMU] Calibration done (n={_calibrationCount}): " +
|
||||
$"MeanAcc=({meanAccX:F4},{meanAccY:F4},{meanAccZ:F4}), GravityMag={gravityMagnitude:F4}, " +
|
||||
$"AccBias=({_accBiasX:F4},{_accBiasY:F4},{_accBiasZ:F4}), " +
|
||||
$"GyroBias=({_gyroBiasX:F6},{_gyroBiasY:F6},{_gyroBiasZ:F6})");
|
||||
}
|
||||
_calibrationDone = true;
|
||||
_calibrationCompletedTcs?.TrySetResult(true);
|
||||
_calibrationCompletedTcs = null;
|
||||
// Khoi tao yaw integration tu thoi diem calibration xong
|
||||
_integratedYaw = 0;
|
||||
_lastIntegrationTime = timestamp;
|
||||
// Bat dau dem sample rate tu sau calibration
|
||||
_sampleRateStartTime = timestamp;
|
||||
_sampleCount = 0;
|
||||
}
|
||||
|
||||
// Ap dung bias: du lieu da calibrate (sau 2 giay moi chay toi day)
|
||||
double accX = snapshot.AccX - _accBiasX;
|
||||
double accY = snapshot.AccY - _accBiasY;
|
||||
double accZ = snapshot.AccZ - _accBiasZ;
|
||||
double gx = snapshot.Gx - _gyroBiasX;
|
||||
double gy = snapshot.Gy - _gyroBiasY;
|
||||
double gz = snapshot.Gz - _gyroBiasZ;
|
||||
double roll = snapshot.Roll - _orientationBiasRoll;
|
||||
double pitch = snapshot.Pitch - _orientationBiasPitch;
|
||||
|
||||
// Tich phan CalibratedGz de tinh Yaw thay vi dung firmware AHRS Yaw
|
||||
// Firmware AHRS Yaw drift ~0.009 rad/s do tich phan gyro noi bo khong chinh xac
|
||||
// CalibratedGz sau khi tru bias chi con ~0.00006 rad/s trung binh → giam drift 150 lan
|
||||
double yaw;
|
||||
if (_lastIntegrationTime != DateTime.MinValue)
|
||||
{
|
||||
double dt = (timestamp - _lastIntegrationTime).TotalSeconds;
|
||||
_integratedYaw += gz * dt;
|
||||
yaw = _integratedYaw;
|
||||
}
|
||||
else
|
||||
{
|
||||
yaw = 0;
|
||||
}
|
||||
_lastIntegrationTime = timestamp;
|
||||
|
||||
// Diagnostic log moi 5 giay: theo doi drift
|
||||
if (timestamp - _lastDiagnosticLog >= DiagnosticLogInterval)
|
||||
{
|
||||
_lastDiagnosticLog = timestamp;
|
||||
var elapsedSec = (timestamp - _timerStartUtc).TotalSeconds;
|
||||
double firmwareYaw = snapshot.Yaw - _orientationBiasYaw;
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [IMU_DIAG] t={elapsedSec:F1}s | " +
|
||||
$"CalibratedGz={gz:F6} | " +
|
||||
$"IntegratedYaw={yaw:F6} FirmwareYaw={firmwareYaw:F6} | " +
|
||||
$"Temp={snapshot.Temp:F2}");
|
||||
}
|
||||
|
||||
// Outlier validation: reject data with unrealistic values
|
||||
// This prevents ImuTracker corruption from EMI spikes or communication errors
|
||||
double accMagnitude = Math.Sqrt(accX * accX + accY * accY + accZ * accZ);
|
||||
double gyroMagnitude = Math.Sqrt(gx * gx + gy * gy + gz * gz);
|
||||
if (accMagnitude > MaxValidAccelerationMps2 || gyroMagnitude > MaxValidAngularVelocityRadS)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecN100IMU] OUTLIER REJECTED: " +
|
||||
$"accMag={accMagnitude:F2} m/s² (max={MaxValidAccelerationMps2}), " +
|
||||
$"gyroMag={gyroMagnitude:F2} rad/s (max={MaxValidAngularVelocityRadS})");
|
||||
return;
|
||||
}
|
||||
|
||||
// Quaternion tu goc Euler da calibrate
|
||||
var cosRoll = Math.Cos(roll / 2);
|
||||
var sinRoll = Math.Sin(roll / 2);
|
||||
var cosPitch = Math.Cos(pitch / 2);
|
||||
var sinPitch = Math.Sin(pitch / 2);
|
||||
var cosYaw = Math.Cos(yaw / 2);
|
||||
var sinYaw = Math.Sin(yaw / 2);
|
||||
var qw = cosRoll * cosPitch * cosYaw + sinRoll * sinPitch * sinYaw;
|
||||
var qx = sinRoll * cosPitch * cosYaw - cosRoll * sinPitch * sinYaw;
|
||||
var qy = cosRoll * sinPitch * cosYaw + sinRoll * cosPitch * sinYaw;
|
||||
var qz = cosRoll * cosPitch * sinYaw - sinRoll * sinPitch * cosYaw;
|
||||
|
||||
var newAcceleration = CreateAccelStamped(accX, accY, accZ, timestamp);
|
||||
var newAngularVelocity = CreateVector3Stamped(gx, gy, gz, timestamp);
|
||||
var newMagnetometer = CreateVector3Stamped(snapshot.MagX, snapshot.MagY, snapshot.MagZ, timestamp);
|
||||
var newOrientation = CreateVector3Stamped(roll, pitch, yaw, timestamp);
|
||||
var newQuaternion = CreateQuaternionStamped(qw, qx, qy, qz, timestamp);
|
||||
|
||||
AccelStamped previousAcceleration;
|
||||
Vector3Stamped previousOrientation;
|
||||
|
||||
// Thread-safe update of cached data using lock
|
||||
// Struct assignments are NOT atomic — without lock, readers could get partially updated data
|
||||
lock (_dataLock)
|
||||
{
|
||||
previousAcceleration = _cachedAcceleration;
|
||||
previousOrientation = _cachedOrientation;
|
||||
|
||||
_cachedAcceleration = newAcceleration;
|
||||
_cachedAngularVelocity = newAngularVelocity;
|
||||
_cachedMagnetometer = newMagnetometer;
|
||||
_cachedOrientation = newOrientation;
|
||||
_cachedQuaternion = newQuaternion;
|
||||
_cachedTemperature = snapshot.Temp;
|
||||
_lastUpdateTime = timestamp;
|
||||
}
|
||||
|
||||
// Fire unified event (SensorPipeline)
|
||||
ImuDataChanged?.Invoke(this, new ImuDataChangedEventArgs(
|
||||
newAcceleration,
|
||||
newAngularVelocity,
|
||||
newMagnetometer,
|
||||
newOrientation,
|
||||
timestamp));
|
||||
|
||||
// Fire individual events (interface — XlocIntegrationService, etc.)
|
||||
if (Math.Abs(previousAcceleration.Accel.Linear.X - newAcceleration.Accel.Linear.X) > 0.1 ||
|
||||
Math.Abs(previousAcceleration.Accel.Linear.Y - newAcceleration.Accel.Linear.Y) > 0.1 ||
|
||||
Math.Abs(previousAcceleration.Accel.Linear.Z - newAcceleration.Accel.Linear.Z) > 0.1)
|
||||
{
|
||||
AccelerationChanged?.Invoke(this, new AccelerationChangedEventArgs(newAcceleration));
|
||||
}
|
||||
|
||||
// Xloc requires a continuous IMU stream even when the robot is stationary.
|
||||
// Emit angular velocity updates every sample instead of threshold-based changes.
|
||||
AngularVelocityChanged?.Invoke(this, new AngularVelocityChangedEventArgs(newAngularVelocity));
|
||||
|
||||
MagnetometerChanged?.Invoke(this, new MagnetometerChangedEventArgs(newMagnetometer));
|
||||
|
||||
if (Math.Abs(previousOrientation.Vector.X - newOrientation.Vector.X) > 0.01 ||
|
||||
Math.Abs(previousOrientation.Vector.Y - newOrientation.Vector.Y) > 0.01 ||
|
||||
Math.Abs(previousOrientation.Vector.Z - newOrientation.Vector.Z) > 0.01)
|
||||
{
|
||||
OrientationChanged?.Invoke(this, new OrientationChangedEventArgs(newOrientation));
|
||||
}
|
||||
|
||||
_sampleCount++;
|
||||
var elapsed = timestamp - _sampleRateStartTime;
|
||||
if (elapsed >= _sampleRateWindow)
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
_sampleRate = _sampleCount / elapsed.TotalSeconds;
|
||||
_sampleCount = 0;
|
||||
_sampleRateStartTime = timestamp;
|
||||
UpdateProperties();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "Error updating data from IMU");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cap nhat properties hien thi
|
||||
/// </summary>
|
||||
private void UpdateProperties()
|
||||
{
|
||||
var accel = _cachedAcceleration;
|
||||
var angularVel = _cachedAngularVelocity;
|
||||
var orientation = _cachedOrientation;
|
||||
var temp = _cachedTemperature;
|
||||
var sampleRate = _sampleRate;
|
||||
var isCalibrated = _isCalibrated;
|
||||
|
||||
SetProperty("IsCalibrated", isCalibrated.ToString());
|
||||
SetProperty("SampleRate", sampleRate.ToString("F1"));
|
||||
SetProperty("Acceleration", $"{accel.Accel.Linear.X:F2}, {accel.Accel.Linear.Y:F2}, {accel.Accel.Linear.Z:F2}");
|
||||
SetProperty("AngularVelocity", $"{angularVel.Vector.X:F3}, {angularVel.Vector.Y:F3}, {angularVel.Vector.Z:F3}");
|
||||
SetProperty("Orientation", $"{orientation.Vector.X:F3}, {orientation.Vector.Y:F3}, {orientation.Vector.Z:F3}");
|
||||
SetProperty("Temperature", temp.ToString("F2"));
|
||||
}
|
||||
|
||||
private static AccelStamped CreateAccelStamped(double x, double y, double z, DateTime timestamp)
|
||||
{
|
||||
return new AccelStamped
|
||||
{
|
||||
Header = new Header
|
||||
{
|
||||
Stamp = timestamp,
|
||||
FrameId = "imu_frame"
|
||||
},
|
||||
Accel = new Accel
|
||||
{
|
||||
Linear = new Vector3(x, y, z),
|
||||
Angular = new Vector3(0, 0, 0)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3Stamped CreateVector3Stamped(double x, double y, double z, DateTime timestamp)
|
||||
{
|
||||
return new Vector3Stamped
|
||||
{
|
||||
Header = new Header
|
||||
{
|
||||
Stamp = timestamp,
|
||||
FrameId = "imu_frame"
|
||||
},
|
||||
Vector = new Vector3(x, y, z)
|
||||
};
|
||||
}
|
||||
|
||||
private static QuaternionStamped CreateQuaternionStamped(double w, double x, double y, double z, DateTime timestamp)
|
||||
{
|
||||
return new QuaternionStamped
|
||||
{
|
||||
Header = new Header
|
||||
{
|
||||
Stamp = timestamp,
|
||||
FrameId = "imu_frame"
|
||||
},
|
||||
Quaternion = new Quaternion(x, y, z, w)
|
||||
};
|
||||
}
|
||||
|
||||
#region IInertialMeasurementUnit Implementation
|
||||
|
||||
bool IInertialMeasurementUnit.IsConnected => base.IsConnected;
|
||||
|
||||
public bool IsCalibrated
|
||||
{
|
||||
get { return _isCalibrated; }
|
||||
}
|
||||
|
||||
public double SampleRate
|
||||
{
|
||||
get
|
||||
{
|
||||
Thread.MemoryBarrier();
|
||||
return _sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
public AccelStamped CachedAcceleration
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedAcceleration; } }
|
||||
}
|
||||
|
||||
public Vector3Stamped CachedAngularVelocity
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedAngularVelocity; } }
|
||||
}
|
||||
|
||||
public Vector3Stamped? CachedMagnetometer
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedMagnetometer; } }
|
||||
}
|
||||
|
||||
public Vector3Stamped CachedOrientation
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedOrientation; } }
|
||||
}
|
||||
|
||||
public QuaternionStamped? CachedQuaternion
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedQuaternion; } }
|
||||
}
|
||||
|
||||
public double? CachedTemperature
|
||||
{
|
||||
get { lock (_dataLock) { return _cachedTemperature; } }
|
||||
}
|
||||
|
||||
DateTime IInertialMeasurementUnit.LastUpdateTime
|
||||
{
|
||||
get { lock (_dataLock) { return _lastUpdateTime; } }
|
||||
}
|
||||
|
||||
public async Task<AccelStamped> ReadAccelerationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return CachedAcceleration;
|
||||
}
|
||||
|
||||
public async Task<Vector3Stamped> ReadAngularVelocityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return CachedAngularVelocity;
|
||||
}
|
||||
|
||||
public async Task<Vector3Stamped?> ReadMagnetometerAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return CachedMagnetometer;
|
||||
}
|
||||
|
||||
public async Task<Vector3Stamped> ReadOrientationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return CachedOrientation;
|
||||
}
|
||||
|
||||
public async Task<QuaternionStamped?> ReadQuaternionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return CachedQuaternion;
|
||||
}
|
||||
|
||||
public async Task<Imu> ReadAllDataAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
lock (_dataLock)
|
||||
{
|
||||
return CreateImuFromCachedData();
|
||||
}
|
||||
}
|
||||
|
||||
private Imu CreateImuFromCachedData()
|
||||
{
|
||||
var timestamp = _lastUpdateTime != default ? _lastUpdateTime : DateTime.UtcNow;
|
||||
|
||||
var orientation = _cachedQuaternion.Quaternion;
|
||||
|
||||
var orientationCovariance = new double[Imu.OrientationCovarianceSize];
|
||||
var angularVelocityCovariance = new double[Imu.AngularVelocityCovarianceSize];
|
||||
var linearAccelerationCovariance = new double[Imu.LinearAccelerationCovarianceSize];
|
||||
|
||||
double gyroVariance = 1e-4;
|
||||
angularVelocityCovariance[0] = gyroVariance;
|
||||
angularVelocityCovariance[4] = gyroVariance;
|
||||
angularVelocityCovariance[8] = gyroVariance;
|
||||
|
||||
double accelVariance = 1e-3;
|
||||
linearAccelerationCovariance[0] = accelVariance;
|
||||
linearAccelerationCovariance[4] = accelVariance;
|
||||
linearAccelerationCovariance[8] = accelVariance;
|
||||
|
||||
orientationCovariance[0] = 0.001;
|
||||
orientationCovariance[4] = 0.001;
|
||||
orientationCovariance[8] = 0.002;
|
||||
|
||||
return new Imu(
|
||||
header: new Header
|
||||
{
|
||||
Stamp = timestamp,
|
||||
FrameId = "imu_frame"
|
||||
},
|
||||
orientation: orientation,
|
||||
orientationCovariance: orientationCovariance,
|
||||
angularVelocity: _cachedAngularVelocity.Vector,
|
||||
angularVelocityCovariance: angularVelocityCovariance,
|
||||
linearAcceleration: _cachedAcceleration.Accel.Linear,
|
||||
linearAccelerationCovariance: linearAccelerationCovariance
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<double?> ReadTemperatureAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return CachedTemperature;
|
||||
}
|
||||
|
||||
public async Task CalibrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Huy event, reset calibration, dang ky lai event va doi calib
|
||||
IMU.DataReceived -= IMU_DataReceived;
|
||||
ResetCalibrationState();
|
||||
|
||||
_timerStartUtc = DateTime.UtcNow;
|
||||
_highPrecisionTimer.Restart();
|
||||
|
||||
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
|
||||
IMU.DataReceived += IMU_DataReceived;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(
|
||||
_calibrationCompletedTcs.Task,
|
||||
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// cancellationToken bi huy
|
||||
}
|
||||
_calibrationCompletedTcs = null;
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
public async Task CalibrateMagnetometerAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(2000, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResetCalibrationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Huy event, reset state, dang ky lai va doi calib moi
|
||||
IMU.DataReceived -= IMU_DataReceived;
|
||||
ResetCalibrationState();
|
||||
|
||||
_timerStartUtc = DateTime.UtcNow;
|
||||
_highPrecisionTimer.Restart();
|
||||
|
||||
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
|
||||
IMU.DataReceived += IMU_DataReceived;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(
|
||||
_calibrationCompletedTcs.Task,
|
||||
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// cancellationToken bi huy
|
||||
}
|
||||
_calibrationCompletedTcs = null;
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
public async Task SetSampleRateAsync(double sampleRate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
_sampleRate = Math.Max(1, Math.Min(1000, sampleRate));
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
public async Task SetAccelerometerRangeAsync(double range, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SetGyroscopeRangeAsync(double range, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
IMU.DataReceived -= IMU_DataReceived;
|
||||
IMU.Disconnect();
|
||||
IMU.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Ports;
|
||||
using System.Threading;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.WheeltecIMU
|
||||
{
|
||||
public class WheeltecReader : IDisposable
|
||||
{
|
||||
// Buffer nho de tich luy du lieu cho den khi co du mot frame hoan chinh
|
||||
// Frame lon nhat: INSGPS = 8 + 84 = 92 bytes, dung 256 bytes de dam bao an toan
|
||||
private const int FRAME_BUFFER_SIZE = 256;
|
||||
private readonly byte[] _frameBuffer = new byte[FRAME_BUFFER_SIZE];
|
||||
private int _frameBufferLength = 0;
|
||||
|
||||
// Non-volatile field de dung voi Volatile.Write lam memory barrier
|
||||
private int _memoryBarrier = 0;
|
||||
|
||||
// Thread doc du lieu tu SerialPort voi priority cao
|
||||
private Thread? _readingThread;
|
||||
private volatile bool _shouldRead = false;
|
||||
private CancellationTokenSource? _readingCts;
|
||||
|
||||
// Event de thong bao khi co du lieu moi duoc decode
|
||||
public event EventHandler? DataReceived;
|
||||
|
||||
// Properties - lock-free voi memory barriers (volatile khong ho tro double)
|
||||
public double Roll { get; private set; }
|
||||
public double Pitch { get; private set; }
|
||||
public double Yaw { get; private set; }
|
||||
public uint Time_stamp { get; private set; }
|
||||
|
||||
public double Gx { get; private set; }
|
||||
public double Gy { get; private set; }
|
||||
public double Gz { get; private set; }
|
||||
|
||||
public double AccX { get; private set; }
|
||||
public double AccY { get; private set; }
|
||||
public double AccZ { get; private set; }
|
||||
|
||||
public double MagX { get; private set; }
|
||||
public double MagY { get; private set; }
|
||||
public double MagZ { get; private set; }
|
||||
|
||||
public double Temp { get; private set; }
|
||||
|
||||
public double Rollspeed { get; private set; }
|
||||
public double Pitchspeed { get; private set; }
|
||||
public double Yawspeed { get; private set; }
|
||||
|
||||
const byte FRAME_HEAD = 0xFC;
|
||||
const byte FRAME_END = 0xFD;
|
||||
|
||||
// Loai goi
|
||||
const byte TYPE_IMU = 0x40;
|
||||
const byte TYPE_AHRS = 0x41;
|
||||
const byte TYPE_INSGPS = 0x42;
|
||||
const byte TYPE_GROUND = 0xF0;
|
||||
|
||||
// Chieu dai payload
|
||||
const byte IMU_LEN = 0x38; // 56
|
||||
const byte AHRS_LEN = 0x30; // 48
|
||||
const byte INSGPS_LEN = 0x54; // 84
|
||||
|
||||
// Dictionary de map datatype -> expectedLength
|
||||
private static readonly Dictionary<byte, byte> DataTypeLengthMap = new()
|
||||
{
|
||||
{ TYPE_IMU, IMU_LEN },
|
||||
{ TYPE_AHRS, AHRS_LEN },
|
||||
{ TYPE_INSGPS, INSGPS_LEN }
|
||||
};
|
||||
|
||||
private SerialPort? serial;
|
||||
|
||||
// Luu thong so port de tao lai SerialPort khi reconnect
|
||||
private readonly string _portName;
|
||||
private readonly int _baudRate;
|
||||
private readonly int _timeOut;
|
||||
|
||||
// Thoi gian backoff giua cac lan thu reconnect (ms)
|
||||
private const int RECONNECT_BACKOFF_MS = 1000;
|
||||
|
||||
// Watchdog: neu qua khoang thoi gian nay khong co frame hop le -> coi nhu mat ket noi
|
||||
// va trigger reconnect. Dung cho truong hop USB "chet mem" khong ne'm exception.
|
||||
private const long DATA_TIMEOUT_TICKS = 3 * TimeSpan.TicksPerSecond;
|
||||
private long _lastFrameTicks;
|
||||
|
||||
public bool IsConnected => serial != null && serial.IsOpen;
|
||||
|
||||
public WheeltecReader(string portName, int baudRate, int timeOut)
|
||||
{
|
||||
_portName = portName;
|
||||
_baudRate = baudRate;
|
||||
_timeOut = timeOut;
|
||||
|
||||
serial = CreateSerialPort();
|
||||
}
|
||||
|
||||
private SerialPort CreateSerialPort()
|
||||
{
|
||||
return new SerialPort()
|
||||
{
|
||||
PortName = _portName,
|
||||
BaudRate = _baudRate,
|
||||
ReadTimeout = _timeOut,
|
||||
Parity = Parity.None,
|
||||
StopBits = StopBits.One,
|
||||
DataBits = 8,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot structure de lay tat ca du lieu cung luc mot cach thread-safe
|
||||
/// </summary>
|
||||
public struct DataSnapshot
|
||||
{
|
||||
public double AccX, AccY, AccZ;
|
||||
public double Gx, Gy, Gz;
|
||||
public double MagX, MagY, MagZ;
|
||||
public double Roll, Pitch, Yaw;
|
||||
public double Temp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lay snapshot cua tat ca du lieu hien tai mot cach thread-safe
|
||||
/// Dam bao tat ca cac gia tri deu tu cung mot thoi diem
|
||||
/// </summary>
|
||||
public DataSnapshot GetSnapshot()
|
||||
{
|
||||
Volatile.Read(ref _memoryBarrier);
|
||||
return new DataSnapshot
|
||||
{
|
||||
AccX = AccX,
|
||||
AccY = AccY,
|
||||
AccZ = AccZ,
|
||||
Gx = Gx,
|
||||
Gy = Gy,
|
||||
Gz = Gz,
|
||||
MagX = MagX,
|
||||
MagY = MagY,
|
||||
MagZ = MagZ,
|
||||
Roll = Roll,
|
||||
Pitch = Pitch,
|
||||
Yaw = Yaw,
|
||||
Temp = Temp
|
||||
};
|
||||
}
|
||||
|
||||
public void Connect()
|
||||
{
|
||||
// Reset frame buffer khi ket noi moi
|
||||
_frameBufferLength = 0;
|
||||
Array.Clear(_frameBuffer, 0, FRAME_BUFFER_SIZE);
|
||||
|
||||
// Reading thread se tu mo port trong outer loop va tu reconnect khi mat ket noi
|
||||
StartReadingThread();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Khoi dong reading thread voi priority cao de doc du lieu tu SerialPort
|
||||
/// </summary>
|
||||
private void StartReadingThread()
|
||||
{
|
||||
if (_readingThread != null && _readingThread.IsAlive)
|
||||
return;
|
||||
|
||||
_shouldRead = true;
|
||||
|
||||
// Tao moi CancellationTokenSource cho thread moi
|
||||
_readingCts?.Dispose();
|
||||
_readingCts = new CancellationTokenSource();
|
||||
|
||||
_readingThread = new Thread(() => ReadingThreadLoop(_readingCts.Token))
|
||||
{
|
||||
Name = "WheeltecIMU-Reading",
|
||||
IsBackground = false,
|
||||
Priority = ThreadPriority.Highest
|
||||
};
|
||||
|
||||
_readingThread.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dung reading thread
|
||||
/// </summary>
|
||||
private void StopReadingThread()
|
||||
{
|
||||
_shouldRead = false;
|
||||
|
||||
_readingCts?.Cancel();
|
||||
|
||||
if (_readingThread != null)
|
||||
{
|
||||
if (!_readingThread.Join(1000))
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Reading thread did not stop gracefully");
|
||||
}
|
||||
_readingThread = null;
|
||||
}
|
||||
|
||||
// Dispose CancellationTokenSource sau khi thread da dung
|
||||
_readingCts?.Dispose();
|
||||
_readingCts = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reading thread loop - outer loop xu ly reconnect, inner loop doc du lieu
|
||||
/// Moi exception tu SerialPort deu duoc bat de tranh crash thread va tu dong
|
||||
/// reconnect sau RECONNECT_BACKOFF_MS
|
||||
/// </summary>
|
||||
private void ReadingThreadLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
Thread.BeginThreadAffinity();
|
||||
byte[] readBuffer = new byte[256];
|
||||
|
||||
while (_shouldRead && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (serial == null || !serial.IsOpen)
|
||||
{
|
||||
SafeCloseSerial();
|
||||
serial = CreateSerialPort();
|
||||
serial.Open();
|
||||
_frameBufferLength = 0;
|
||||
serial.DiscardInBuffer();
|
||||
_lastFrameTicks = DateTime.UtcNow.Ticks;
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Connected to {_portName}");
|
||||
}
|
||||
|
||||
InnerReadLoop(readBuffer, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] IMU disconnected: {ex.Message}");
|
||||
SafeCloseSerial();
|
||||
}
|
||||
|
||||
if (_shouldRead && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
cancellationToken.WaitHandle.WaitOne(RECONNECT_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
|
||||
SafeCloseSerial();
|
||||
Thread.EndThreadAffinity();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inner loop doc du lieu tu serial port. Thoat khi port dong hoac co exception
|
||||
/// de outer loop xu ly reconnect
|
||||
/// </summary>
|
||||
private void InnerReadLoop(byte[] readBuffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (_shouldRead && !cancellationToken.IsCancellationRequested
|
||||
&& serial != null && serial.IsOpen)
|
||||
{
|
||||
int bytesToRead = serial.BytesToRead;
|
||||
if (bytesToRead <= 0)
|
||||
{
|
||||
if (DateTime.UtcNow.Ticks - _lastFrameTicks > DATA_TIMEOUT_TICKS)
|
||||
{
|
||||
throw new TimeoutException($"No IMU frame for > {DATA_TIMEOUT_TICKS / TimeSpan.TicksPerSecond}s");
|
||||
}
|
||||
Thread.Sleep(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
int bytesRead = serial.Read(readBuffer, 0, Math.Min(bytesToRead, readBuffer.Length));
|
||||
if (bytesRead <= 0) continue;
|
||||
|
||||
ProcessIncomingData(readBuffer, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dong va dispose SerialPort an toan, set serial=null de lan sau tao moi.
|
||||
/// SerialPort sau IOException thuong khong Open() lai duoc tren Linux nen phai tao moi.
|
||||
/// </summary>
|
||||
private void SafeCloseSerial()
|
||||
{
|
||||
if (serial == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
serial.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Error closing serial: {ex.Message}");
|
||||
}
|
||||
|
||||
try { serial.Dispose(); }
|
||||
catch { }
|
||||
|
||||
serial = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xu ly du lieu moi nhan duoc tu serial port
|
||||
/// Them vao frame buffer va tim, parse cac frame hoan chinh
|
||||
/// </summary>
|
||||
private void ProcessIncomingData(byte[] data, int length)
|
||||
{
|
||||
int dataOffset = 0;
|
||||
|
||||
while (dataOffset < length)
|
||||
{
|
||||
ProcessCompleteFramesInBuffer();
|
||||
|
||||
int availableSpace = FRAME_BUFFER_SIZE - _frameBufferLength;
|
||||
|
||||
if (availableSpace == 0)
|
||||
{
|
||||
RemoveIncompleteFrameAtStart();
|
||||
availableSpace = FRAME_BUFFER_SIZE - _frameBufferLength;
|
||||
|
||||
if (availableSpace == 0)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Buffer still full after removing incomplete frame, clearing buffer");
|
||||
_frameBufferLength = 0;
|
||||
availableSpace = FRAME_BUFFER_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
int bytesToAdd = Math.Min(length - dataOffset, availableSpace);
|
||||
Array.Copy(data, dataOffset, _frameBuffer, _frameBufferLength, bytesToAdd);
|
||||
_frameBufferLength += bytesToAdd;
|
||||
dataOffset += bytesToAdd;
|
||||
|
||||
ProcessCompleteFramesInBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xu ly tat ca cac frame hoan chinh trong buffer hien tai
|
||||
/// </summary>
|
||||
private void ProcessCompleteFramesInBuffer()
|
||||
{
|
||||
while (_frameBufferLength > 0)
|
||||
{
|
||||
ReadOnlySpan<byte> bufferSpan = new(_frameBuffer, 0, _frameBufferLength);
|
||||
int headIndex = bufferSpan.IndexOf(FRAME_HEAD);
|
||||
|
||||
if (headIndex < 0)
|
||||
{
|
||||
_frameBufferLength = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
if (headIndex > 0)
|
||||
{
|
||||
int remainingBytes = _frameBufferLength - headIndex;
|
||||
Array.Copy(_frameBuffer, headIndex, _frameBuffer, 0, remainingBytes);
|
||||
_frameBufferLength = remainingBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_frameBufferLength < 8)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
byte datatype = _frameBuffer[1];
|
||||
byte payloadLength = _frameBuffer[2];
|
||||
|
||||
if (!DataTypeLengthMap.TryGetValue(datatype, out byte expectedLength))
|
||||
{
|
||||
int remainingBytes = _frameBufferLength - 1;
|
||||
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingBytes);
|
||||
_frameBufferLength = remainingBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadLength != expectedLength)
|
||||
{
|
||||
int remainingBytes = _frameBufferLength - 1;
|
||||
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingBytes);
|
||||
_frameBufferLength = remainingBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
int totalFrameLength = 8 + payloadLength;
|
||||
if (_frameBufferLength < totalFrameLength)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (_frameBuffer[7 + payloadLength] != FRAME_END)
|
||||
{
|
||||
int remainingBytes = _frameBufferLength - 1;
|
||||
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingBytes);
|
||||
_frameBufferLength = remainingBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] frame = new byte[totalFrameLength];
|
||||
Array.Copy(_frameBuffer, 0, frame, 0, totalFrameLength);
|
||||
|
||||
int remainingAfterFrame = _frameBufferLength - totalFrameLength;
|
||||
if (remainingAfterFrame > 0)
|
||||
{
|
||||
Array.Copy(_frameBuffer, totalFrameLength, _frameBuffer, 0, remainingAfterFrame);
|
||||
}
|
||||
_frameBufferLength = remainingAfterFrame;
|
||||
|
||||
try
|
||||
{
|
||||
if (ParseFrame(frame))
|
||||
{
|
||||
_lastFrameTicks = DateTime.UtcNow.Ticks;
|
||||
try
|
||||
{
|
||||
DataReceived?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] DataReceived subscriber error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Error parsing frame: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loai bo frame thieu o dau buffer va tim frame head tiep theo
|
||||
/// </summary>
|
||||
private void RemoveIncompleteFrameAtStart()
|
||||
{
|
||||
if (_frameBufferLength == 0)
|
||||
return;
|
||||
|
||||
ReadOnlySpan<byte> bufferSpan = new(_frameBuffer, 0, _frameBufferLength);
|
||||
int headIndex = bufferSpan.IndexOf(FRAME_HEAD);
|
||||
|
||||
if (headIndex < 0)
|
||||
{
|
||||
_frameBufferLength = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (headIndex == 0)
|
||||
{
|
||||
if (_frameBufferLength < 8)
|
||||
return;
|
||||
|
||||
byte datatype = _frameBuffer[1];
|
||||
if (!DataTypeLengthMap.TryGetValue(datatype, out byte expectedLength))
|
||||
{
|
||||
int remainingAfterSkip = _frameBufferLength - 1;
|
||||
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingAfterSkip);
|
||||
_frameBufferLength = remainingAfterSkip;
|
||||
return;
|
||||
}
|
||||
|
||||
byte payloadLength = _frameBuffer[2];
|
||||
if (payloadLength != expectedLength)
|
||||
{
|
||||
int remainingAfterSkip = _frameBufferLength - 1;
|
||||
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingAfterSkip);
|
||||
_frameBufferLength = remainingAfterSkip;
|
||||
return;
|
||||
}
|
||||
|
||||
int totalFrameLength = 8 + payloadLength;
|
||||
if (_frameBufferLength < totalFrameLength)
|
||||
return;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int remainingAfterHead = _frameBufferLength - headIndex;
|
||||
Array.Copy(_frameBuffer, headIndex, _frameBuffer, 0, remainingAfterHead);
|
||||
_frameBufferLength = remainingAfterHead;
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
StopReadingThread();
|
||||
|
||||
SafeCloseSerial();
|
||||
|
||||
_frameBufferLength = 0;
|
||||
Array.Clear(_frameBuffer, 0, FRAME_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
private void DecodeIMU(byte[] payload)
|
||||
{
|
||||
Gx = BitConverter.ToSingle(payload, 0);
|
||||
Gy = BitConverter.ToSingle(payload, 4);
|
||||
Gz = BitConverter.ToSingle(payload, 8);
|
||||
AccX = BitConverter.ToSingle(payload, 12);
|
||||
AccY = BitConverter.ToSingle(payload, 16);
|
||||
AccZ = BitConverter.ToSingle(payload, 20);
|
||||
MagX = BitConverter.ToSingle(payload, 24);
|
||||
MagY = BitConverter.ToSingle(payload, 28);
|
||||
MagZ = BitConverter.ToSingle(payload, 32);
|
||||
Temp = BitConverter.ToSingle(payload, 36);
|
||||
Time_stamp = BitConverter.ToUInt32(payload, 40);
|
||||
Volatile.Write(ref _memoryBarrier, 0);
|
||||
}
|
||||
|
||||
private void DecodeAHRS(byte[] payload)
|
||||
{
|
||||
double rollspeed = BitConverter.ToSingle(payload, 0);
|
||||
double pitchspeed = BitConverter.ToSingle(payload, 4);
|
||||
double yawspeed = BitConverter.ToSingle(payload, 8);
|
||||
|
||||
double roll = BitConverter.ToSingle(payload, 12);
|
||||
double pitch = BitConverter.ToSingle(payload, 16);
|
||||
double yaw = BitConverter.ToSingle(payload, 20);
|
||||
|
||||
Rollspeed = rollspeed;
|
||||
Pitchspeed = pitchspeed;
|
||||
Yawspeed = yawspeed;
|
||||
Roll = roll;
|
||||
Pitch = pitch;
|
||||
Yaw = yaw;
|
||||
Volatile.Write(ref _memoryBarrier, 0);
|
||||
}
|
||||
|
||||
private void DecodeINSGPS(byte[] payload)
|
||||
{
|
||||
double latitude = BitConverter.ToDouble(payload, 0);
|
||||
double longitude = BitConverter.ToDouble(payload, 8);
|
||||
double altitude = BitConverter.ToSingle(payload, 16);
|
||||
|
||||
double vn = BitConverter.ToSingle(payload, 20);
|
||||
double ve = BitConverter.ToSingle(payload, 24);
|
||||
double vd = BitConverter.ToSingle(payload, 28);
|
||||
|
||||
double roll = BitConverter.ToSingle(payload, 32);
|
||||
double pitch = BitConverter.ToSingle(payload, 36);
|
||||
double yaw = BitConverter.ToSingle(payload, 40);
|
||||
|
||||
double qw = BitConverter.ToSingle(payload, 44);
|
||||
double qx = BitConverter.ToSingle(payload, 48);
|
||||
double qy = BitConverter.ToSingle(payload, 52);
|
||||
double qz = BitConverter.ToSingle(payload, 56);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse mot frame hoan chinh tu buffer
|
||||
/// </summary>
|
||||
private bool ParseFrame(byte[] frame)
|
||||
{
|
||||
if (frame.Length < 8)
|
||||
return false;
|
||||
|
||||
byte head = frame[0];
|
||||
if (head != FRAME_HEAD)
|
||||
return false;
|
||||
|
||||
byte datatype = frame[1];
|
||||
byte length = frame[2];
|
||||
byte sn = frame[3];
|
||||
byte crc8 = frame[4];
|
||||
byte crc16_h = frame[5];
|
||||
byte crc16_l = frame[6];
|
||||
ushort head_crc16 = (ushort)(crc16_l + (crc16_h << 8));
|
||||
|
||||
Span<byte> header = [head, datatype, length, sn];
|
||||
byte crc8_calc = CRCTable.CRC8_Table(header);
|
||||
if (crc8_calc != crc8)
|
||||
{
|
||||
throw new Exception($"CRC8 header error: recv={crc8:X2}, calc={crc8_calc:X2}");
|
||||
}
|
||||
|
||||
if (frame[7 + length] != FRAME_END)
|
||||
{
|
||||
throw new Exception($"Frame end error: {BitConverter.ToString(frame)}");
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> payload = frame.AsSpan(7, length);
|
||||
|
||||
ushort crc16_calc = CRCTable.CRC16_Table(payload);
|
||||
if (crc16_calc != head_crc16)
|
||||
{
|
||||
throw new Exception($"CRC16 payload error: recv={head_crc16:X4}, calc={crc16_calc:X4}");
|
||||
}
|
||||
|
||||
switch (datatype)
|
||||
{
|
||||
case TYPE_AHRS:
|
||||
DecodeAHRS([..payload]);
|
||||
break;
|
||||
case TYPE_IMU:
|
||||
DecodeIMU([..payload]);
|
||||
break;
|
||||
case TYPE_INSGPS:
|
||||
DecodeINSGPS([..payload]);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Ports;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.YNZDH;
|
||||
|
||||
public class ModbusRtuClient : IDisposable
|
||||
{
|
||||
private readonly ILogger? _logger;
|
||||
private SerialPort? _port;
|
||||
|
||||
private readonly string _portName;
|
||||
private readonly int _baud;
|
||||
private readonly Parity _parity;
|
||||
private readonly int _dataBits;
|
||||
private readonly StopBits _stopBits;
|
||||
private readonly int _readTimeoutMs;
|
||||
private readonly int _writeTimeoutMs;
|
||||
|
||||
private readonly object _sync = new();
|
||||
private bool _faulted = false;
|
||||
private DateTime _lastRetry = DateTime.MinValue;
|
||||
private int _retryDelayMs = 1000; // backoff min = 1s
|
||||
private DateTime _lastDataTime = DateTime.MinValue;
|
||||
private readonly int _dataTimeoutMs = 10_000; // 10 giây
|
||||
private int _consecutiveFails = 0;
|
||||
private readonly int _maxFails = 3; // sau 3 lần fail liên tiếp thì coi như lost
|
||||
|
||||
public bool IsFaulted => _faulted;
|
||||
|
||||
public ModbusRtuClient(string portName,
|
||||
int baud = 9600,
|
||||
Parity parity = Parity.None,
|
||||
int dataBits = 8,
|
||||
StopBits stopBits = StopBits.One,
|
||||
int readTimeoutMs = 50,
|
||||
int writeTimeoutMs = 50,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_portName = portName;
|
||||
_baud = baud;
|
||||
_parity = parity;
|
||||
_dataBits = dataBits;
|
||||
_stopBits = stopBits;
|
||||
_readTimeoutMs = readTimeoutMs;
|
||||
_writeTimeoutMs = writeTimeoutMs;
|
||||
_lastDataTime = DateTime.Now;
|
||||
_consecutiveFails = 0;
|
||||
|
||||
EnsureConnected();
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// AUTO RECONNECT (giống TadaRs485Client)
|
||||
// -------------------------
|
||||
// NOTE: This method uses lock (_sync) which may cause contention if called from multiple threads.
|
||||
// If called from a non-realtime thread, it may delay realtime polling thread.
|
||||
private void EnsureConnected()
|
||||
{
|
||||
var ensureStartTicks = Stopwatch.GetTimestamp();
|
||||
double lockAcquisitionMs = 0;
|
||||
double checkTimeMs = 0;
|
||||
double disposeTimeMs = 0;
|
||||
double createTimeMs = 0;
|
||||
double openTimeMs = 0;
|
||||
int currentThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
string? currentThreadName = Thread.CurrentThread.Name;
|
||||
|
||||
var lockStartTicks = Stopwatch.GetTimestamp();
|
||||
lock (_sync)
|
||||
{
|
||||
var lockEndTicks = Stopwatch.GetTimestamp();
|
||||
lockAcquisitionMs = ((lockEndTicks - lockStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
|
||||
var checkStartTicks = Stopwatch.GetTimestamp();
|
||||
if (_port != null && _port.IsOpen && !_faulted)
|
||||
{
|
||||
var checkEndTicks = Stopwatch.GetTimestamp();
|
||||
checkTimeMs = ((checkEndTicks - checkStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((DateTime.Now - _lastRetry).TotalMilliseconds < _retryDelayMs)
|
||||
{
|
||||
var checkEndTicks = Stopwatch.GetTimestamp();
|
||||
checkTimeMs = ((checkEndTicks - checkStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
return;
|
||||
}
|
||||
var checkEndTicks2 = Stopwatch.GetTimestamp();
|
||||
checkTimeMs = ((checkEndTicks2 - checkStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
|
||||
_lastRetry = DateTime.Now;
|
||||
|
||||
try
|
||||
{
|
||||
var disposeStartTicks = Stopwatch.GetTimestamp();
|
||||
_port?.Dispose();
|
||||
var disposeEndTicks = Stopwatch.GetTimestamp();
|
||||
disposeTimeMs = ((disposeEndTicks - disposeStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
|
||||
var createStartTicks = Stopwatch.GetTimestamp();
|
||||
_port = new SerialPort(_portName, _baud, _parity, _dataBits, _stopBits)
|
||||
{
|
||||
ReadTimeout = _readTimeoutMs,
|
||||
WriteTimeout = _writeTimeoutMs,
|
||||
// Set buffer sizes to minimize kernel delays
|
||||
ReadBufferSize = 4096,
|
||||
WriteBufferSize = 4096
|
||||
};
|
||||
var createEndTicks = Stopwatch.GetTimestamp();
|
||||
createTimeMs = ((createEndTicks - createStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
|
||||
var openStartTicks = Stopwatch.GetTimestamp();
|
||||
_port.Open();
|
||||
var openEndTicks = Stopwatch.GetTimestamp();
|
||||
openTimeMs = ((openEndTicks - openStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
|
||||
_faulted = false;
|
||||
_retryDelayMs = 1000; // reset backoff
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Connect failed: {ex.Message}", ex.Message);
|
||||
_faulted = true;
|
||||
|
||||
// exponential backoff giống Tada
|
||||
_retryDelayMs = Math.Min(_retryDelayMs * 2, 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
var ensureEndTicks = Stopwatch.GetTimestamp();
|
||||
var ensureTotalMs = ((ensureEndTicks - ensureStartTicks) * 1000.0) / Stopwatch.Frequency;
|
||||
|
||||
// Log if EnsureConnected took longer than 10ms (should be very fast if already connected)
|
||||
if (ensureTotalMs > 10.0 && _logger != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[ModbusRtuClient] Slow EnsureConnected: Total={TotalMs:F1}ms, ThreadId={ThreadId}, ThreadName={ThreadName}, " +
|
||||
"LockAcquisition={LockAcquisitionMs:F1}ms, Check={CheckTimeMs:F1}ms, " +
|
||||
"Dispose={DisposeTimeMs:F1}ms, Create={CreateTimeMs:F1}ms, Open={OpenTimeMs:F1}ms. " +
|
||||
"NOTE: High LockAcquisition time indicates lock contention from other threads.",
|
||||
ensureTotalMs, currentThreadId, currentThreadName ?? "Unknown",
|
||||
lockAcquisitionMs, checkTimeMs, disposeTimeMs, createTimeMs, openTimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckDataTimeout()
|
||||
{
|
||||
if (_lastDataTime != DateTime.MinValue &&
|
||||
(DateTime.Now - _lastDataTime).TotalMilliseconds > _dataTimeoutMs)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Communication lost (data timeout).");
|
||||
_faulted = true;
|
||||
_lastDataTime = DateTime.MinValue; // reset để tránh spam log
|
||||
}
|
||||
}
|
||||
|
||||
public void ForceReconnect()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_port != null)
|
||||
{
|
||||
try { if (_port.IsOpen) _port.Close(); } catch { }
|
||||
_port.Dispose();
|
||||
_port = null;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
_faulted = false;
|
||||
_lastRetry = DateTime.MinValue;
|
||||
_retryDelayMs = 1000;
|
||||
_consecutiveFails = 0;
|
||||
_lastDataTime = DateTime.Now;
|
||||
|
||||
EnsureConnected();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// CRC
|
||||
// -------------------------
|
||||
private static ushort Crc16(byte[] data, int len)
|
||||
{
|
||||
ushort crc = 0xFFFF;
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
crc ^= data[i];
|
||||
for (int j = 0; j < 8; j++)
|
||||
{
|
||||
bool lsb = (crc & 0x0001) != 0;
|
||||
crc >>= 1;
|
||||
if (lsb) crc ^= 0xA001;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// TX/RX WITH RECONNECT
|
||||
// -------------------------
|
||||
private byte[] TxRx(byte[] req, int respLen)
|
||||
{
|
||||
EnsureConnected();
|
||||
if (_port == null || !_port.IsOpen || _faulted)
|
||||
throw new Exception("Modbus port not available");
|
||||
|
||||
try
|
||||
{
|
||||
// Build frame
|
||||
ushort crc = Crc16(req, req.Length);
|
||||
byte[] frame = new byte[req.Length + 2];
|
||||
Array.Copy(req, frame, req.Length);
|
||||
frame[^2] = (byte)(crc & 0xFF); // CRC Lo
|
||||
frame[^1] = (byte)(crc >> 8 & 0xFF); // CRC Hi
|
||||
|
||||
// Discard buffer and write
|
||||
_port.DiscardInBuffer();
|
||||
_port.DiscardOutBuffer();
|
||||
_port.Write(frame, 0, frame.Length);
|
||||
|
||||
// Read response
|
||||
byte[] buf = new byte[respLen];
|
||||
int got = 0;
|
||||
while (got < respLen)
|
||||
{
|
||||
int bytesToRead = respLen - got;
|
||||
int bytesRead = _port.Read(buf, got, bytesToRead); // may throw TimeoutException
|
||||
got += bytesRead;
|
||||
}
|
||||
|
||||
// Verify CRC
|
||||
if (got < 3)
|
||||
{
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Communication lost (response too short).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
throw new Exception("Response too short");
|
||||
}
|
||||
|
||||
ushort rxCrc = (ushort)(buf[got - 2] | buf[got - 1] << 8);
|
||||
ushort calc = Crc16(buf, got - 2);
|
||||
if (rxCrc != calc)
|
||||
{
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Communication lost (CRC mismatch).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
throw new Exception("CRC mismatch");
|
||||
}
|
||||
|
||||
// Success - reset fail counter and update last data time
|
||||
_lastDataTime = DateTime.Now;
|
||||
_consecutiveFails = 0;
|
||||
return buf;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] IO error: {ExMessage}", ex.Message);
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Communication lost (too many failed reads).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
EnsureConnected(); // thử reconnect
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read Holding Registers (FC 0x03)
|
||||
/// </summary>
|
||||
public ushort[] ReadHoldingRegisters(byte slave, ushort startAddr, ushort quantity)
|
||||
{
|
||||
byte[] pdu =
|
||||
[
|
||||
slave, 0x03,
|
||||
(byte)(startAddr >> 8), (byte)(startAddr & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF),
|
||||
];
|
||||
|
||||
// Expected response: [slave][0x03][byteCount][data...][CRClo][CRChi]
|
||||
int byteCount = quantity * 2;
|
||||
int respLen = 3 + byteCount + 2;
|
||||
var resp = TxRx(pdu, respLen);
|
||||
|
||||
if (resp[0] != slave || resp[1] != 0x03)
|
||||
{
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Communication lost (invalid response function).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
throw new Exception("Invalid response function");
|
||||
}
|
||||
|
||||
if (resp[2] != byteCount)
|
||||
{
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _maxFails)
|
||||
{
|
||||
_logger?.LogError("[ModbusRtuClient] Communication lost (unexpected byte count).");
|
||||
_faulted = true;
|
||||
}
|
||||
CheckDataTimeout();
|
||||
throw new Exception("Unexpected byte count");
|
||||
}
|
||||
|
||||
// Success - reset fail counter and update last data time (TxRx already did this, but ensure it's updated)
|
||||
// Note: _consecutiveFails and _lastDataTime are already reset in TxRx() on success
|
||||
_lastDataTime = DateTime.Now;
|
||||
|
||||
ushort[] regs = new ushort[quantity];
|
||||
for (int i = 0; i < quantity; i++)
|
||||
{
|
||||
int idx = 3 + i * 2;
|
||||
regs[i] = (ushort)(resp[idx] << 8 | resp[idx + 1]); // Big-endian to ushort
|
||||
}
|
||||
return regs;
|
||||
}
|
||||
|
||||
public void WriteMultipleRegisters(byte slave, ushort startAddr, ushort[] values)
|
||||
{
|
||||
int byteCount = values.Length * 2;
|
||||
byte[] pdu = new byte[7 + byteCount];
|
||||
|
||||
pdu[0] = slave;
|
||||
pdu[1] = 0x10;
|
||||
pdu[2] = (byte)(startAddr >> 8);
|
||||
pdu[3] = (byte)startAddr;
|
||||
pdu[4] = (byte)(values.Length >> 8);
|
||||
pdu[5] = (byte)values.Length;
|
||||
pdu[6] = (byte)byteCount;
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
pdu[7 + i * 2] = (byte)(values[i] >> 8);
|
||||
pdu[7 + i * 2 + 1] = (byte)values[i];
|
||||
}
|
||||
|
||||
int respLen = 8;
|
||||
TxRx(pdu, respLen);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Dispose
|
||||
// -------------------------
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_port != null)
|
||||
{
|
||||
if (_port.IsOpen) _port.Close();
|
||||
_port.Dispose();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_port = null;
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.Shared;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RobotNet10.RobotApp.Drivers.YNZDH;
|
||||
|
||||
[Device(DeviceType.RfHandle, "YNZDH", "YNZDH_RfHandle", "1.0.0",
|
||||
Description = "YNZDH RF Handle (minimal but full simulation properties)")]
|
||||
public class YNZDH_RfHandle : DeviceBase, IRfHandle
|
||||
{
|
||||
private readonly string _port;
|
||||
private readonly int _baud;
|
||||
private readonly ILogger<YNZDH_RfHandle> _logger;
|
||||
|
||||
private ModbusRtuClient? _modbus;
|
||||
|
||||
// High-priority polling thread for real-time data acquisition
|
||||
private Thread? _pollingThread;
|
||||
private CancellationTokenSource? _pollingCts;
|
||||
private volatile bool _shouldPoll = false;
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public event Action? Updated;
|
||||
|
||||
public YNZDH_RfHandle(string deviceId, string deviceName, IConfigurationSection cfg, IServiceProvider serviceProvider)
|
||||
: base(deviceId, deviceName, DeviceType.RfHandle)
|
||||
{
|
||||
_port = cfg.GetValue<string>("Port") ?? throw new Exception("Port is required");
|
||||
_baud = cfg.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
|
||||
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
_logger = loggerFactory.CreateLogger<YNZDH_RfHandle>();
|
||||
|
||||
AutoReconnectEnabled = true;
|
||||
ReconnectDelayMs = 2000;
|
||||
MaxReconnectAttempts = 0;
|
||||
|
||||
// Khởi tạo PropertyDescriptions → DeviceBase validation pass
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
// ====================== STATES ===========================
|
||||
|
||||
public DateTime LastUpdateTime { get; private set; }
|
||||
|
||||
public int Heartbeat { get; private set; }
|
||||
public bool RemoteReady { get; private set; }
|
||||
public bool EStop { get; private set; }
|
||||
|
||||
public bool LiftUp { get; private set; }
|
||||
public bool LiftDown { get; private set; }
|
||||
public bool RotateLeft { get; private set; }
|
||||
public bool RotateRight { get; private set; }
|
||||
|
||||
public bool ModeSelect { get; private set; }
|
||||
public bool Enable { get; private set; }
|
||||
|
||||
public int Speed { get; private set; } // 0–100
|
||||
public double Linear { get; private set; } // -1 → +1
|
||||
public double Angular { get; private set; } // -1 → +1
|
||||
|
||||
public RFMode Mode { get; private set; } = RFMode.None;
|
||||
|
||||
private Joy? _cachedJoyState;
|
||||
|
||||
// IRfHandle Implementation
|
||||
public Joy? CurrentJoyState
|
||||
{
|
||||
get { lock (_lock) { return _cachedJoyState; } }
|
||||
}
|
||||
|
||||
// ====================== SIM PROPERTIES ====================
|
||||
|
||||
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new("Heartbeat", "Heartbeat"),
|
||||
new("RemoteReady", "Remote Ready"),
|
||||
new("EStop", "Emergency Stop"),
|
||||
|
||||
new("LiftUp", "Lift Up"),
|
||||
new("LiftDown", "Lift Down"),
|
||||
new("RotateLeft", "Rotate Left"),
|
||||
new("RotateRight", "Rotate Right"),
|
||||
|
||||
new("ModeSelect", "Mode Select"),
|
||||
new("Enable", "Enable"),
|
||||
|
||||
new("Speed", "Speed"),
|
||||
new("Mode", "Mode"),
|
||||
|
||||
new("LastUpdate", "Last Update Time")
|
||||
];
|
||||
}
|
||||
|
||||
private void UpdateProperties()
|
||||
{
|
||||
SetProperty("Heartbeat", Heartbeat.ToString());
|
||||
SetProperty("RemoteReady", RemoteReady.ToString());
|
||||
SetProperty("EStop", EStop.ToString());
|
||||
|
||||
SetProperty("LiftUp", LiftUp.ToString());
|
||||
SetProperty("LiftDown", LiftDown.ToString());
|
||||
SetProperty("RotateLeft", RotateLeft.ToString());
|
||||
SetProperty("RotateRight", RotateRight.ToString());
|
||||
|
||||
SetProperty("ModeSelect", ModeSelect.ToString());
|
||||
SetProperty("Enable", Enable.ToString());
|
||||
|
||||
SetProperty("Speed", Speed.ToString());
|
||||
SetProperty("Mode", Mode.ToString());
|
||||
|
||||
SetProperty("LastUpdate", LastUpdateTime == default
|
||||
? "Never"
|
||||
: LastUpdateTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
}
|
||||
|
||||
// ====================== DEVICEBASE ========================
|
||||
|
||||
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
//_modbus = new ModbusRtuClient(_port, _baud, logger: _logger);
|
||||
_modbus = new ModbusRtuClient(_port, _baud);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Modbus init failed");
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "Modbus init failed");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartPolling();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopPolling();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// Stop polling thread before disposing base class
|
||||
StopPolling();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
protected override Task OnResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopPolling();
|
||||
StartPolling();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_modbus != null);
|
||||
|
||||
// ===================== POLLING LOOP ======================
|
||||
|
||||
/// <summary>
|
||||
/// Start high-priority polling thread for real-time data acquisition at 10Hz
|
||||
/// </summary>
|
||||
private void StartPolling()
|
||||
{
|
||||
StopPolling(); // Đảm bảo không có thread nào đang chạy
|
||||
|
||||
_shouldPoll = true;
|
||||
|
||||
// Tạo mới CancellationTokenSource cho thread mới
|
||||
_pollingCts?.Dispose();
|
||||
_pollingCts = new CancellationTokenSource();
|
||||
|
||||
_pollingThread = new Thread(() => PollingThreadLoop(_pollingCts.Token))
|
||||
{
|
||||
Name = $"YNZDH-RfHandle-Polling-{DeviceId}",
|
||||
IsBackground = false, // Không phải background thread để đảm bảo chạy liên tục
|
||||
Priority = ThreadPriority.Highest // Priority cao để đảm bảo real-time polling
|
||||
};
|
||||
|
||||
_pollingThread.Start();
|
||||
_logger.LogDebug("YNZDH_RfHandle: Started high-priority polling thread at 10Hz for device {DeviceId}", DeviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop polling thread gracefully
|
||||
/// </summary>
|
||||
private void StopPolling()
|
||||
{
|
||||
_shouldPoll = false;
|
||||
|
||||
_pollingCts?.Cancel();
|
||||
|
||||
if (_pollingThread != null)
|
||||
{
|
||||
if (!_pollingThread.Join(1000)) // Đợi tối đa 1 giây
|
||||
{
|
||||
_logger.LogWarning("YNZDH_RfHandle: Polling thread did not stop gracefully for device {DeviceId}", DeviceId);
|
||||
}
|
||||
_pollingThread = null;
|
||||
}
|
||||
|
||||
// Dispose CancellationTokenSource sau khi thread đã dừng
|
||||
_pollingCts?.Dispose();
|
||||
_pollingCts = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-priority polling thread loop - runs at 10Hz (100ms interval)
|
||||
/// Uses Stopwatch for high-precision timing to ensure accurate 10Hz polling rate
|
||||
/// </summary>
|
||||
private void PollingThreadLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
var modbusClient = _modbus;
|
||||
if (modbusClient == null)
|
||||
{
|
||||
_logger.LogError("YNZDH_RfHandle: Modbus client is null");
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.BeginThreadAffinity();
|
||||
try
|
||||
{
|
||||
const int pollingIntervalMs = 100; // 10Hz = 100ms
|
||||
var intervalTicks = pollingIntervalMs * TimeSpan.TicksPerMillisecond;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var nextPollTime = stopwatch.ElapsedTicks + intervalTicks;
|
||||
var spinWait = new SpinWait();
|
||||
long currentTicks = 0;
|
||||
|
||||
while (_shouldPoll && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
currentTicks = stopwatch.ElapsedTicks;
|
||||
|
||||
// Check if it's time to poll
|
||||
if (currentTicks >= nextPollTime)
|
||||
{
|
||||
try
|
||||
{
|
||||
ushort[] regs = modbusClient.ReadHoldingRegisters(1, 1, 4);
|
||||
DecodeRegisters(regs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "Polling error");
|
||||
ResetToDefaultValues();
|
||||
}
|
||||
|
||||
// Calculate next poll time
|
||||
nextPollTime = currentTicks + intervalTicks;
|
||||
}
|
||||
|
||||
// SpinWait for precise timing (10 spins then reset)
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.SpinOnce();
|
||||
spinWait.Reset();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "YNZDH_RfHandle: Error in polling thread loop for device {DeviceId}", DeviceId);
|
||||
LastError = ex;
|
||||
OnErrorOccurred(ex, "Polling thread error");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.EndThreadAffinity();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== DECODE ============================
|
||||
|
||||
private void ResetToDefaultValues()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
Heartbeat = 0;
|
||||
RemoteReady = false;
|
||||
EStop = false;
|
||||
|
||||
LiftUp = false;
|
||||
LiftDown = false;
|
||||
RotateLeft = false;
|
||||
RotateRight = false;
|
||||
|
||||
ModeSelect = false;
|
||||
Enable = false;
|
||||
|
||||
Speed = 0;
|
||||
Linear = 0.0;
|
||||
Angular = 0.0;
|
||||
Mode = RFMode.None;
|
||||
|
||||
_cachedJoyState = null;
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
Updated?.Invoke();
|
||||
}
|
||||
|
||||
private void DecodeRegisters(ushort[] regs)
|
||||
{
|
||||
byte d0 = (byte)(regs[0] >> 8); // Word0H
|
||||
byte d1 = (byte)(regs[0]); // Word0L
|
||||
byte d2 = (byte)(regs[1] >> 8); // Word1H
|
||||
byte d3 = (byte)(regs[1]); // Word1L
|
||||
byte d6 = (byte)(regs[3] >> 8); // Word3H (JOY FB)
|
||||
byte d7 = (byte)(regs[3]); // Word3L (JOY LR)
|
||||
lock (_lock)
|
||||
{
|
||||
// ===== System =====
|
||||
Heartbeat = (d0 >> 4) & 0x0F;
|
||||
RemoteReady = (d0 & 0x04) != 0;
|
||||
RemoteReady = !RemoteReady;
|
||||
EStop = (d0 & 0x01) != 0;
|
||||
|
||||
// ===== Buttons =====
|
||||
Enable = (d1 & 0x80) != 0;
|
||||
ModeSelect = (d1 & 0x40) != 0;
|
||||
|
||||
LiftUp = (d1 & 0x01) != 0;
|
||||
LiftDown = (d1 & 0x02) != 0;
|
||||
RotateLeft = (d1 & 0x04) != 0;
|
||||
RotateRight = (d1 & 0x08) != 0;
|
||||
|
||||
// ===== Speed =====
|
||||
Speed = Math.Clamp((int)d3, 0, 100);
|
||||
// ===== Mode =====
|
||||
Mode = DecodeMode(d2);
|
||||
|
||||
// ===== Safety =====
|
||||
if (!RemoteReady || !Enable || EStop)
|
||||
{
|
||||
Linear = 0;
|
||||
Angular = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// ===== Joystick ANALOG =====
|
||||
Linear = (d6 - 127f) / 127f;
|
||||
Angular = (127f - d7) / 127f;
|
||||
}
|
||||
|
||||
LastUpdateTime = DateTime.UtcNow;
|
||||
|
||||
// Update cached JoyState
|
||||
_cachedJoyState = CreateJoyStateFromCache();
|
||||
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
Updated?.Invoke();
|
||||
}
|
||||
|
||||
private static RFMode DecodeMode(byte d2) =>
|
||||
(d2 & 0x0F) switch
|
||||
{
|
||||
0x00 => RFMode.Default,
|
||||
0x01 => RFMode.Maintenance,
|
||||
0x02 => RFMode.Override,
|
||||
_ => RFMode.None
|
||||
};
|
||||
|
||||
// ===================== IRfHandle Implementation ===========
|
||||
|
||||
public Task<Joy> ReadJoyStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cachedJoyState.HasValue)
|
||||
{
|
||||
return Task.FromResult(_cachedJoyState.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult(CreateJoyStateFromCache());
|
||||
}
|
||||
}
|
||||
|
||||
private Joy CreateJoyStateFromCache()
|
||||
{
|
||||
return new Joy
|
||||
{
|
||||
Header = new Header
|
||||
{
|
||||
Stamp = LastUpdateTime == default ? DateTime.UtcNow : LastUpdateTime,
|
||||
FrameId = "rfhandle_frame"
|
||||
},
|
||||
Axes =
|
||||
[
|
||||
Linear, // Axis 0: Forward / Backward
|
||||
Angular, // Axis 1: Left / Right
|
||||
Speed / 100f // Axis 2: Speed
|
||||
],
|
||||
Buttons =
|
||||
[
|
||||
LiftUp ? 1 : 0,
|
||||
LiftDown ? 1 : 0,
|
||||
RotateLeft ? 1 : 0,
|
||||
RotateRight ? 1 : 0,
|
||||
ModeSelect ? 1 : 0,
|
||||
Enable ? 1 : 0,
|
||||
EStop ? 1 : 0
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user