diff --git a/ci/testing.yml b/ci/testing.yml index edc5e5c..6c19710 100644 --- a/ci/testing.yml +++ b/ci/testing.yml @@ -3,7 +3,7 @@ displayName: 'Install .NET Core SDK' inputs: packageType: 'sdk' - version: '8.x' + version: '10.x' - task: DotNetCoreCLI@2 displayName: 'Unit Test Core' diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index bb9b0ab..5fec805 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -21,6 +21,11 @@ - Provides detailed reports with resource values and comments for cleanup decisions - Can be integrated into Azure DevOps pipelines using `ci/azure-pipeline-resource-check.yml` +## Localization +- **Do not manually translate resource strings** - Translations are handled by a separate automated service +- Only add new resource strings to the main `Resources.resx` file with English values +- The automated translation service will populate the language-specific `.resx` files (de, es, fr, ja, zh) + ## Release Process - Create a release: `pwsh ci/release.ps1` - The script automates the release workflow: diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 8400539..74a5ad1 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -2,7 +2,7 @@ 1.0.2 - net8.0 + net10.0 enable enable OSDPBench.Core @@ -24,8 +24,7 @@ - - + diff --git a/src/Core/Models/PacketTraceEntry.cs b/src/Core/Models/PacketTraceEntry.cs index f5d00c5..0b5a7c8 100644 --- a/src/Core/Models/PacketTraceEntry.cs +++ b/src/Core/Models/PacketTraceEntry.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using OSDP.Net.Messages; using OSDP.Net.Model; using OSDP.Net.Tracing; @@ -44,10 +44,10 @@ public string Type { if (Packet.CommandType != null) { - return ToSpacedString(Packet.CommandType); + return Packet.CommandType.Value.GetDisplayName(); } - return Packet.ReplyType != null ? ToSpacedString(Packet.ReplyType) : "Unknown"; + return Packet.ReplyType != null ? Packet.ReplyType.Value.GetDisplayName() : "Unknown"; } } @@ -63,12 +63,23 @@ public string Type /// This property parses and formats the payload data of the packet, /// or returns "Empty" if no data is available. /// - public string Details => Packet.ParsePayloadData()?.ToString() ?? "Empty"; - - private static string ToSpacedString(Enum enumValue) + public string Details { - // Use Regex to insert spaces before any capital letter followed by a lowercase letter, ignoring the first capital. - return Regex.Replace(enumValue.ToString(), "(? 0 + ? BitConverter.ToString(bytes).Replace("-", " ") + : "Empty"; + } + + return payload.ToString() ?? "Empty"; + } } // Private constructor diff --git a/src/Core/Models/PacketTraceEntryBuilder.cs b/src/Core/Models/PacketTraceEntryBuilder.cs index 8c1668b..302884b 100644 --- a/src/Core/Models/PacketTraceEntryBuilder.cs +++ b/src/Core/Models/PacketTraceEntryBuilder.cs @@ -7,10 +7,45 @@ namespace OSDPBench.Core.Models; /// public class PacketTraceEntryBuilder { + private MessageSpy _messageSpy; private TraceEntry _traceEntry; private PacketTraceEntry? _lastTraceEntry; private DateTime _timestamp; + /// + /// Initializes a new instance of the class + /// with a default MessageSpy (no secure channel decryption). + /// + public PacketTraceEntryBuilder() : this(new MessageSpy()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The MessageSpy instance to use for parsing packets. + /// Should be reused across packets to maintain secure channel state. + public PacketTraceEntryBuilder(MessageSpy messageSpy) + { + _messageSpy = messageSpy; + } + + /// + /// Configures the security key for secure channel decryption. + /// Creates a new MessageSpy with the specified key, resetting secure channel state. + /// + /// The security key for decryption, or null for no encryption. + /// The current builder instance for method chaining. + /// + /// Call this method BEFORE any packets are processed to ensure the MessageSpy + /// can track the secure channel negotiation from the beginning. + /// + public PacketTraceEntryBuilder WithSecurityKey(byte[]? securityKey) + { + _messageSpy = securityKey != null ? new MessageSpy(securityKey) : new MessageSpy(); + return this; + } + /// /// Initializes the instance with the specified trace entry and previous trace entry /// while also recording the current timestamp. @@ -23,18 +58,27 @@ public PacketTraceEntryBuilder FromTraceEntry(TraceEntry traceEntry, PacketTrace _traceEntry = traceEntry; _lastTraceEntry = lastTraceEntry; _timestamp = DateTime.UtcNow; - + return this; } /// /// Creates and returns a new instance of the class. /// - /// A new instance of the class, fully constructed based on the specified trace entry details. - public PacketTraceEntry Build() + /// A new instance of the class, or null if parsing failed. + /// + /// Uses to parse the packet data, which supports secure channel decryption + /// when a security key was provided to the MessageSpy constructor. + /// + public PacketTraceEntry? Build() { + if (!_messageSpy.TryParsePacket(_traceEntry.Data, out var packet) || packet == null) + { + return null; + } + return PacketTraceEntry.Create(_traceEntry.Direction, _timestamp, _lastTraceEntry != null ? _timestamp - _lastTraceEntry.Timestamp : TimeSpan.Zero, - PacketDecoding.ParseMessage(_traceEntry.Data)); + packet); } -} \ No newline at end of file +} diff --git a/src/Core/Resources/Resources.resx b/src/Core/Resources/Resources.resx index 0e1b977..452523b 100644 --- a/src/Core/Resources/Resources.resx +++ b/src/Core/Resources/Resources.resx @@ -279,6 +279,18 @@ An update will be out soon that supports secure channel Message about future secure channel support + + Clear Text + Badge shown when communication is not encrypted + + + Encrypted - Default Key + Badge shown when using secure channel with the default security key + + + Encrypted + Badge shown when using secure channel with a custom security key + TimeStamp Column header for timestamp in monitoring grid diff --git a/src/Core/Services/DeviceManagementService.cs b/src/Core/Services/DeviceManagementService.cs index 776dbc7..fa85eb8 100644 --- a/src/Core/Services/DeviceManagementService.cs +++ b/src/Core/Services/DeviceManagementService.cs @@ -109,6 +109,9 @@ public DeviceManagementService() /// public bool UsesDefaultSecurityKey { get; private set; } + /// + public byte[]? SecurityKey => _securityKey; + /// public bool IsConnected { get; private set; } @@ -243,9 +246,9 @@ private async Task WaitUntilDeviceIsOffline() { return; } - + using var cts = new CancellationTokenSource(_defaultShutdownTimeout); - + // Check if the connection exists before querying its status try { @@ -264,10 +267,47 @@ private async Task WaitUntilDeviceIsOffline() // Connection was already removed from the panel, which is fine during shutdown return; } - + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); } + private async Task WaitUntilDeviceIsOnline() + { + // Skip waiting if we don't have a valid connection + if (_connectionId == Guid.Empty) + { + return; + } + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + try + { + while (!cts.Token.IsCancellationRequested) + { + try + { + if (_panel.IsOnline(_connectionId, Address)) + { + return; + } + } + catch (KeyNotFoundException) + { + // Connection doesn't exist yet, keep waiting + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + } + } + catch (OperationCanceledException) + { + // Timeout occurred + } + + throw new TimeoutException("The device did not come online within the specified timeout."); + } + /// public event EventHandler? ConnectionStatusChange; @@ -304,6 +344,9 @@ public async Task ReconnectAfterCommunicationChange(IOsdpConnection osdpConnecti _connectionId = _panel.StartConnection(osdpConnection, _defaultPollInterval, Tracer); _panel.AddDevice(_connectionId, Address, true, IsUsingSecureChannel, UsesDefaultSecurityKey ? null : _securityKey); + + // Wait for the connection to fully establish before returning + await WaitUntilDeviceIsOnline(); } /// diff --git a/src/Core/Services/IDeviceManagementService.cs b/src/Core/Services/IDeviceManagementService.cs index dc38bbe..ed894f9 100644 --- a/src/Core/Services/IDeviceManagementService.cs +++ b/src/Core/Services/IDeviceManagementService.cs @@ -52,6 +52,15 @@ public interface IDeviceManagementService /// bool UsesDefaultSecurityKey { get; } + /// + /// Gets the security key used for secure channel communication. + /// + /// + /// Returns the custom security key if one was provided, or null if using the default key or not using secure channel. + /// Used by the tracing system to decrypt secure channel traffic. + /// + byte[]? SecurityKey { get; } + /// /// Gets the connection status of the device. /// diff --git a/src/Core/ViewModels/Pages/ConnectViewModel.cs b/src/Core/ViewModels/Pages/ConnectViewModel.cs index f6de50d..01d5c90 100644 --- a/src/Core/ViewModels/Pages/ConnectViewModel.cs +++ b/src/Core/ViewModels/Pages/ConnectViewModel.cs @@ -21,7 +21,10 @@ public partial class ConnectViewModel : ObservableObject, IDisposable private readonly IUsbDeviceMonitorService? _usbDeviceMonitorService; private ISerialPortConnectionService _serialPortConnectionService; + private readonly PacketTraceEntryBuilder _traceEntryBuilder = new(); private PacketTraceEntry? _lastPacketEntry; + private byte[]? _lastConfiguredSecurityKey; + private bool _securityKeyConfigured; private bool _isDisposed; private Timer? _usbStatusTimer; private readonly TaskCompletionSource _initializationComplete = new(); @@ -86,28 +89,53 @@ private void UpdateConnectionTypes() private void OnDeviceManagementServiceOnTraceEntryReceived(object? sender, TraceEntry traceEntry) { + UsingSecureChannel = _deviceManagementService.IsUsingSecureChannel; + UsesDefaultSecurityKey = _deviceManagementService.UsesDefaultSecurityKey; + + // Configure security key on first trace entry or when key changes + // This must happen before processing any packets so MessageSpy can track secure channel state + EnsureSecurityKeyConfigured(); + // Update activity indicators based on a raw trace entry direction (works for encrypted packets too) UpdateActivityIndicators(traceEntry.Direction); - + PacketTraceEntry? packetTraceEntry = BuildPacketTraceEntry(traceEntry); if (packetTraceEntry == null) return; - + _lastPacketEntry = packetTraceEntry; } + private void EnsureSecurityKeyConfigured() + { + var currentKey = _deviceManagementService.SecurityKey; + if (!_securityKeyConfigured || !SecurityKeysEqual(_lastConfiguredSecurityKey, currentKey)) + { + _traceEntryBuilder.WithSecurityKey(currentKey); + _lastConfiguredSecurityKey = currentKey; + _securityKeyConfigured = true; + } + } + + private static bool SecurityKeysEqual(byte[]? key1, byte[]? key2) + { + if (key1 == null && key2 == null) return true; + if (key1 == null || key2 == null) return false; + if (key1.Length != key2.Length) return false; + return key1.AsSpan().SequenceEqual(key2); + } + private PacketTraceEntry? BuildPacketTraceEntry(TraceEntry traceEntry) { try { - var builder = new PacketTraceEntryBuilder(); - return builder.FromTraceEntry(traceEntry, _lastPacketEntry).Build(); + return _traceEntryBuilder.FromTraceEntry(traceEntry, _lastPacketEntry).Build(); } catch (Exception) { return null; } } - + private void UpdateActivityIndicators(TraceDirection direction) { switch (direction) @@ -187,9 +215,13 @@ private void DeviceManagementServiceOnNakReplyReceived(object? sender, string na [ObservableProperty] private string _securityKey = string.Empty; [ObservableProperty] private DateTime _lastTxActiveTime; - + [ObservableProperty] private DateTime _lastRxActiveTime; - + + [ObservableProperty] private bool _usingSecureChannel; + + [ObservableProperty] private bool _usesDefaultSecurityKey; + [ObservableProperty] private string _usbStatusText = string.Empty; /// diff --git a/src/Core/ViewModels/Pages/ManageViewModel.cs b/src/Core/ViewModels/Pages/ManageViewModel.cs index abafac7..c764f58 100644 --- a/src/Core/ViewModels/Pages/ManageViewModel.cs +++ b/src/Core/ViewModels/Pages/ManageViewModel.cs @@ -20,8 +20,11 @@ public partial class ManageViewModel : ObservableObject private readonly IDialogService _dialogService; private readonly IDeviceManagementService _deviceManagementService; private readonly ISerialPortConnectionService _serialPortConnectionService; - + private readonly PacketTraceEntryBuilder _traceEntryBuilder = new(); + private PacketTraceEntry? _lastPacketEntry; + private byte[]? _lastConfiguredSecurityKey; + private bool _securityKeyConfigured; /// public ManageViewModel(IDialogService dialogService, IDeviceManagementService deviceManagementService, ISerialPortConnectionService serialPortConnectionService) @@ -102,10 +105,8 @@ await _dialogService.ShowMessageDialog(Resources.Resources.GetString("Dialog_Upd // Reconnect with new settings before showing success if (_deviceManagementService.PortName != null) { - // Give device time to switch to new settings - await Task.Delay(500); - // Use ReconnectAfterCommunicationChange to avoid waiting for device to go offline on old connection + // This method will wait for the connection to fully establish before returning await _deviceManagementService.ReconnectAfterCommunicationChange( _serialPortConnectionService.GetConnection( _deviceManagementService.PortName, @@ -211,6 +212,13 @@ private void DeviceManagementServiceOnKeypadReadReceived(object? sender, string private void OnDeviceManagementServiceOnTraceEntryReceived(object? sender, TraceEntry traceEntry) { + UsingSecureChannel = _deviceManagementService.IsUsingSecureChannel; + UsesDefaultSecurityKey = _deviceManagementService.UsesDefaultSecurityKey; + + // Configure security key on first trace entry or when key changes + // This must happen before processing any packets so MessageSpy can track secure channel state + EnsureSecurityKeyConfigured(); + // Update activity indicators based on raw trace entry direction (works for encrypted packets too) switch (traceEntry.Direction) { @@ -223,20 +231,39 @@ private void OnDeviceManagementServiceOnTraceEntryReceived(object? sender, Trace break; } - var build = new PacketTraceEntryBuilder(); - PacketTraceEntry packetTraceEntry; + PacketTraceEntry? packetTraceEntry; try { - packetTraceEntry = build.FromTraceEntry(traceEntry, _lastPacketEntry).Build(); + packetTraceEntry = _traceEntryBuilder.FromTraceEntry(traceEntry, _lastPacketEntry).Build(); } catch (Exception) { return; } - + + // Build() can return null if packet parsing failed _lastPacketEntry = packetTraceEntry; } + private void EnsureSecurityKeyConfigured() + { + var currentKey = _deviceManagementService.SecurityKey; + if (!_securityKeyConfigured || !SecurityKeysEqual(_lastConfiguredSecurityKey, currentKey)) + { + _traceEntryBuilder.WithSecurityKey(currentKey); + _lastConfiguredSecurityKey = currentKey; + _securityKeyConfigured = true; + } + } + + private static bool SecurityKeysEqual(byte[]? key1, byte[]? key2) + { + if (key1 == null && key2 == null) return true; + if (key1 == null || key2 == null) return false; + if (key1.Length != key2.Length) return false; + return key1.AsSpan().SequenceEqual(key2); + } + private void UpdateFields() { IdentityLookup = _deviceManagementService.IdentityLookup; @@ -292,6 +319,10 @@ private void DeviceManagementServiceOnConnectionStatusChange(object? sender, Con [ObservableProperty] private object? _deviceActionParameter; [ObservableProperty] private DateTime _lastTxActiveTime; - + [ObservableProperty] private DateTime _lastRxActiveTime; + + [ObservableProperty] private bool _usingSecureChannel; + + [ObservableProperty] private bool _usesDefaultSecurityKey; } \ No newline at end of file diff --git a/src/Core/ViewModels/Pages/MonitorViewModel.cs b/src/Core/ViewModels/Pages/MonitorViewModel.cs index b78a385..4fa1163 100644 --- a/src/Core/ViewModels/Pages/MonitorViewModel.cs +++ b/src/Core/ViewModels/Pages/MonitorViewModel.cs @@ -14,8 +14,12 @@ namespace OSDPBench.Core.ViewModels.Pages; public partial class MonitorViewModel : ObservableObject { private readonly IDeviceManagementService _deviceManagementService; - + private readonly PacketTraceEntryBuilder _traceEntryBuilder = new(); + private PacketTraceEntry? _lastPacketEntry; + private byte[]? _lastConfiguredSecurityKey; + private bool _securityKeyConfigured; + private bool _wasConnected; /// public MonitorViewModel(IDeviceManagementService deviceManagementService) @@ -32,7 +36,16 @@ public MonitorViewModel(IDeviceManagementService deviceManagementService) private void OnDeviceManagementServiceOnConnectionStatusChange(object? _, ConnectionStatus connectionStatus) { - if (connectionStatus == ConnectionStatus.Connected) InitializePollingMetrics(); + // Only clear trace when transitioning from connected to disconnected (session ended) + if (connectionStatus == ConnectionStatus.Disconnected && _wasConnected) + { + InitializePollingMetrics(); + _wasConnected = false; + } + else if (connectionStatus == ConnectionStatus.Connected) + { + _wasConnected = true; + } UpdateConnectionInfo(); @@ -71,6 +84,17 @@ private void InitializePollingMetrics() private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry traceEntry) { UsingSecureChannel = _deviceManagementService.IsUsingSecureChannel; + UsesDefaultSecurityKey = _deviceManagementService.UsesDefaultSecurityKey; + + // Configure security key on first trace entry or when key changes + // This must happen before processing any packets so MessageSpy can track secure channel state + var currentKey = _deviceManagementService.SecurityKey; + if (!_securityKeyConfigured || !SecurityKeysEqual(_lastConfiguredSecurityKey, currentKey)) + { + _traceEntryBuilder.WithSecurityKey(currentKey); + _lastConfiguredSecurityKey = currentKey; + _securityKeyConfigured = true; + } // Update activity indicators based on raw trace entry direction (works for encrypted packets too) switch (traceEntry.Direction) @@ -84,17 +108,30 @@ private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry break; } - var build = new PacketTraceEntryBuilder(); - PacketTraceEntry packetTraceEntry; + PacketTraceEntry? packetTraceEntry; try { - packetTraceEntry = build.FromTraceEntry(traceEntry, _lastPacketEntry).Build(); + packetTraceEntry = _traceEntryBuilder.FromTraceEntry(traceEntry, _lastPacketEntry).Build(); } catch (Exception) { return; } + // If parsing failed, skip this packet but still count it for statistics + if (packetTraceEntry == null) + { + if (traceEntry.Direction == Output) + { + CommandsSent++; + } + else if (traceEntry.Direction == Input) + { + RepliesReceived++; + } + return; + } + // Update statistics if (traceEntry.Direction == Output) { @@ -121,6 +158,18 @@ private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry if (notDisplaying) return; + // Filter out duplicate NAK messages + if (packetTraceEntry.Packet.ReplyType == ReplyType.Nak && TraceEntriesView.Count > 0) + { + var lastDisplayedEntry = TraceEntriesView[0]; + if (lastDisplayedEntry.Packet.ReplyType == ReplyType.Nak && + lastDisplayedEntry.Details == packetTraceEntry.Details) + { + // Skip adding duplicate NAK message + return; + } + } + TraceEntriesView.Insert(0, packetTraceEntry); if (TraceEntriesView.Count > 20) { @@ -137,6 +186,8 @@ private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry [ObservableProperty] private DateTime _lastRxActiveTime; [ObservableProperty] private bool _usingSecureChannel; + + [ObservableProperty] private bool _usesDefaultSecurityKey; [ObservableProperty] private byte _connectedAddress; @@ -186,4 +237,12 @@ partial void OnRepliesReceivedChanged(int value) _ = value; // Intentionally unused - only triggering dependent property notification OnPropertyChanged(nameof(LineQualityPercentage)); } + + private static bool SecurityKeysEqual(byte[]? key1, byte[]? key2) + { + if (key1 == null && key2 == null) return true; + if (key1 == null || key2 == null) return false; + if (key1.Length != key2.Length) return false; + return key1.AsSpan().SequenceEqual(key2); + } } \ No newline at end of file diff --git a/src/UI/Windows/Styles/LayoutTemplates.xaml b/src/UI/Windows/Styles/LayoutTemplates.xaml index cfcfcd1..9e41120 100644 --- a/src/UI/Windows/Styles/LayoutTemplates.xaml +++ b/src/UI/Windows/Styles/LayoutTemplates.xaml @@ -58,7 +58,86 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + diff --git a/src/UI/Windows/Windows.csproj b/src/UI/Windows/Windows.csproj index 27b1ff4..c14085e 100644 --- a/src/UI/Windows/Windows.csproj +++ b/src/UI/Windows/Windows.csproj @@ -2,7 +2,7 @@ WinExe - net8.0-windows + net10.0-windows AnyCPU;x64;ARM64 win-x64;win-arm64 app.manifest @@ -16,10 +16,10 @@ - - + + - + diff --git a/test/Core.Tests/Core.Tests.csproj b/test/Core.Tests/Core.Tests.csproj index bbeb3af..78334e6 100644 --- a/test/Core.Tests/Core.Tests.csproj +++ b/test/Core.Tests/Core.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 false @@ -18,12 +18,12 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/test/Core.Tests/Services/DeviceManagementServiceTests.cs b/test/Core.Tests/Services/DeviceManagementServiceTests.cs index 7e0d500..bc9ca9f 100644 --- a/test/Core.Tests/Services/DeviceManagementServiceTests.cs +++ b/test/Core.Tests/Services/DeviceManagementServiceTests.cs @@ -211,6 +211,140 @@ public async Task Shutdown_ResetsLookupProperties() #endregion + #region ReconnectAfterCommunicationChange Tests + + [Test] + public async Task ReconnectAfterCommunicationChange_UpdatesAddressAndBaudRate() + { + // Arrange + const byte newAddress = 0x01; + const int newBaudRate = 115200; + var newConnectionMock = new Mock(); + newConnectionMock.Setup(x => x.BaudRate).Returns(newBaudRate); + + // Act - This will attempt to reconnect and wait for device online + // Since there's no real device, it will timeout after 10 seconds + // We'll use a try-catch to avoid waiting for the full timeout + try + { + await Task.WhenAny( + _deviceManagementService.ReconnectAfterCommunicationChange(newConnectionMock.Object, newAddress), + Task.Delay(100) // Give it a small window to set properties + ); + } + catch (TimeoutException) + { + // Expected - no real device to connect to + } + + // Assert - Properties should be updated even if connection times out + Assert.That(_deviceManagementService.Address, Is.EqualTo(newAddress)); + Assert.That(_deviceManagementService.BaudRate, Is.EqualTo(newBaudRate)); + } + + [Test] + public async Task ReconnectAfterCommunicationChange_PreservesSecuritySettings() + { + // Arrange + const byte initialAddress = 0x7F; + const bool useSecureChannel = true; + const bool useDefaultSecurityKey = false; + byte[] customKey = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + + // First connect with security settings + await _deviceManagementService.Connect(_connectionMock.Object, initialAddress, useSecureChannel, useDefaultSecurityKey, customKey); + + // Verify initial security settings + Assert.That(_deviceManagementService.IsUsingSecureChannel, Is.True); + Assert.That(_deviceManagementService.UsesDefaultSecurityKey, Is.False); + + // Arrange for reconnection + const byte newAddress = 0x01; + const int newBaudRate = 115200; + var newConnectionMock = new Mock(); + newConnectionMock.Setup(x => x.BaudRate).Returns(newBaudRate); + + // Act - Reconnect with new communication parameters + try + { + await Task.WhenAny( + _deviceManagementService.ReconnectAfterCommunicationChange(newConnectionMock.Object, newAddress), + Task.Delay(100) + ); + } + catch (TimeoutException) + { + // Expected - no real device to connect to + } + + // Assert - Security settings should be preserved + Assert.That(_deviceManagementService.Address, Is.EqualTo(newAddress)); + Assert.That(_deviceManagementService.BaudRate, Is.EqualTo(newBaudRate)); + Assert.That(_deviceManagementService.IsUsingSecureChannel, Is.True, + "Secure channel setting should be preserved after reconnection"); + Assert.That(_deviceManagementService.UsesDefaultSecurityKey, Is.False, + "Custom security key setting should be preserved after reconnection"); + } + + [Test] + public async Task ReconnectAfterCommunicationChange_WaitsForDeviceOnline() + { + // Arrange + const byte newAddress = 0x01; + const int newBaudRate = 115200; + var newConnectionMock = new Mock(); + newConnectionMock.Setup(x => x.BaudRate).Returns(newBaudRate); + + // Act & Assert - Verify that the method doesn't return immediately + // Start the reconnection task + var startTime = DateTime.Now; + var reconnectTask = _deviceManagementService.ReconnectAfterCommunicationChange(newConnectionMock.Object, newAddress); + + // Wait a short time and verify the task is still running (not completed immediately) + await Task.Delay(500); + Assert.That(reconnectTask.IsCompleted, Is.False, + "ReconnectAfterCommunicationChange should wait for device to come online, not return immediately"); + + // Wait for the task to complete (or timeout) + try + { + await reconnectTask; + } + catch (TimeoutException) + { + // Expected - no real device to connect to + } + + var elapsed = DateTime.Now - startTime; + + // The method should have waited for a significant amount of time (approaching the 10-second timeout) + Assert.That(elapsed.TotalSeconds, Is.GreaterThan(9.5), + "Method should wait for approximately 10 seconds before timing out"); + } + + #endregion + + #region Reconnect Tests + + [Test] + public async Task Reconnect_UpdatesAddressAndBaudRate() + { + // Arrange + const byte newAddress = 0x02; + const int newBaudRate = 57600; + var newConnectionMock = new Mock(); + newConnectionMock.Setup(x => x.BaudRate).Returns(newBaudRate); + + // Act + await _deviceManagementService.Reconnect(newConnectionMock.Object, newAddress); + + // Assert + Assert.That(_deviceManagementService.Address, Is.EqualTo(newAddress)); + Assert.That(_deviceManagementService.BaudRate, Is.EqualTo(newBaudRate)); + } + + #endregion + #region Event Tests [Test]