using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RobotNet10.RobotApp.Devices; using RobotNet10.RobotApp.Hubs; using RobotNet10.RobotApp.Motion; using RobotNet10.RobotApp.TF3; using RobotNet10.Shared; using RobotNet10.Shared.Geometry; using RobotNet10.Shared.Sensor; using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; // using RobotNet10.Shared.Numbers; namespace RobotNet10.RobotApp.Xloc; /// /// Integration service to dispatch sensor data from RobotNet to XLOC SLAM engine /// Sends EKF filtered odometry, IMU raw data, and LIDAR scans /// public class XlocIntegrationService : IHostedService, IDisposable { private readonly XlocIntegrationConfiguration _config; private readonly IDeviceProvider _deviceProvider; private readonly OdometryService? _odometryService; private readonly EKFService? _ekfService; private readonly ILogger _logger; private readonly IHubContext? _poseHubContext; private readonly TF3BufferManager? _tf3BufferManager; private readonly object _lock = new(); private readonly object _statsLock = new(); // Lock for thread-safe statistics access private readonly object _timestampLock = new(); // Lock for monotonic timestamp adjustment private readonly object _sourceSampleLock = new(); // Lock for source sample deduplication private readonly ConcurrentDictionary _latencyLogTimes = new(); private readonly ConcurrentDictionary _lastDispatchedTimestamps = new(); private readonly ConcurrentDictionary _stampLogTimes = new(); private readonly ConcurrentDictionary _lastSourceTimestamps = new(); private readonly ConcurrentDictionary _lastSourceSequences = new(); private readonly SemaphoreSlim _dispatchSemaphore = new SemaphoreSlim(1, 1); // Only 1 dispatch at a time! private XlocClient? _xlocClient; private XlocAsyncDispatcher? _asyncDispatcher; // Separate timers for each sensor type (multi-threading) private Timer? _imuTimer; private Timer? _odomTimer; private Timer? _lidarTimer; private Timer? _loggingTimer; // Timer for console logging of pose and diagnostics private bool _disposed = false; private bool _isInitialized = false; /// /// UTC time when last succeeded (API or direct). Used by /// to avoid immediately re-starting after user stop. /// public DateTime? LastLocalizationStopUtc { get; private set; } // Event handler references (stored for unregistration) private EventHandler? _lidar1ScanHandler; private EventHandler? _lidar2ScanHandler; private EventHandler? _lidar3ScanHandler; private EventHandler? _odomUpdatedHandler; private EventHandler? _imuAngularVelocityHandler; // Lidar event pump queue system private readonly ConcurrentQueue<(int slot, LaserScan scan)> _pendingLidarQueue1 = new(); private readonly ConcurrentQueue<(int slot, LaserScan scan)> _pendingLidarQueue2 = new(); private readonly ConcurrentQueue<(int slot, LaserScan scan)> _pendingLidarQueue3 = new(); private const int MaxPendingLidarScans = 20; private Task? _lidarEventPumpTask; private CancellationTokenSource? _lidarEventPumpCts; // Cached device references private IInertialMeasurementUnit? _imuDevice; private ILidar? _lidarDevice1; private ILidar? _lidarDevice2; // Second lidar (right side) private ILidar? _lidarDevice3; // Third lidar (left side) // Cached laser scans for web visualization (thread-safe) private LaserScan? _cachedLaserScan1 = null; private LaserScan? _cachedLaserScan2 = null; private LaserScan? _cachedLaserScan3 = null; private readonly object _laserCacheLock = new(); // Statistics (accessed from multiple threads) private long _odometryCount = 0; private long _imuCount = 0; private long _lidarCount = 0; private long _lidar1DispatchCount = 0; private long _lidar2DispatchCount = 0; private long _lidar3DispatchCount = 0; private uint _imuSeq = 0; private DateTime _lastStatsLog = DateTime.UtcNow; // Frequency snapshot tracking private long _lastOdomCountSnapshot; private long _lastImuCountSnapshot; private long _lastLidar1CountSnapshot; private long _lastLidar2CountSnapshot; private long _lastLidar3CountSnapshot; private DateTime _lastFreqSnapshotTime = DateTime.UtcNow; public XlocIntegrationService( IConfiguration configuration, IDeviceProvider deviceProvider, ILogger logger, TF3BufferManager? tf3BufferManager = null, IHubContext? poseHubContext = null, OdometryService? odometryService = null, EKFService? ekfService = null) { _deviceProvider = deviceProvider ?? throw new ArgumentNullException(nameof(deviceProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _poseHubContext = poseHubContext; _odometryService = odometryService; _ekfService = ekfService; _tf3BufferManager = tf3BufferManager; // Load configuration var configSection = configuration.GetSection("Xloc:Integration"); if (!configSection.Exists()) { _logger.LogWarning("Configuration section 'Xloc:Integration' not found. Using defaults."); _config = new XlocIntegrationConfiguration(); } else { _config = new XlocIntegrationConfiguration(); configSection.Bind(_config); } ValidateConfiguration(); } private void ValidateConfiguration() { if (_config.Enabled) { if (_config.UpdateRateHz <= 0 || _config.UpdateRateHz > 100) { _logger.LogWarning("UpdateRateHz {Rate} is out of range [1-100]. Using default 20 Hz.", _config.UpdateRateHz); _config.UpdateRateHz = 20; } if (_config.EnableEKFOdometry && _ekfService == null) { _logger.LogWarning("EKF Odometry is enabled but EKFService is not available. Will be disabled."); _config.EnableEKFOdometry = false; } if (_config.EnableRawOdometry && _odometryService == null) { _logger.LogWarning("Raw Odometry is enabled but OdometryService is not available. Will be disabled."); _config.EnableRawOdometry = false; } } } /// /// Get the XlocClient instance for use in other services /// public XlocClient? XlocClient => _xlocClient; public async Task StartAsync(CancellationToken cancellationToken) { // var connected = await _deviceProvider.WaitForDevicesConnectedAsync(TimeSpan.FromMinutes(5), cancellationToken); if (!_config.Enabled) { _logger.LogInformation("XlocIntegrationService is disabled in configuration"); return; } _logger.LogInformation("Starting XlocIntegrationService..."); try { // Wait for devices to be connected _logger.LogInformation("Waiting for devices to be connected..."); var connected = await _deviceProvider.WaitForDevicesConnectedAsync(TimeSpan.FromMinutes(5), cancellationToken); if (!connected) { _logger.LogWarning("Timeout waiting for devices. XlocIntegrationService will retry initialization."); _ = Task.Run(async () => await RetryInitializationAsync(cancellationToken)); return; } await InitializeAsync(cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Error starting XlocIntegrationService"); } } private async Task RetryInitializationAsync(CancellationToken cancellationToken) { const int maxRetries = 60; // 5 minutes with 5 second intervals int retryCount = 0; while (retryCount < maxRetries && !cancellationToken.IsCancellationRequested && !_isInitialized) { await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); var connected = await _deviceProvider.WaitForDevicesConnectedAsync(TimeSpan.FromSeconds(1), cancellationToken); if (connected) { _logger.LogInformation("Devices are now connected. Initializing XlocIntegrationService..."); await InitializeAsync(cancellationToken); return; } retryCount++; if (retryCount % 12 == 0) // Log every minute { _logger.LogInformation("Still waiting for devices... (attempt {Attempt}/{MaxAttempts})", retryCount, maxRetries); } } if (retryCount >= maxRetries) { _logger.LogWarning("Timeout waiting for devices. XlocIntegrationService will not be initialized."); } } private async Task InitializeAsync(CancellationToken cancellationToken) { lock (_lock) { if (_isInitialized) return; try { // Get device references if (_config.EnableIMU && !string.IsNullOrEmpty(_config.ImuDeviceId)) { var device = _deviceProvider.GetDevice(_config.ImuDeviceId); if (device is IInertialMeasurementUnit imu && device.IsConnected) { _imuDevice = imu; _logger.LogInformation("IMU device '{DeviceId}' found and connected", _config.ImuDeviceId); } else { _logger.LogWarning("IMU device '{DeviceId}' not found or not connected. IMU data will not be sent.", _config.ImuDeviceId); } } if (_config.EnableLidar1 && !string.IsNullOrEmpty(_config.Lidar1DeviceId)) { var device = _deviceProvider.GetDevice(_config.Lidar1DeviceId); if (device is ILidar lidar && device.IsConnected) { _lidarDevice1 = lidar; _logger.LogInformation("LIDAR device '{DeviceId}' found and connected", _config.Lidar1DeviceId); } else { _logger.LogWarning("LIDAR device '{DeviceId}' not found or not connected. LIDAR data will not be sent.", _config.Lidar1DeviceId); } } // Second lidar (right side) if (_config.EnableLidar2 && !string.IsNullOrEmpty(_config.Lidar2DeviceId)) { var device2 = _deviceProvider.GetDevice(_config.Lidar2DeviceId); if (device2 is ILidar lidar2 && device2.IsConnected) { _lidarDevice2 = lidar2; _logger.LogInformation("LIDAR 2 device '{DeviceId}' found and connected", _config.Lidar2DeviceId); } else { _logger.LogWarning("LIDAR 2 device '{DeviceId}' not found or not connected.", _config.Lidar2DeviceId); } } // Third lidar (left side) if (_config.EnableLidar3 && !string.IsNullOrEmpty(_config.Lidar3DeviceId)) { var device3 = _deviceProvider.GetDevice(_config.Lidar3DeviceId); if (device3 is ILidar lidar3 && device3.IsConnected) { _lidarDevice3 = lidar3; _logger.LogInformation("LIDAR 3 device '{DeviceId}' found and connected", _config.Lidar3DeviceId); } else { _logger.LogWarning("LIDAR 3 device '{DeviceId}' not found or not connected.", _config.Lidar3DeviceId); } } // Create and initialize xloc client with TF3 buffer from TF3BufferManager if (_tf3BufferManager == null || !_tf3BufferManager.IsInitialized) { throw new InvalidOperationException( "TF3BufferManager is not available or not initialized. " + "Ensure TF3BufferManager is registered in dependency injection and initialized before XlocIntegrationService."); } IntPtr tfBuffer = _tf3BufferManager.TfBuffer; _logger.LogInformation("[XLOC] Using TF3 buffer from TF3BufferManager: {Buffer}", tfBuffer); _xlocClient = new XlocClient(tfBuffer, _logger); _xlocClient.Initialize(); _logger.LogInformation("XlocClient initialized successfully"); // DON'T start mapping/localization immediately - wait for sensor data first! // This prevents crash when xloc doesn't have any data yet if (_config.Mode == XlocMode.Localization && !string.IsNullOrEmpty(_config.MapFilePath)) { if (_xlocClient.ActivateMap(_config.MapFilePath)) { _logger.LogInformation("Map activated: {MapFile}", _config.MapFilePath); } else { _logger.LogWarning("Failed to activate map. Will try to start without map."); } } // Create async dispatcher — single-thread model (like ROS ros::spin) _asyncDispatcher = new XlocAsyncDispatcher(_xlocClient, queueCapacity: 300, logger: _logger); _logger.LogInformation("[XLOC] Async dispatcher created (single-thread, capacity=300)"); // Verify timestamp conversion at startup VerifyTimestampConversion(); // Register event-driven odometry dispatch if (_config.EnableRawOdometry && _odometryService != null) { _odomUpdatedHandler = OnOdometryUpdated; _odometryService.OdometryUpdated += _odomUpdatedHandler; _logger.LogInformation("[XLOC] Odometry event handler registered (event-driven)"); } // Register event-driven IMU dispatch if (_config.EnableIMU && _imuDevice != null) { _imuAngularVelocityHandler = OnImuAngularVelocityChanged; _imuDevice.AngularVelocityChanged += _imuAngularVelocityHandler; _logger.LogInformation("[XLOC] IMU event handler registered (event-driven)"); } // Register event-driven lidar dispatch with queue pump RegisterLidarEventDispatch(cancellationToken); // Start console logging timer (every 2 seconds) _loggingTimer = new Timer(LogPoseAndDiagnostics, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); _logger.LogInformation("XLOC console logging started (every 2 seconds)"); _isInitialized = true; _logger.LogInformation("XlocIntegrationService initialized successfully with event-driven sensor dispatch"); } catch (Exception ex) { _logger.LogError(ex, "Failed to initialize XlocIntegrationService"); throw; } } await Task.CompletedTask; }// IMU message sequence counter public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Stopping XlocIntegrationService..."); try { // IMPORTANT: Set _isInitialized to false FIRST to signal event handlers to stop lock (_lock) { _isInitialized = false; } // Unregister all event handlers immediately UnregisterLidarEventDispatch(); UnregisterOdomImuEventDispatch(); // Dispose timers _imuTimer?.Dispose(); _imuTimer = null; _odomTimer?.Dispose(); _odomTimer = null; _lidarTimer?.Dispose(); _lidarTimer = null; _loggingTimer?.Dispose(); _loggingTimer = null; // Wait for in-flight dispatches to complete _logger.LogInformation("Waiting for in-flight dispatches to complete..."); await Task.Delay(500, cancellationToken); // Dispose async dispatcher BEFORE stopping xloc client (prevent race conditions) var dispatcher = _asyncDispatcher; _asyncDispatcher = null; dispatcher?.Dispose(); // Stop xloc if (_xlocClient != null) { if (_config.Mode == XlocMode.Localization) { _xlocClient.StopLocalization(); } else if (_config.Mode == XlocMode.Mapping && !string.IsNullOrEmpty(_config.SaveMapFilePath)) { _xlocClient.StopMapping(_config.SaveMapFilePath); _logger.LogInformation("Map saved to: {MapFile}", _config.SaveMapFilePath); } _xlocClient.Dispose(); _xlocClient = null; _logger.LogInformation("XlocClient disposed successfully"); } LogFinalStatistics(); } catch (Exception ex) { _logger.LogError(ex, "Error stopping XlocIntegrationService"); } await Task.CompletedTask; } #region Event-Driven Dispatch — Odometry, IMU, Lidar /// /// Event handler: called when OdometryService fires OdometryUpdated. /// Deduplicates, adjusts timestamp, and dispatches via async dispatcher. /// private void OnOdometryUpdated(object? sender, OdometryUpdatedEventArgs e) { if (!_isInitialized || _xlocClient == null || _asyncDispatcher == null) return; try { var rawOdom = e.Odometry; if (rawOdom.Header.FrameId == null) return; // Dedup by sequence + timestamp var ts = (rawOdom.Header.Stamp - DateTime.UnixEpoch).TotalSeconds; if (!TryAcceptSourceSample(_config.RawOdometrySensorId, rawOdom.Header.Seq, ts)) return; // Monotonic timestamp var stamp = GetAdjustedSensorStamp(_config.RawOdometrySensorId, DateTime.UtcNow); var updatedOdom = new Odometry { Header = new Header { Seq = rawOdom.Header.Seq, Stamp = stamp, FrameId = rawOdom.Header.FrameId }, ChildFrameId = rawOdom.ChildFrameId, Pose = rawOdom.Pose, Twist = rawOdom.Twist }; var sensorId = _config.RawOdometrySensorId; var client = _xlocClient; var dispatcher = _asyncDispatcher; if (client == null || dispatcher == null) return; _ = dispatcher.EnqueueOdometryAsync(() => { client.DispatchOdometry(updatedOdom, sensorId); Interlocked.Increment(ref _odometryCount); }, sensorId); } catch (TaskCanceledException) { } catch (OperationCanceledException) { } catch (InvalidOperationException ex) when (ex.Message.Contains("disposed")) { _logger.LogDebug("Odometry dispatch skipped: dispatcher disposed"); } catch (Exception ex) { _logger.LogError(ex, "Error in OnOdometryUpdated event handler"); } } /// /// Event handler: called when IMU fires AngularVelocityChanged. /// Creates full IMU message, deduplicates, and dispatches via async dispatcher. /// private void OnImuAngularVelocityChanged(object? sender, AngularVelocityChangedEventArgs e) { if (!_isInitialized || _xlocClient == null || _imuDevice == null || _asyncDispatcher == null) return; try { var imu = CreateImuMessage(_imuDevice); if (imu == null) return; var originalImu = imu.Value; // Dedup by sequence + timestamp var ts = (originalImu.Header.Stamp - DateTime.UnixEpoch).TotalSeconds; if (!TryAcceptSourceSample(_config.ImuSensorId, originalImu.Header.Seq, ts)) return; // Monotonic timestamp var stamp = GetAdjustedSensorStamp(_config.ImuSensorId, DateTime.UtcNow); var updatedImu = new Imu { Header = new Header { Seq = originalImu.Header.Seq, Stamp = stamp, FrameId = originalImu.Header.FrameId }, Orientation = originalImu.Orientation, OrientationCovariance = originalImu.OrientationCovariance, AngularVelocity = originalImu.AngularVelocity, AngularVelocityCovariance = originalImu.AngularVelocityCovariance, LinearAcceleration = originalImu.LinearAcceleration, LinearAccelerationCovariance = originalImu.LinearAccelerationCovariance }; var sensorId = _config.ImuSensorId; var client = _xlocClient; var dispatcher = _asyncDispatcher; if (client == null || dispatcher == null) return; _ = dispatcher.EnqueueImuAsync(() => { client.DispatchImu(updatedImu, sensorId); Interlocked.Increment(ref _imuCount); }, sensorId); } catch (TaskCanceledException) { } catch (OperationCanceledException) { } catch (InvalidOperationException ex) when (ex.Message.Contains("disposed")) { _logger.LogDebug("IMU dispatch skipped: dispatcher disposed"); } catch (Exception ex) { _logger.LogError(ex, "Error in OnImuAngularVelocityChanged event handler"); } } #endregion #region Lidar Event Pump System /// /// Register lidar event handlers and start the pump loop. /// private void RegisterLidarEventDispatch(CancellationToken ct) { StartLidarEventPump(ct); if (_lidarDevice1 != null && _config.EnableLidar1) { _lidar1ScanHandler = (s, e) => QueuePendingLidarScan(1, e.MeasurementData); _lidarDevice1.ScanDataReceived += _lidar1ScanHandler; _logger.LogInformation("[XLOC] Lidar 1 event handler registered"); } if (_lidarDevice2 != null && _config.EnableLidar2) { _lidar2ScanHandler = (s, e) => QueuePendingLidarScan(2, e.MeasurementData); _lidarDevice2.ScanDataReceived += _lidar2ScanHandler; _logger.LogInformation("[XLOC] Lidar 2 event handler registered"); } if (_lidarDevice3 != null && _config.EnableLidar3) { _lidar3ScanHandler = (s, e) => QueuePendingLidarScan(3, e.MeasurementData); _lidarDevice3.ScanDataReceived += _lidar3ScanHandler; _logger.LogInformation("[XLOC] Lidar 3 event handler registered"); } } /// /// Unregister lidar event handlers and stop the pump. /// private void UnregisterLidarEventDispatch() { if (_lidarDevice1 != null && _lidar1ScanHandler != null) { _lidarDevice1.ScanDataReceived -= _lidar1ScanHandler; _lidar1ScanHandler = null; } if (_lidarDevice2 != null && _lidar2ScanHandler != null) { _lidarDevice2.ScanDataReceived -= _lidar2ScanHandler; _lidar2ScanHandler = null; } if (_lidarDevice3 != null && _lidar3ScanHandler != null) { _lidarDevice3.ScanDataReceived -= _lidar3ScanHandler; _lidar3ScanHandler = null; } StopLidarEventPump(); } /// /// Unregister odometry and IMU event handlers. /// private void UnregisterOdomImuEventDispatch() { if (_odometryService != null && _odomUpdatedHandler != null) { _odometryService.OdometryUpdated -= _odomUpdatedHandler; _odomUpdatedHandler = null; } if (_imuDevice != null && _imuAngularVelocityHandler != null) { _imuDevice.AngularVelocityChanged -= _imuAngularVelocityHandler; _imuAngularVelocityHandler = null; } } private void QueuePendingLidarScan(int slot, LaserScan scan) { var queue = slot switch { 1 => _pendingLidarQueue1, 2 => _pendingLidarQueue2, _ => _pendingLidarQueue3 }; var queueCountBefore = queue.Count; queue.Enqueue((slot, scan)); // Trim queue to prevent unbounded growth int droppedCount = 0; while (queue.Count > MaxPendingLidarScans) { queue.TryDequeue(out _); droppedCount++; } if (droppedCount > 0) { // _logger.LogWarning("[XLOC-DIAG] scan_{Slot} QueuePending DROPPED {Dropped} scans (queue was {Before}, max={Max})", // slot, droppedCount, queueCountBefore, MaxPendingLidarScans); } } private void StartLidarEventPump(CancellationToken externalCt) { _lidarEventPumpCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); _lidarEventPumpTask = Task.Run(() => LidarEventPumpLoopAsync(_lidarEventPumpCts.Token)); _logger.LogInformation("[XLOC] Lidar event pump started"); } private void StopLidarEventPump() { try { _lidarEventPumpCts?.Cancel(); _lidarEventPumpTask?.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } catch (OperationCanceledException) { } finally { _lidarEventPumpCts?.Dispose(); _lidarEventPumpCts = null; _lidarEventPumpTask = null; // Clear queues while (_pendingLidarQueue1.TryDequeue(out _)) { } while (_pendingLidarQueue2.TryDequeue(out _)) { } while (_pendingLidarQueue3.TryDequeue(out _)) { } } } private async Task LidarEventPumpLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { var loopStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); bool dispatchedAny = false; dispatchedAny |= await DrainLidarQueueAsync(_pendingLidarQueue1, "queue1", ct); dispatchedAny |= await DrainLidarQueueAsync(_pendingLidarQueue2, "queue2", ct); dispatchedAny |= await DrainLidarQueueAsync(_pendingLidarQueue3, "queue3", ct); var loopElapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - loopStart; if (dispatchedAny && loopElapsed > 20) { // _logger.LogWarning("[XLOC-DIAG] PumpLoop took {ElapsedMs}ms (q1={Q1},q2={Q2},q3={Q3})", // loopElapsed, _pendingLidarQueue1.Count, _pendingLidarQueue2.Count, _pendingLidarQueue3.Count); } if (!dispatchedAny) { await Task.Delay(_config.LidarDispatchIntervalMs, ct); } } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, "[XLOC] Error in lidar event pump loop"); } } } private async Task DrainLidarQueueAsync(ConcurrentQueue<(int slot, LaserScan scan)> queue, string queueName, CancellationToken ct) { bool dispatched = false; int count = 0; var drainStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); while (queue.TryDequeue(out var item) && !ct.IsCancellationRequested) { await DispatchLidarFromEventAsync(item.slot, item.scan); dispatched = true; count++; } if (dispatched) { var drainElapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - drainStart; if (drainElapsed > 10) { // _logger.LogWarning("[XLOC-DIAG] Drain {Queue} took {ElapsedMs}ms for {Count} scans", // queueName, drainElapsed, count); } } return dispatched; } /// /// Dispatch a lidar scan from event queue. Deduplicates, adjusts timestamp, /// caches for web visualization, and dispatches via async dispatcher. /// private async Task DispatchLidarFromEventAsync(int slot, LaserScan originalScan) { if (!_isInitialized || _xlocClient == null || _asyncDispatcher == null) return; try { var (sensorId, frameId) = slot switch { 1 => (_config.Lidar1SensorId, "scan_1"), 2 => (_config.Lidar2SensorId, "scan_2"), _ => (_config.Lidar3SensorId, "scan_3") }; // Dedup var ts = (originalScan.Header.Stamp - DateTime.UnixEpoch).TotalSeconds; if (!TryAcceptSourceSample(sensorId, originalScan.Header.Seq, ts)) return; // Monotonic timestamp var stamp = GetAdjustedSensorStamp(sensorId, DateTime.UtcNow); // Use sensor-provided angle parameters (QUYVN device config) // For Lidar 3, use hardcoded values as in original code var updatedScan = new LaserScan { Header = new Header { Seq = originalScan.Header.Seq, Stamp = stamp, FrameId = frameId }, AngleMin = slot == 3 ? (float)-2.356194496154785 : originalScan.AngleMin, AngleMax = slot == 3 ? (float)2.3557233810424805 : originalScan.AngleMax, AngleIncrement = slot == 3 ? (float)0.00581718236207962 : originalScan.AngleIncrement, TimeIncrement = slot == 3 ? (float)6.172839493956417e-05 : originalScan.TimeIncrement, ScanTime = slot == 3 ? (float)0.06666667014360428 : originalScan.ScanTime, RangeMin = slot == 3 ? 0f : originalScan.RangeMin, RangeMax = slot == 3 ? 100f : originalScan.RangeMax, Ranges = originalScan.Ranges, Intensities = originalScan.Intensities ?? [] }; // Always cache latest scan for web visualization even when xloc is stopped lock (_laserCacheLock) { switch (slot) { case 1: _cachedLaserScan1 = updatedScan; break; case 2: _cachedLaserScan2 = updatedScan; break; case 3: _cachedLaserScan3 = updatedScan; break; } } // Dispatch to xloc via async dispatcher var client = _xlocClient; var dispatcher = _asyncDispatcher; if (client == null || dispatcher == null ) return; var lidarSlot = slot; // capture for lambda var enqueueTs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); await dispatcher.EnqueueLaserScanAsync(() => { var asyncWaitMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - enqueueTs; var nativeStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); client.DispatchLaserScan(updatedScan, sensorId); var nativeElapsedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - nativeStart; Interlocked.Increment(ref _lidarCount); switch (lidarSlot) { case 1: Interlocked.Increment(ref _lidar1DispatchCount); break; case 2: Interlocked.Increment(ref _lidar2DispatchCount); break; case 3: Interlocked.Increment(ref _lidar3DispatchCount); break; } if (asyncWaitMs > 10 || nativeElapsedMs > 10) { // _logger.LogWarning( // "[XLOC-DIAG] scan_{Slot} async_wait={AsyncWaitMs}ms native_call={NativeMs}ms (sensor={SensorId})", // lidarSlot, asyncWaitMs, nativeElapsedMs, sensorId); } }, sensorId); } catch (TaskCanceledException) { } catch (OperationCanceledException) { } catch (InvalidOperationException ex) when (ex.Message.Contains("disposed")) { _logger.LogDebug("Lidar {Slot} dispatch skipped: dispatcher disposed", slot); } catch (Exception ex) { _logger.LogError(ex, "Error dispatching lidar {Slot} from event", slot); } } #endregion private Imu? CreateImuMessage(IInertialMeasurementUnit imu) { try { // Increment IMU sequence number var seq = Interlocked.Increment(ref _imuSeq); var header = new Header { Seq = seq, // Use device-provided timestamp directly (do not use MonotonicTimestampManager here) Stamp = imu.LastUpdateTime, FrameId = _config.ImuFrameId }; // Get orientation (raw from IMU) var orientation = new Quaternion(); var quatData = imu.CachedQuaternion; if (quatData.HasValue) { orientation = quatData.Value.Quaternion; } else { // Fallback to identity if no quaternion available orientation = new Quaternion { X = 0, Y = 0, Z = 0, W = 1 }; } // Get angular velocity (raw from IMU) var angularVelocity = imu.CachedAngularVelocity.Vector; // Get linear acceleration (raw from IMU) var linearAcceleration = imu.CachedAcceleration.Accel.Linear; // ===== SEND RAW IMU DATA TO XLOC (NO TRANSFORMATION) ===== // Testing to see if transformation is needed or causing issues // Covariance matrices (row-major, 3x3) // Based on Wheeltec N100 IMU specifications var orientationCov = new double[9] { 0.01, 0.0, 0.0, // Row 1: var_x, cov_xy, cov_xz 0.0, 0.01, 0.0, // Row 2: cov_yx, var_y, cov_yz 0.0, 0.0, 0.01 // Row 3: cov_zx, cov_zy, var_z }; var angularVelocityCov = new double[9] { 0.01, 0.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0, 0.01 }; var linearAccelerationCov = new double[9] { 0.05, 0.0, 0.0, 0.0, 0.05, 0.0, 0.0, 0.0, 0.05 }; return new Imu { Header = header, Orientation = orientation, OrientationCovariance = orientationCov, AngularVelocity = angularVelocity, AngularVelocityCovariance = angularVelocityCov, LinearAcceleration = linearAcceleration, LinearAccelerationCovariance = linearAccelerationCov }; } catch (Exception ex) { _logger.LogError(ex, "Error creating IMU message"); return null; } } #region Helper Methods — Timestamp, Dedup, Delay /// /// Precise delay that compensates for drift instead of fixed Task.Delay. /// Resyncs if more than one full period late. /// Returns the updated nextCycleTime. /// private static async Task DelayUntilNextCycleAsync(int intervalMs, DateTime nextCycleTime, CancellationToken ct) { nextCycleTime = nextCycleTime.AddMilliseconds(intervalMs); var now = DateTime.UtcNow; var delay = nextCycleTime - now; if (delay.TotalMilliseconds < -intervalMs) { // More than one full period late — resync to avoid burst catch-up return now.AddMilliseconds(intervalMs); } if (delay > TimeSpan.Zero) await Task.Delay(delay, ct); return nextCycleTime; } /// /// Ensures strictly monotonic timestamps per sensor. /// If the new stamp equals or precedes the previous one, adds 1 tick. /// private DateTime GetAdjustedSensorStamp(string sensorId, DateTime rawStamp) { lock (_timestampLock) { // Fallback to UtcNow if stamp is default (uninitialized) if (rawStamp == default) rawStamp = DateTime.UtcNow; if (_lastDispatchedTimestamps.TryGetValue(sensorId, out var last) && rawStamp <= last) { rawStamp = last.AddTicks(1); } _lastDispatchedTimestamps[sensorId] = rawStamp; return rawStamp; } } /// /// Deduplicates source samples using dual strategy: sequence-based (preferred) + timestamp-based fallback. /// Returns false if the sample was already processed. /// When sequence is strictly increasing, use sequence alone. /// When sequence is unchanged (e.g. device always sends seq=0), fall through to timestamp-based dedup. /// private bool TryAcceptSourceSample(string sourceId, uint sequence, double timestamp) { lock (_sourceSampleLock) { bool hasLastSeq = _lastSourceSequences.TryGetValue(sourceId, out var lastSeq); if (hasLastSeq) { if (sequence > lastSeq) { // Sequence strictly increased — accept without timestamp check _lastSourceSequences[sourceId] = sequence; _lastSourceTimestamps[sourceId] = timestamp; return true; } if (sequence < lastSeq) { // Sequence went backwards (duplicate or reorder) — reject return false; } // sequence == lastSeq: device may not increment seq (e.g. always 0) // Fall through to timestamp-based dedup } // Timestamp-based dedup (primary when seq is unchanged, fallback otherwise) if (_lastSourceTimestamps.TryGetValue(sourceId, out var lastTs) && timestamp <= lastTs) return false; _lastSourceSequences[sourceId] = sequence; _lastSourceTimestamps[sourceId] = timestamp; return true; } } /// /// Validates DateTime to Unix time conversion roundtrip at startup. /// private void VerifyTimestampConversion() { var now = DateTime.UtcNow; var unixSec = (now - DateTime.UnixEpoch).TotalSeconds; var roundtrip = DateTime.UnixEpoch.AddSeconds(unixSec); var driftMs = Math.Abs((roundtrip - now).TotalMilliseconds); if (driftMs > 100) _logger.LogWarning("[XLOC] Timestamp conversion drift: {Drift:F3}ms — may cause issues", driftMs); else _logger.LogInformation("[XLOC] Timestamp conversion OK, drift: {Drift:F6}ms", driftMs); } #endregion private void LogStatistics() { _logger.LogInformation( "Xloc sensor data dispatched - Odometry: {OdomCount}, IMU: {ImuCount}, LIDAR: {LidarCount}", _odometryCount, _imuCount, _lidarCount); } private void LogFinalStatistics() { _logger.LogInformation( "XlocIntegrationService final statistics - Total dispatched: Odometry={OdomCount}, IMU={ImuCount}, LIDAR={LidarCount}", _odometryCount, _imuCount, _lidarCount); } /// /// Get current pose from xloc (thread-safe) /// public (double x, double y, double z, double qx, double qy, double qz, double qw)? GetCurrentPose() { lock (_lock) { if (!_isInitialized || _xlocClient == null) return null; return _xlocClient.GetCurrentPose(); } } /// /// Get diagnostics from xloc (thread-safe) /// public XlocDiagnosticsData? GetDiagnostics() { lock (_lock) { if (!_isInitialized || _xlocClient == null) return null; return _xlocClient.GetDiagnostics(); } } /// /// Get current pose as 2D (x, y, yaw) - easier to use for navigation /// public OccupancyGridData? GetStaticGridMap(bool reloadFromFile = false) { lock (_lock) { if (_xlocClient == null || !_isInitialized) return null; // Avoid calling native map getter before a map is active to prevent noisy native warnings. var diagnostics = _xlocClient.GetDiagnostics(); if (diagnostics == null || string.IsNullOrWhiteSpace(diagnostics.CurrentActiveMap)) return null; return _xlocClient.GetStaticGridMap(reloadFromFile); } } public OccupancyGridData? GetOnlineGridMap() { lock (_lock) { if (_xlocClient == null || !_isInitialized) return null; return _xlocClient.GetOnlineGridMap(); } } /// /// Get static grid map pointer (from loaded map file) /// Returns IntPtr to xloc_occupancy_grid_t (must be freed with xloc_free_occupancy_grid) /// public IntPtr GetStaticGridMapPtr(bool reloadFromFile = false) { lock (_lock) { if (_xlocClient == null || !_isInitialized) return IntPtr.Zero; return _xlocClient.GetStaticGridMapPtr(reloadFromFile); } } /// /// Get online grid map pointer (from SLAM) /// Returns IntPtr to xloc_occupancy_grid_t (must be freed with xloc_free_occupancy_grid) /// public IntPtr GetOnlineGridMapPtr() { lock (_lock) { if (_xlocClient == null || !_isInitialized) return IntPtr.Zero; return _xlocClient.GetOnlineGridMapPtr(); } } public (double x, double y, double yaw)? GetCurrentPose2D() { var pose = GetCurrentPose(); if (!pose.HasValue) return null; var (x, y, z, qx, qy, qz, qw) = pose.Value; // Convert quaternion to yaw angle double yaw = Math.Atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)); return (x, y, yaw); } /// /// Get sampled laser scan data for web visualization (1 degree intervals) /// Returns array of (angle, range) pairs /// Thread-safe /// public List<(float angle, float range)>? GetSampledLaserScan() { return GetSampledLaserScanInternal(_cachedLaserScan1, 360); } /// /// Get sampled laser scan data from Lidar 2 (right side) /// public List<(float angle, float range)>? GetSampledLaserScan2() { return GetSampledLaserScanInternal(_cachedLaserScan2, 360); } /// /// Get sampled laser scan data from Lidar 3 (left side) /// public List<(float angle, float range)>? GetSampledLaserScan3() { return GetSampledLaserScanInternal(_cachedLaserScan3, 360); } /// /// Get full laser scan data (no downsampling) from Lidar 1 for web visualization. /// public List<(float angle, float range)>? GetFullLaserScan() { return GetFullLaserScanInternal(_cachedLaserScan1); } /// /// Get full laser scan data (no downsampling) from Lidar 2. /// public List<(float angle, float range)>? GetFullLaserScan2() { return GetFullLaserScanInternal(_cachedLaserScan2); } /// /// Get full laser scan data (no downsampling) from Lidar 3. /// public List<(float angle, float range)>? GetFullLaserScan3() { return GetFullLaserScanInternal(_cachedLaserScan3); } /// /// Internal method to sample laser scan data /// private List<(float angle, float range)>? GetSampledLaserScanInternal(LaserScan? cachedScan, int maxPoints) { lock (_laserCacheLock) { if (cachedScan == null) return null; var scan = cachedScan.Value; if (scan.Ranges == null || scan.Ranges.Length == 0) return null; var result = new List<(float angle, float range)>(); // Target: 1 degree = π/180 radians ≈ 0.01745 rad const float targetAngleIncrement = (float)(Math.PI / 180.0); // Calculate how many indices to skip for ~1 degree spacing int skipCount = Math.Max(1, (int)Math.Round(targetAngleIncrement / scan.AngleIncrement)); // Sample points at ~1 degree intervals for (int i = 0; i < scan.Ranges.Length; i += skipCount) { float range = (float)scan.Ranges[i]; // Only include valid ranges (within min/max limits) if (range >= scan.RangeMin && range <= scan.RangeMax && !float.IsNaN(range) && !float.IsInfinity(range)) { float angle = (float)(scan.AngleMin + i * scan.AngleIncrement); result.Add((angle, range)); // Limit to maxPoints per lidar if (result.Count >= maxPoints) break; } } return result; } } /// /// Internal method to return full-resolution laser scan points. /// private List<(float angle, float range)>? GetFullLaserScanInternal(LaserScan? cachedScan) { lock (_laserCacheLock) { if (cachedScan == null) return null; var scan = cachedScan.Value; if (scan.Ranges == null || scan.Ranges.Length == 0) return null; var result = new List<(float angle, float range)>(scan.Ranges.Length); for (int i = 0; i < scan.Ranges.Length; i++) { float range = (float)scan.Ranges[i]; // Keep all finite points from iLidar data (no downsampling). if (!float.IsNaN(range) && !float.IsInfinity(range)) { float angle = (float)(scan.AngleMin + i * scan.AngleIncrement); result.Add((angle, range)); } } return result; } } /// /// Log XLOC pose and diagnostics to console (called by timer) /// private void LogPoseAndDiagnostics(object? state) { XlocClient? client = null; lock (_lock) { if (!_isInitialized || _xlocClient == null) return; client = _xlocClient; } if (client == null) return; try { // Get and log current pose var pose = client.GetCurrentPose(); if (pose.HasValue) { double yaw = Math.Atan2( 2 * (pose.Value.qw * pose.Value.qz + pose.Value.qx * pose.Value.qy), 1 - 2 * (pose.Value.qy * pose.Value.qy + pose.Value.qz * pose.Value.qz) ) * 180.0 / Math.PI; // IMPORTANT: Publish map→odom transform based on current localized pose // This updates the TF3 buffer with the latest localization result // Navigation uses this transform to know where the robot is in the map frame // client.PublishMapToOdomTransform(); } else { _logger.LogWarning("XLOC POSE: No data available from xloc_get_current_pose()"); } // Get and log diagnostics // Log sensor dispatch frequency every ~2 seconds var now = DateTime.UtcNow; var elapsed = (now - _lastFreqSnapshotTime).TotalSeconds; if (elapsed >= 2.0) { var odomHz = (Interlocked.Read(ref _odometryCount) - _lastOdomCountSnapshot) / elapsed; var imuHz = (Interlocked.Read(ref _imuCount) - _lastImuCountSnapshot) / elapsed; var l1Hz = (Interlocked.Read(ref _lidar1DispatchCount) - _lastLidar1CountSnapshot) / elapsed; var l2Hz = (Interlocked.Read(ref _lidar2DispatchCount) - _lastLidar2CountSnapshot) / elapsed; var l3Hz = (Interlocked.Read(ref _lidar3DispatchCount) - _lastLidar3CountSnapshot) / elapsed; _logger.LogInformation( "[XLOC-FREQ] Odom: {O:F1}Hz, IMU: {I:F1}Hz, L1: {L1:F1}Hz, L2: {L2:F1}Hz, L3: {L3:F1}Hz", odomHz, imuHz, l1Hz, l2Hz, l3Hz); _lastOdomCountSnapshot = Interlocked.Read(ref _odometryCount); _lastImuCountSnapshot = Interlocked.Read(ref _imuCount); _lastLidar1CountSnapshot = Interlocked.Read(ref _lidar1DispatchCount); _lastLidar2CountSnapshot = Interlocked.Read(ref _lidar2DispatchCount); _lastLidar3CountSnapshot = Interlocked.Read(ref _lidar3DispatchCount); _lastFreqSnapshotTime = now; } } catch (Exception ex) { _logger.LogError(ex, "Error logging XLOC data to console"); } } #region Manual Control Methods /// /// Manually activate a map for localization /// public bool ActivateMap(string mapFilePath) { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot activate map: XlocIntegrationService not initialized"); return false; } return _xlocClient.ActivateMap(mapFilePath); } } /// /// Set initial pose: Tell XLOC where the ROBOT is located in the FIXED map frame /// /// IMPORTANT: /// - This sets the ROBOT's position (x, y, z) and orientation (roll, pitch, yaw) IN the map frame /// - The MAP FRAME itself NEVER moves (always fixed at origin 0,0,0) /// - Only the robot's estimated position in the map frame changes /// /// Parameters: (x, y, z, roll, pitch, yaw) all in map frame coordinates /// - Position (x, y, z): meters in map frame /// - Orientation (roll, pitch, yaw): radians (converted to quaternion internally) /// public bool SetInitialPose(double x, double y, double z, double roll, double pitch, double yaw) { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot set initial pose: XlocIntegrationService not initialized"); return false; } // Guard: skip SetInitialPose if XLOC is in MAPPING (0) or PROCESSING (2) state var diagnostics = _xlocClient.GetDiagnostics(); if (diagnostics != null && diagnostics.XlocState is 0 or 2) { _logger.LogWarning("Skip SetInitialPose because XLOC state is {State} (0=MAPPING, 2=PROCESSING)", diagnostics.XlocState); return false; } // Get current pose BEFORE setting initial pose var poseBefore = _xlocClient.GetCurrentPose(); _logger.LogWarning("=== XLOC SetInitialPose - BEFORE ==="); if (poseBefore.HasValue) { var (px, py, pz, pqx, pqy, pqz, pqw) = poseBefore.Value; // Convert quaternion to yaw for logging double yawBefore = Math.Atan2(2.0 * (pqw * pqz + pqx * pqy), 1.0 - 2.0 * (pqy * pqy + pqz * pqz)); } else { _logger.LogWarning("Current pose: NULL (no pose available)"); } // Convert Euler angles (roll, pitch, yaw) to quaternion using full formula var cy = Math.Cos(yaw * 0.5); var sy = Math.Sin(yaw * 0.5); var cp = Math.Cos(pitch * 0.5); var sp = Math.Sin(pitch * 0.5); var cr = Math.Cos(roll * 0.5); var sr = Math.Sin(roll * 0.5); var qw = cr * cp * cy + sr * sp * sy; var qx = sr * cp * cy - cr * sp * sy; var qy = cr * sp * cy + sr * cp * sy; var qz = cr * cp * sy - sr * sp * cy; var result = _xlocClient.SetInitialPose(x, y, z, qx, qy, qz, qw); if (result) { // Get current pose AFTER setting initial pose System.Threading.Thread.Sleep(50); // Small delay to let XLOC process var poseAfter = _xlocClient.GetCurrentPose(); if (poseAfter.HasValue) { var (px, py, pz, pqx, pqy, pqz, pqw) = poseAfter.Value; double yawAfter = Math.Atan2(2.0 * (pqw * pqz + pqx * pqy), 1.0 - 2.0 * (pqy * pqy + pqz * pqz)); var dx = Math.Abs(px - x); var dy = Math.Abs(py - y); var dz = Math.Abs(pz - z); var dyaw = Math.Abs(yawAfter - yaw); } } return result; } } /// /// Change (re-anchor) the map coordinate system origin. /// Takes an (x, y, z) offset in meters and Euler angles (roll, pitch, yaw) in radians, /// converts them to a pose, and invokes the native XLOC change_map_origin. /// public bool ChangeMapOrigin(double x, double y, double z, double roll, double pitch, double yaw) { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot change map origin: XlocIntegrationService not initialized"); return false; } // Block when XLOC is MAPPING (0) or PROCESSING (2) to avoid inconsistent map state var diagnostics = _xlocClient.GetDiagnostics(); if (diagnostics != null && diagnostics.XlocState is 0 or 2) { _logger.LogWarning("Skip ChangeMapOrigin because XLOC state is {State} (0=MAPPING, 2=PROCESSING)", diagnostics.XlocState); return false; } var cy = Math.Cos(yaw * 0.5); var sy = Math.Sin(yaw * 0.5); var cp = Math.Cos(pitch * 0.5); var sp = Math.Sin(pitch * 0.5); var cr = Math.Cos(roll * 0.5); var sr = Math.Sin(roll * 0.5); var qw = cr * cp * cy + sr * sp * sy; var qx = sr * cp * cy - cr * sp * sy; var qy = cr * sp * cy + sr * cp * sy; var qz = cr * cp * sy - sr * sp * cy; _logger.LogInformation("ChangeMapOrigin -> pos=({X:F3}, {Y:F3}, {Z:F3}) yaw={Yaw:F3} rad", x, y, z, yaw); var result = _xlocClient.ChangeMapOrigin(x, y, z, qx, qy, qz, qw); if (result) { _logger.LogInformation("Map origin changed successfully"); // Persist the new origin to the map's YAML on disk so that subsequent calls to // /api/xloc/gridmap/static (which override the native origin with the YAML value) // return the same origin that native XLOC now uses. Without this, the map bytes // and the origin served to the client are desynchronized and the map renders // in the wrong place until the user reloads the map. try { var nativeGrid = _xlocClient.GetStaticGridMap(reloadFromFile: false); var activeMapName = diagnostics?.CurrentActiveMap; if (nativeGrid != null && !string.IsNullOrWhiteSpace(activeMapName)) { var newYaw = QuaternionToYaw( nativeGrid.Origin.Qx, nativeGrid.Origin.Qy, nativeGrid.Origin.Qz, nativeGrid.Origin.Qw); UpdateMapYamlOrigin(activeMapName!, nativeGrid.Origin.X, nativeGrid.Origin.Y, newYaw); } else { _logger.LogWarning("ChangeMapOrigin: cannot persist YAML origin (native grid or active map name missing)"); } } catch (Exception ex) { _logger.LogError(ex, "ChangeMapOrigin: failed to persist new origin to YAML"); } } else { _logger.LogWarning("Failed to change map origin"); } return result; } } private static double QuaternionToYaw(double qx, double qy, double qz, double qw) { // ZYX yaw: atan2(2*(qw*qz + qx*qy), 1 - 2*(qy^2 + qz^2)) return Math.Atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)); } /// /// Rewrite the `origin: [x, y, yaw]` line of the active map's YAML file so the /// static-gridmap endpoint (which reads origin from YAML) returns the same value /// that native XLOC now has in memory after xloc_change_map_origin. /// private void UpdateMapYamlOrigin(string activeMapName, double x, double y, double yaw) { var mapsDir = XlocPaths.GetMapsDirectory(); var folderName = System.IO.Path.GetFileName(activeMapName.TrimEnd(System.IO.Path.DirectorySeparatorChar, '/')); if (string.IsNullOrEmpty(folderName)) { _logger.LogWarning("UpdateMapYamlOrigin: active map name '{Name}' did not resolve to a folder", activeMapName); return; } var mapFolder = System.IO.Path.Combine(mapsDir, folderName); if (!System.IO.Directory.Exists(mapFolder)) { _logger.LogWarning("UpdateMapYamlOrigin: map folder not found: {Folder}", mapFolder); return; } var yamlFiles = System.IO.Directory.GetFiles(mapFolder, "*.yaml"); if (yamlFiles.Length == 0) { _logger.LogWarning("UpdateMapYamlOrigin: no YAML file found in {Folder}", mapFolder); return; } var yamlPath = yamlFiles[0]; var ci = System.Globalization.CultureInfo.InvariantCulture; var newOriginLine = string.Format(ci, "origin: [{0:F6}, {1:F6}, {2:F6}]", x, y, yaw); var lines = System.IO.File.ReadAllLines(yamlPath); bool replaced = false; for (int i = 0; i < lines.Length; i++) { if (lines[i].TrimStart().StartsWith("origin:", StringComparison.Ordinal)) { lines[i] = newOriginLine; replaced = true; break; } } if (!replaced) { // Preserve the rest of the file by appending an origin line if one wasn't present. var appended = new string[lines.Length + 1]; Array.Copy(lines, appended, lines.Length); appended[lines.Length] = newOriginLine; lines = appended; } System.IO.File.WriteAllLines(yamlPath, lines); _logger.LogInformation("UpdateMapYamlOrigin: {Yaml} -> {Line}", yamlPath, newOriginLine); } /// /// Manually start localization mode /// public bool StartLocalization() { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot start localization: XlocIntegrationService not initialized"); return false; } _logger.LogInformation("Attempting to start localization..."); var result = _xlocClient.StartLocalization(); if (result) { _logger.LogInformation("Localization started manually"); } else { _logger.LogWarning("Failed to start localization - check previous log messages for error details"); } return result; } } /// /// Manually stop localization mode /// public bool StopLocalization() { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot stop localization: XlocIntegrationService not initialized"); return false; } var result = _xlocClient.StopLocalization(); if (result) { LastLocalizationStopUtc = DateTime.UtcNow; _logger.LogInformation("Localization stopped manually"); } return result; } } /// /// Manually start mapping mode /// public bool StartMapping() { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot start mapping: XlocIntegrationService not initialized"); return false; } var result = _xlocClient.StartMapping(); if (result) { _logger.LogInformation("Mapping started manually"); } return result; } } /// /// Manually stop mapping and save to file /// public bool StopMapping(string saveMapFilePath) { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot stop mapping: XlocIntegrationService not initialized"); return false; } var result = _xlocClient.StopMapping(saveMapFilePath); if (result) { _logger.LogInformation("Mapping stopped and saved to: {MapFile}", saveMapFilePath); } return result; } } /// /// Reset SLAM error state - clears previous trajectory state /// public bool ResetSlamError() { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot reset SLAM error: XlocIntegrationService not initialized"); return false; } _logger.LogInformation("Resetting SLAM error state..."); var result = _xlocClient.ResetSlamError(); if (result) { _logger.LogInformation("SLAM error reset successfully"); } else { _logger.LogWarning("Failed to reset SLAM error"); } return result; } } /// /// Start updating existing map (only allowed when in localization mode) /// public bool StartUpdateMap() { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot start update map: XlocIntegrationService not initialized"); return false; } _logger.LogInformation("Attempting to start map update..."); var result = _xlocClient.StartUpdateMap(); if (result) { _logger.LogInformation("Map update started successfully"); } else { _logger.LogWarning("Failed to start map update - check that robot is in localization mode"); } return result; } } /// /// Stop updating map and optionally save the updated map /// public bool StopUpdateMap(bool saveUpdatedMap = true) { lock (_lock) { if (!_isInitialized || _xlocClient == null) { _logger.LogWarning("Cannot stop update map: XlocIntegrationService not initialized"); return false; } _logger.LogInformation("Stopping map update (save={Save})...", saveUpdatedMap); var result = _xlocClient.StopUpdateMap(saveUpdatedMap); if (result) { _logger.LogInformation("Map update stopped successfully (save={Save})", saveUpdatedMap); } else { _logger.LogWarning("Failed to stop map update"); } return result; } } #endregion /// /// Broadcast current pose to all SignalR clients /// private async Task BroadcastPoseToClientsAsync() { if (_poseHubContext == null) return; try { var pose = GetCurrentPose(); if (!pose.HasValue) return; var (x, y, z, qx, qy, qz, qw) = pose.Value; // Convert quaternion to yaw double yaw = Math.Atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)); var poseData = new XlocPoseData { X = x, Y = y, Z = z, Yaw = yaw, QuaternionX = qx, QuaternionY = qy, QuaternionZ = qz, QuaternionW = qw, Timestamp = DateTime.UtcNow, IsActive = _isInitialized, XlocState = 0, // Not available without diagnostics call MapName = string.Empty, Reliability = 0.0, MatchingScore = 0.0 }; await _poseHubContext.Clients.All.SendAsync("ReceivePose", poseData); } catch (Exception ex) { _logger.LogError(ex, "Error broadcasting pose to SignalR clients"); } } public void Dispose() { if (_disposed) return; _disposed = true; try { // Stop event handlers first lock (_lock) { _isInitialized = false; } UnregisterLidarEventDispatch(); Thread.Sleep(500); UnregisterOdomImuEventDispatch(); _imuTimer?.Dispose(); _odomTimer?.Dispose(); _lidarTimer?.Dispose(); _loggingTimer?.Dispose(); _asyncDispatcher?.Dispose(); _xlocClient?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing XlocIntegrationService"); } } } /// /// Xloc operation mode /// public enum XlocMode { None, Localization, Mapping } /// /// Configuration for XlocIntegrationService /// public class XlocIntegrationConfiguration { /// /// Enable/disable the integration service /// public bool Enabled { get; set; } = false; /// /// Operation mode: Localization or Mapping /// public XlocMode Mode { get; set; } = XlocMode.Localization; /// /// IMU data dispatch interval in milliseconds (default: 1ms = 1000Hz) /// public int ImuDispatchIntervalMs { get; set; } = 1; /// /// Odometry data dispatch interval in milliseconds (default: 1ms = 1000Hz) /// public int OdomDispatchIntervalMs { get; set; } = 1; /// /// Lidar data dispatch interval in milliseconds (default: 1ms = 1000Hz) /// public int LidarDispatchIntervalMs { get; set; } = 1; /// /// Update rate in Hz (DEPRECATED - use individual sensor intervals instead) /// [Obsolete("Use ImuDispatchIntervalMs, OdomDispatchIntervalMs, LidarDispatchIntervalMs instead")] public int UpdateRateHz { get; set; } = 20; /// /// Path to map file for localization mode /// public string MapFilePath { get; set; } = string.Empty; /// /// Path to save map file when stopping mapping mode /// public string SaveMapFilePath { get; set; } = string.Empty; /// /// Enable dispatching EKF filtered odometry /// public bool EnableEKFOdometry { get; set; } = true; /// /// Sensor ID for EKF odometry /// public string EKFOdometrySensorId { get; set; } = "ekf_odometry"; /// /// Enable dispatching raw wheel odometry (usually not needed if EKF is enabled) /// public bool EnableRawOdometry { get; set; } = false; /// /// Sensor ID for raw odometry /// public string RawOdometrySensorId { get; set; } = "wheel_odometry"; /// /// Enable dispatching IMU data /// public bool EnableIMU { get; set; } = true; /// /// IMU device ID /// public string ImuDeviceId { get; set; } = string.Empty; /// /// Sensor ID for IMU /// public string ImuSensorId { get; set; } = "imu0"; /// /// IMU frame ID /// public string ImuFrameId { get; set; } = "imu_link"; /// /// Enable dispatching LIDAR data /// public bool EnableLidar1 { get; set; } = true; public bool EnableLidar2 { get; set; } = true; public bool EnableLidar3 { get; set; } = true; /// /// LIDAR device ID /// public string Lidar1DeviceId { get; set; } = "scan_1"; /// /// Sensor ID for LIDAR 1 (rear center) /// public string Lidar1SensorId { get; set; } = "scan_1"; /// /// LIDAR 2 device ID (right side) /// public string Lidar2DeviceId { get; set; } = "scan_2"; /// /// Sensor ID for LIDAR 2 (right side) /// public string Lidar2SensorId { get; set; } = "scan_2"; /// /// LIDAR 3 device ID (left side) /// public string Lidar3DeviceId { get; set; } = "scan_3"; /// /// Sensor ID for LIDAR 3 (left side) /// public string Lidar3SensorId { get; set; } = "scan_3"; /// /// Interval in seconds to log statistics /// public int StatsLogIntervalSeconds { get; set; } = 10; /// /// When true, polls diagnostics and calls /// StartLocalization when XLOC enters READY (3) with an active map. /// public bool AutoStartLocalizationOnReady { get; set; } /// /// How often to poll diagnostics for auto-start (milliseconds). /// public int AutoStartLocalizationPollIntervalMs { get; set; } = 500; /// /// After a successful stop, do not auto-start localization for this many milliseconds. /// public int AutoStartLocalizationCooldownAfterStopMs { get; set; } = 10_000; }