using CommunityToolkit.Mvvm.ComponentModel; using FieldLogger.Models; using FieldLogger.Services.Ble; using Microsoft.Extensions.Logging; namespace FieldLogger.Services; public enum ConnectionState { Disconnected, Connecting, Connected } /// /// Owns the lifetime of both device connections: auto-connects to saved devices /// at startup, reconnects with backoff when a connection drops, and exposes /// bindable state for the UI. /// public sealed partial class DeviceConnectionManager : ObservableObject { private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)]; private readonly UmReceiverService _locator; private readonly MaglinkService _gps; private readonly SettingsService _settings; private readonly ILogger _logger; private CancellationTokenSource? _locatorCts; private CancellationTokenSource? _gpsCts; [ObservableProperty] private ConnectionState _locatorState; [ObservableProperty] private ConnectionState _gpsState; [ObservableProperty] private string? _locatorName; [ObservableProperty] private string? _gpsName; public UmReceiverService Locator => _locator; public MaglinkService Gps => _gps; public DeviceConnectionManager(UmReceiverService locator, MaglinkService gps, SettingsService settings, ILogger logger) { _locator = locator; _gps = gps; _settings = settings; _logger = logger; _locator.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.Locator); _gps.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.RtkGps); LocatorName = settings.GetSavedDeviceName(DeviceKind.Locator); GpsName = settings.GetSavedDeviceName(DeviceKind.RtkGps); } /// True when a device of this kind has been selected at least once. public bool HasSavedDevice(DeviceKind kind) => _settings.GetSavedDeviceId(kind) is not null; /// Kicks off auto-connect attempts for every saved device. Safe to call repeatedly. public void Start() { if (HasSavedDevice(DeviceKind.Locator) && LocatorState == ConnectionState.Disconnected) _ = ConnectLoopAsync(DeviceKind.Locator); if (HasSavedDevice(DeviceKind.RtkGps) && GpsState == ConnectionState.Disconnected) _ = ConnectLoopAsync(DeviceKind.RtkGps); } /// Saves a newly selected device and connects to it, replacing any previous device. public async Task UseDeviceAsync(DeviceKind kind, Guid id, string name) { CancelLoop(kind); await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync()); _settings.SaveDevice(kind, id, name); if (kind == DeviceKind.Locator) LocatorName = name; else GpsName = name; _ = ConnectLoopAsync(kind); } public async Task ForgetDeviceAsync(DeviceKind kind) { CancelLoop(kind); await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync()); _settings.ClearDevice(kind); if (kind == DeviceKind.Locator) { LocatorName = null; LocatorState = ConnectionState.Disconnected; } else { GpsName = null; GpsState = ConnectionState.Disconnected; } } private void OnDeviceDropped(DeviceKind kind) { _logger.LogInformation("{Kind} connection lost; scheduling reconnect", kind); SetState(kind, ConnectionState.Disconnected); _ = ConnectLoopAsync(kind); } private async Task ConnectLoopAsync(DeviceKind kind) { var id = _settings.GetSavedDeviceId(kind); if (id is null) return; CancelLoop(kind); var cts = new CancellationTokenSource(); if (kind == DeviceKind.Locator) _locatorCts = cts; else _gpsCts = cts; SetState(kind, ConnectionState.Connecting); for (var attempt = 0; !cts.Token.IsCancellationRequested; attempt++) { try { await BleScanner.EnsurePermissionsAsync(); if (kind == DeviceKind.Locator) await _locator.ConnectAsync(id.Value, cts.Token); else await _gps.ConnectAsync(id.Value, cts.Token); SetState(kind, ConnectionState.Connected); _logger.LogInformation("{Kind} connected", kind); return; } catch (OperationCanceledException) { return; } catch (Exception ex) { _logger.LogWarning(ex, "{Kind} connect attempt {Attempt} failed", kind, attempt + 1); var delay = RetryDelays[Math.Min(attempt, RetryDelays.Length - 1)]; try { await Task.Delay(delay, cts.Token); } catch (OperationCanceledException) { return; } } } } private void CancelLoop(DeviceKind kind) { var cts = kind == DeviceKind.Locator ? _locatorCts : _gpsCts; cts?.Cancel(); cts?.Dispose(); if (kind == DeviceKind.Locator) _locatorCts = null; else _gpsCts = null; } private void SetState(DeviceKind kind, ConnectionState state) { // Property change notifications must fire on the UI thread for bindings. MainThread.BeginInvokeOnMainThread(() => { if (kind == DeviceKind.Locator) LocatorState = state; else GpsState = state; }); } }