diff --git a/src/AdaptiveRemote.App/Services/Broadlink/BroadlinkSettings.cs b/src/AdaptiveRemote.App/Services/Broadlink/BroadlinkSettings.cs index b37eb74..3e10629 100644 --- a/src/AdaptiveRemote.App/Services/Broadlink/BroadlinkSettings.cs +++ b/src/AdaptiveRemote.App/Services/Broadlink/BroadlinkSettings.cs @@ -18,4 +18,14 @@ public class BroadlinkSettings /// for devices. /// public double ScanTimeout { get; set; } = 3; + + /// + /// Optional discovery port override for testing. If not set, uses port 80. + /// + public int? DiscoveryPort { get; set; } + + /// + /// Optional discovery address override for testing. If not set, uses broadcast address. + /// + public string? DiscoveryAddress { get; set; } } diff --git a/src/AdaptiveRemote.App/Services/Broadlink/ISocket.cs b/src/AdaptiveRemote.App/Services/Broadlink/ISocket.cs index b26710a..0492deb 100644 --- a/src/AdaptiveRemote.App/Services/Broadlink/ISocket.cs +++ b/src/AdaptiveRemote.App/Services/Broadlink/ISocket.cs @@ -9,6 +9,11 @@ namespace AdaptiveRemote.Services.Broadlink; /// public interface ISocket : IDisposable { + /// + /// Gets the local endpoint of the socket. + /// + EndPoint? LocalEndPoint { get; } + /// /// Sends data to the specified remote host. /// diff --git a/src/AdaptiveRemote.App/Services/Broadlink/SocketWrapper.cs b/src/AdaptiveRemote.App/Services/Broadlink/SocketWrapper.cs index e289f6e..84ee658 100644 --- a/src/AdaptiveRemote.App/Services/Broadlink/SocketWrapper.cs +++ b/src/AdaptiveRemote.App/Services/Broadlink/SocketWrapper.cs @@ -14,6 +14,8 @@ private SocketWrapper(Socket socket) _socket = socket; } + EndPoint? ISocket.LocalEndPoint => _socket.LocalEndPoint; + ValueTask ISocket.SendToAsync(ReadOnlyMemory packet, EndPoint endPoint, CancellationToken cancellationToken) => _socket.SendToAsync(packet, endPoint, cancellationToken); ValueTask ISocket.ReceiveFromAsync(Memory buffer, EndPoint remoteEP, CancellationToken cancellationToken) diff --git a/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs b/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs index 5c65874..af06d48 100644 --- a/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs +++ b/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs @@ -34,7 +34,12 @@ IAsyncEnumerable IUdpService.BroadcastAsync(ScanRequestPacke _ = Task.Run(async () => { - IPEndPoint discoverEndPoint = new(IPAddress.Broadcast, 80); + // Use configured discovery endpoint or default to broadcast on port 80 + IPAddress discoveryIp = _settings.DiscoveryAddress != null + ? IPAddress.Parse(_settings.DiscoveryAddress) + : IPAddress.Broadcast; + int discoveryPort = _settings.DiscoveryPort ?? 80; + IPEndPoint discoverEndPoint = new(discoveryIp, discoveryPort); DateTime startTime = DateTime.Now; TimeSpan timeout = TimeSpan.FromSeconds(_settings.ScanTimeout); @@ -44,6 +49,13 @@ IAsyncEnumerable IUdpService.BroadcastAsync(ScanRequestPacke { using ISocket socket = _socketFactory.CreateForBroadcast(); + // Populate LocalPort and LocalIPAddress in the scan packet + if (socket.LocalEndPoint is IPEndPoint localEndPoint) + { + packet.LocalPort = (short)localEndPoint.Port; + packet.LocalIPAddress = localEndPoint.Address.GetAddressBytes(); + } + while (DateTime.Now - startTime < timeout) { TimeSpan timeLeft = timeout - (DateTime.Now - startTime); diff --git a/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature b/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature new file mode 100644 index 0000000..25c8f3d --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature @@ -0,0 +1,17 @@ +Feature: Broadlink Device Integration + As a user + I want the application to communicate with Broadlink IR devices + So that I can control my TV and AV equipment using the adaptive remote + +Scenario: Broadlink receives Power command + Given the application is not running + When I start the application + Then I should see the application in the Ready phase + And I should not see any warning or error messages in the logs + When I click on the 'Power' button + Then I should see the Broadlink device recorded at least one inbound packet + And the recorded Broadlink packet's raw payload should not be empty + And no Broadlink packets should be marked as malformed + When I click on the 'Exit' button + And I wait for the application to shut down + Then I should not see any warning or error messages in the logs diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs new file mode 100644 index 0000000..9a062a6 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs @@ -0,0 +1,70 @@ +using AdaptiveRemote.EndtoEndTests; +using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps; + +[Binding] +public class BroadlinkSteps : StepsBase +{ + [Then(@"I should see the Broadlink device recorded at least one inbound packet")] + public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneInboundPacket() + { + ISimulatedBroadlinkDevice? device = Environment.Broadlink; + if (device == null) + { + Assert.Fail("Broadlink device is not running"); + } + + // Poll for packets with a timeout of 10 seconds + bool found = WaitHelpers.ExecuteWithRetries(device.HasRecordedInboundPacketWithIrData, timeoutInSeconds: 10); + + if (!found) + { + string recordedPackets = device.GetRecordedPacketsDebugString(); + Assert.Fail($"Expected Broadlink device to record at least one inbound packet with IR data, but none were found. Recorded packets: {recordedPackets}"); + } + + TestContext.WriteLine("Successfully verified Broadlink device recorded inbound packet with IR data"); + } + + [Then(@"the recorded Broadlink packet's raw payload should not be empty")] + public void ThenTheRecordedBroadlinkPacketRawPayloadShouldNotBeEmpty() + { + ISimulatedBroadlinkDevice? device = Environment.Broadlink; + if (device == null) + { + Assert.Fail("Broadlink device is not running"); + } + + RecordedPacket? irPacket = device.GetFirstPacketWithIrData(); + + if (irPacket == null) + { + Assert.Fail("No packet with IR payload was recorded"); + } + + Assert.IsTrue(irPacket.RawPayload!.Length > 0, "IR payload should not be empty"); + TestContext.WriteLine($"IR payload size: {irPacket.RawPayload.Length} bytes"); + } + + [Then(@"no Broadlink packets should be marked as malformed")] + public void ThenNoBroadlinkPacketsShouldBeMarkedAsMalformed() + { + ISimulatedBroadlinkDevice? device = Environment.Broadlink; + if (device == null) + { + Assert.Fail("Broadlink device is not running"); + } + + RecordedPacket? malformedPacket = device.GetFirstMalformedPacket(); + + if (malformedPacket != null) + { + Assert.Fail($"Found malformed packet: {malformedPacket.DebugDescription}"); + } + + TestContext.WriteLine("No malformed packets found"); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index beac06a..ee618ea 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -11,28 +11,23 @@ namespace AdaptiveRemote.EndToEndTests.Steps; [Binding] public class HostSteps : StepsBase { - private const string TiVoDeviceName = "TiVo"; private string LogFilePath => Path.Combine(TestContext.TestResultsDirectory!, TestContext.TestName + ".log"); [BeforeScenario(Order = 50)] public void OnBeforeScenario_SetUpSimulatedEnvironment() { - SimulatedEnvironment simulatedEnvironment = new SimulatedEnvironment(); - ProvideContainerObject(simulatedEnvironment); - - // Register and start the simulated TiVo device for all tests // Create a simple logger that writes to TestContext ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { builder.AddProvider(new TestContextLoggerProvider(TestContext)); }); - ILogger logger = loggerFactory.CreateLogger("SimulatedTiVoDevice"); - ISimulatedDeviceBuilder builder = new SimulatedTiVoDeviceBuilder(logger); - simulatedEnvironment.RegisterDevice(TiVoDeviceName, builder); - ISimulatedDevice device = simulatedEnvironment.StartDevice(TiVoDeviceName); + // Create environment with devices + SimulatedEnvironment simulatedEnvironment = new SimulatedEnvironment(loggerFactory); + ProvideContainerObject(simulatedEnvironment); - TestContext.WriteLine($"Simulated TiVo device started on port {device.Port}"); + TestContext.WriteLine($"Simulated TiVo device started on port {simulatedEnvironment.TiVo?.Port}"); + TestContext.WriteLine($"Simulated Broadlink device started on port {simulatedEnvironment.Broadlink?.Port}"); } [BeforeScenario(Order = 200)] @@ -50,12 +45,20 @@ public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSet // Use the simulated TiVo device string tivoArgs = "--tivo:Fake=True"; - if (Environment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? tivoDevice) && tivoDevice != null) + if (Environment.TiVo != null) + { + tivoArgs = $"--tivo:IP=127.0.0.1:{Environment.TiVo.Port}"; + } + + // Use the simulated Broadlink device + string broadlinkArgs = string.Empty; + if (Environment.Broadlink != null) { - tivoArgs = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; + // Configure the app to discover the simulated device on loopback at its port + broadlinkArgs = $"--broadlink:DiscoveryAddress=127.0.0.1 --broadlink:DiscoveryPort={Environment.Broadlink.Port}"; } - hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} --broadlink:Fake=True --log:FilePath=\"{LogFilePath}\""); + hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} {broadlinkArgs} --log:FilePath=\"{LogFilePath}\""); ProvideContainerObjectFactory(() => AdaptiveRemoteHost.CreateBuilder(hostSettings) .ConfigureLogging(builder => diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs index fc860e0..f11b1dc 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs @@ -8,12 +8,11 @@ namespace AdaptiveRemote.EndToEndTests.Steps; [Binding] public class TiVoSteps : StepsBase { - private const string TiVoDeviceName = "TiVo"; - [Then(@"I should see the TiVo receives a {string} message")] public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) { - if (!Environment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? device)) + ISimulatedTiVoDevice? device = Environment.TiVo; + if (device == null) { Assert.Fail("TiVo device is not running"); } @@ -23,14 +22,14 @@ public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) bool found = WaitHelpers.ExecuteWithRetries( () => { - IReadOnlyList messages = device!.GetRecordedMessages(); + IReadOnlyList messages = device.GetRecordedMessages(); return messages.Any(m => m.Incoming && m.Payload.Equals($"IRCODE {expectedCommand}", StringComparison.OrdinalIgnoreCase)); }, timeoutInSeconds: 5); if (!found) { - IReadOnlyList messages = device!.GetRecordedMessages(); + IReadOnlyList messages = device.GetRecordedMessages(); string recordedMessages = string.Join(", ", messages.Select(m => $"[{m.Timestamp:HH:mm:ss.fff}] {(m.Incoming ? "←" : "→")} {m.Payload}")); Assert.Fail($"Expected TiVo to receive message 'IRCODE {expectedCommand}', but it was not found. Recorded messages: {recordedMessages}"); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs index e182f10..4ed5436 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs @@ -1,3 +1,5 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// @@ -6,24 +8,12 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; public interface ISimulatedEnvironment : IDisposable { /// - /// Registers a device builder with the given name. - /// - /// The unique name for the device. - /// The device builder to register. - void RegisterDevice(string name, ISimulatedDeviceBuilder builder); - - /// - /// Starts a registered device and returns the running device instance. + /// Gets the simulated TiVo device, if started. /// - /// The name of the device to start. - /// The running simulated device. - ISimulatedDevice StartDevice(string name); + ISimulatedTiVoDevice? TiVo { get; } /// - /// Attempts to retrieve a running device by name. + /// Gets the simulated Broadlink device, if started. /// - /// The name of the device to retrieve. - /// The running device, if found. - /// True if the device was found; otherwise, false. - bool TryGetDevice(string name, out ISimulatedDevice? device); + ISimulatedBroadlinkDevice? Broadlink { get; } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index 674f270..0a3aa27 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -1,4 +1,6 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.Host; @@ -7,44 +9,24 @@ namespace AdaptiveRemote.EndtoEndTests.Host; /// public sealed class SimulatedEnvironment : ISimulatedEnvironment { - private readonly Dictionary _builders = new(); - private readonly Dictionary _devices = new(); + private ISimulatedTiVoDevice? _tivo; + private ISimulatedBroadlinkDevice? _broadlink; private bool _disposed; - /// - public void RegisterDevice(string name, ISimulatedDeviceBuilder builder) + public SimulatedEnvironment(ILoggerFactory loggerFactory) { - if (_builders.ContainsKey(name)) - { - throw new InvalidOperationException($"Device with name '{name}' is already registered."); - } + SimulatedTiVoDeviceBuilder tivoBuilder = new SimulatedTiVoDeviceBuilder(loggerFactory); + SimulatedBroadlinkDeviceBuilder broadlinkBuilder = new SimulatedBroadlinkDeviceBuilder(loggerFactory); - _builders[name] = builder; + _tivo = tivoBuilder.Start() as ISimulatedTiVoDevice; + _broadlink = broadlinkBuilder.Start() as ISimulatedBroadlinkDevice; } /// - public ISimulatedDevice StartDevice(string name) - { - if (!_builders.TryGetValue(name, out ISimulatedDeviceBuilder? builder)) - { - throw new InvalidOperationException($"No device builder registered with name '{name}'."); - } - - if (_devices.ContainsKey(name)) - { - throw new InvalidOperationException($"Device with name '{name}' is already started."); - } - - ISimulatedDevice device = builder.Start(); - _devices[name] = device; - return device; - } + public ISimulatedTiVoDevice? TiVo => _tivo; /// - public bool TryGetDevice(string name, out ISimulatedDevice? device) - { - return _devices.TryGetValue(name, out device); - } + public ISimulatedBroadlinkDevice? Broadlink => _broadlink; /// public void Dispose() @@ -54,33 +36,26 @@ public void Dispose() return; } - foreach (ISimulatedDevice device in _devices.Values) + try { - try - { - device.Dispose(); - } - catch - { - // Ignore disposal errors - } + _tivo?.Dispose(); + } + catch + { + // Ignore disposal errors } - _devices.Clear(); - - foreach (ISimulatedDeviceBuilder builder in _builders.Values) + try + { + _broadlink?.Dispose(); + } + catch { - try - { - builder.Dispose(); - } - catch - { - // Ignore disposal errors - } + // Ignore disposal errors } - _builders.Clear(); + _tivo = null; + _broadlink = null; _disposed = true; } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs new file mode 100644 index 0000000..18db375 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Independent AES encryption/decryption for the simulated Broadlink device. +/// Implements the same algorithm as the real device but independently from the app code. +/// +internal sealed class BroadlinkEncryption : IDisposable +{ + private static readonly byte[] InitialKey = [0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02]; + private static readonly byte[] InitialVector = [0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58]; + + private readonly Aes _aes; + + public BroadlinkEncryption(byte[]? key = null) + { + _aes = Aes.Create(); + _aes.KeySize = 128; // 128-bit AES (key is 16 bytes) + _aes.BlockSize = 128; + _aes.Mode = CipherMode.CBC; + _aes.IV = InitialVector; + _aes.Key = key ?? InitialKey; + _aes.Padding = PaddingMode.None; + } + + public byte[] Encrypt(byte[] data) + { + using ICryptoTransform encryptor = _aes.CreateEncryptor(); + using MemoryStream output = new MemoryStream(); + using CryptoStream cryptoStream = new CryptoStream(output, encryptor, CryptoStreamMode.Write); + + cryptoStream.Write(data); + + // Add padding to make data length a multiple of 16 bytes (AES block size) + // Broadlink uses a non-standard padding: fill to next 16-byte boundary using (8192 - length) % 16 + // This matches the protocol implementation used by real devices + int padding = (8192 - data.Length) % 16; + if (padding > 0) + { + byte[] paddingBytes = new byte[padding]; + cryptoStream.Write(paddingBytes, 0, padding); + } + + cryptoStream.FlushFinalBlock(); + return output.ToArray(); + } + + public byte[] Decrypt(byte[] data) + { + using ICryptoTransform decryptor = _aes.CreateDecryptor(); + using MemoryStream input = new MemoryStream(data); + using CryptoStream cryptoStream = new CryptoStream(input, decryptor, CryptoStreamMode.Read); + using MemoryStream output = new MemoryStream(); + + cryptoStream.CopyTo(output); + return output.ToArray(); + } + + public void Dispose() + { + _aes.Dispose(); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs new file mode 100644 index 0000000..c12c6fa --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.NetworkInformation; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Independent Broadlink packet decoder for the test simulator. +/// Does not reuse app runtime code to avoid masking encoding bugs. +/// +internal static class BroadlinkPacketDecoder +{ + private const int MinPacketSize = 0x38; + private const int PreambleSize = 8; + private static readonly byte[] ExpectedPreamble = [0x5a, 0xa5, 0xaa, 0x55, 0x5a, 0xa5, 0xaa, 0x55]; + + public static bool TryDecodePacket(byte[] data, EndPoint remoteEndPoint, out DecodedPacket? packet, out string? error) + { + packet = null; + error = null; + + if (data.Length < MinPacketSize) + { + error = $"Packet too small: {data.Length} bytes (minimum {MinPacketSize})"; + return false; + } + + // Verify preamble + for (int i = 0; i < PreambleSize; i++) + { + if (data[i] != ExpectedPreamble[i]) + { + error = $"Invalid preamble at byte {i}: expected 0x{ExpectedPreamble[i]:X2}, got 0x{data[i]:X2}"; + return false; + } + } + + // Extract header fields + short packetChecksum = ReadInt16(data, 0x20); + short deviceType = ReadInt16(data, 0x24); + short packetType = ReadInt16(data, 0x26); + short messageCount = ReadInt16(data, 0x28); + byte[] macBytes = new byte[6]; + Array.Copy(data, 0x2A, macBytes, 0, 6); + PhysicalAddress hostAddress = new PhysicalAddress(macBytes); + short deviceId = ReadInt16(data, 0x30); + short payloadChecksum = ReadInt16(data, 0x34); + + // Extract payload (everything after header) + byte[] payload = new byte[data.Length - MinPacketSize]; + if (payload.Length > 0) + { + Array.Copy(data, MinPacketSize, payload, 0, payload.Length); + } + + // Verify packet checksum + int calculatedChecksum = ComputeChecksum(data); + int expectedChecksum = packetChecksum & 0xFFFF; // Convert signed short to unsigned comparison + if (calculatedChecksum != expectedChecksum) + { + error = $"Checksum mismatch: expected 0x{expectedChecksum:X4}, calculated 0x{calculatedChecksum:X4}"; + return false; + } + + packet = new DecodedPacket + { + RemoteEndPoint = remoteEndPoint, + DeviceType = deviceType, + PacketType = packetType, + MessageCount = messageCount, + HostAddress = hostAddress, + DeviceId = deviceId, + PayloadChecksum = payloadChecksum, + Payload = payload + }; + + return true; + } + + public static bool TryDecodeScanRequest(byte[] data, out DecodedScanRequest? request, out string? error) + { + request = null; + error = null; + + if (data.Length < 0x30) + { + error = $"Scan request too small: {data.Length} bytes (minimum 0x30)"; + return false; + } + + // Verify byte at 0x26 is 6 (protocol requirement) + if (data[0x26] != 6) + { + error = $"Invalid scan request marker at 0x26: expected 6, got {data[0x26]}"; + return false; + } + + short localPort = ReadInt16(data, 0x1C); + byte[] localIp = new byte[4]; + Array.Copy(data, 0x18, localIp, 0, 4); + + request = new DecodedScanRequest + { + LocalPort = localPort, + LocalIpAddress = localIp + }; + + return true; + } + + private static short ReadInt16(byte[] data, int offset) + { + return BitConverter.ToInt16(data, offset); + } + + private static int ComputeChecksum(byte[] data) + { + int checksum = 0xBEAF; // Checksum seed + for (int i = 0; i < data.Length; i++) + { + // Skip checksum field itself (at 0x20, 2 bytes) + if (i == 0x20 || i == 0x21) + { + continue; + } + checksum += data[i]; + } + return checksum & 0xFFFF; + } +} + +/// +/// Represents a decoded Broadlink packet. +/// +internal sealed record DecodedPacket +{ + public required EndPoint RemoteEndPoint { get; init; } + public required short DeviceType { get; init; } + public required short PacketType { get; init; } + public required short MessageCount { get; init; } + public required PhysicalAddress HostAddress { get; init; } + public required short DeviceId { get; init; } + public required short PayloadChecksum { get; init; } + public required byte[] Payload { get; init; } +} + +/// +/// Represents a decoded scan (discovery) request. +/// +internal sealed record DecodedScanRequest +{ + public required short LocalPort { get; init; } + public required byte[] LocalIpAddress { get; init; } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs new file mode 100644 index 0000000..2b0ea44 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs @@ -0,0 +1,98 @@ +using System.Net.NetworkInformation; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Independent Broadlink packet encoder for the test simulator. +/// Builds response packets that the app can decode. +/// +internal static class BroadlinkPacketEncoder +{ + private const int HeaderSize = 0x38; + private static readonly byte[] Preamble = [0x5a, 0xa5, 0xaa, 0x55, 0x5a, 0xa5, 0xaa, 0x55]; + + public static byte[] EncodeScanResponse(short deviceType, PhysicalAddress macAddress, bool isLocked) + { + byte[] response = new byte[0x80]; + + // Copy MAC address at 0x3A + byte[] macBytes = macAddress.GetAddressBytes(); + Array.Copy(macBytes, 0, response, 0x3A, 6); + + // Set device type at 0x34 + WriteInt16(response, 0x34, deviceType); + + // Set locked flag at 0x7E + WriteInt16(response, 0x7E, (short)(isLocked ? 1 : 0)); + + return response; + } + + public static byte[] EncodeResponse(short deviceType, short packetType, short messageCount, PhysicalAddress hostAddress, short deviceId, byte[] payload, short errorCode = 0) + { + byte[] packet = new byte[HeaderSize + payload.Length]; + + // Write preamble + Array.Copy(Preamble, 0, packet, 0, Preamble.Length); + + // Write header fields + WriteInt16(packet, 0x24, deviceType); + WriteInt16(packet, 0x26, packetType); + WriteInt16(packet, 0x28, messageCount); + + byte[] macBytes = hostAddress.GetAddressBytes(); + Array.Copy(macBytes, 0, packet, 0x2A, 6); + + WriteInt16(packet, 0x30, deviceId); + + // Write error code at 0x22 + WriteInt16(packet, 0x22, errorCode); + + // Write payload checksum at 0x34 + short payloadChecksum = ComputePayloadChecksum(payload); + WriteInt16(packet, 0x34, payloadChecksum); + + // Copy payload + if (payload.Length > 0) + { + Array.Copy(payload, 0, packet, HeaderSize, payload.Length); + } + + // Compute and write packet checksum + short packetChecksum = ComputePacketChecksum(packet); + WriteInt16(packet, 0x20, packetChecksum); + + return packet; + } + + private static void WriteInt16(byte[] data, int offset, short value) + { + byte[] bytes = BitConverter.GetBytes(value); + Array.Copy(bytes, 0, data, offset, 2); + } + + private static short ComputePayloadChecksum(byte[] payload) + { + int checksum = 0xBEAF; // Checksum seed + for (int i = 0; i < payload.Length; i++) + { + checksum += payload[i]; + } + return (short)(checksum & 0xFFFF); + } + + private static short ComputePacketChecksum(byte[] packet) + { + int checksum = 0xBEAF; // Checksum seed + for (int i = 0; i < packet.Length; i++) + { + // Skip checksum field itself (at 0x20, 2 bytes) + if (i == 0x20 || i == 0x21) + { + continue; + } + checksum += packet[i]; + } + return (short)(checksum & 0xFFFF); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs new file mode 100644 index 0000000..15c91a3 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs @@ -0,0 +1,19 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Interface for the simulated Broadlink device, used by tests to verify packet transmission. +/// +public interface ISimulatedBroadlinkDevice : ISimulatedTiVoDevice +{ + /// + /// Gets all packets recorded since the device started or since the last clear. + /// + IReadOnlyList GetRecordedPackets(); + + /// + /// Clears all recorded packets. + /// + void ClearRecordedPackets(); +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs new file mode 100644 index 0000000..18fb27e --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs @@ -0,0 +1,52 @@ +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Extension methods for to support test assertions. +/// +public static class ISimulatedBroadlinkDeviceExtensions +{ + /// + /// Checks if the device has recorded at least one inbound packet with IR payload data. + /// + /// The simulated Broadlink device. + /// True if at least one inbound packet with IR data was recorded; otherwise, false. + public static bool HasRecordedInboundPacketWithIrData(this ISimulatedBroadlinkDevice device) + { + IReadOnlyList packets = device.GetRecordedPackets(); + return packets.Any(p => p.IsInbound && p.RawPayload != null && p.RawPayload.Length > 0); + } + + /// + /// Gets a formatted string of all recorded packets for debugging purposes. + /// + /// The simulated Broadlink device. + /// A string containing details of all recorded packets. + public static string GetRecordedPacketsDebugString(this ISimulatedBroadlinkDevice device) + { + IReadOnlyList packets = device.GetRecordedPackets(); + return string.Join(", ", packets.Select(p => + $"[{p.ReceivedAt:HH:mm:ss.fff}] {(p.IsInbound ? "←" : "→")} {p.DebugDescription}")); + } + + /// + /// Gets the first recorded packet with IR payload data, or null if none exists. + /// + /// The simulated Broadlink device. + /// The first packet with IR data, or null. + public static RecordedPacket? GetFirstPacketWithIrData(this ISimulatedBroadlinkDevice device) + { + IReadOnlyList packets = device.GetRecordedPackets(); + return packets.FirstOrDefault(p => p.IsInbound && p.RawPayload != null); + } + + /// + /// Checks if any recorded packet is marked as malformed. + /// + /// The simulated Broadlink device. + /// The first malformed packet, or null if none exist. + public static RecordedPacket? GetFirstMalformedPacket(this ISimulatedBroadlinkDevice device) + { + IReadOnlyList packets = device.GetRecordedPackets(); + return packets.FirstOrDefault(p => p.IsMalformed); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/RecordedPacket.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/RecordedPacket.cs new file mode 100644 index 0000000..7e25337 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/RecordedPacket.cs @@ -0,0 +1,37 @@ +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Represents a recorded Broadlink packet for test verification. +/// +public sealed record RecordedPacket +{ + /// + /// When the packet was recorded (UTC). + /// + public required DateTimeOffset ReceivedAt { get; init; } + + /// + /// True if the packet was received from the app (inbound to device). + /// + public required bool IsInbound { get; init; } + + /// + /// The packet type/command code. + /// + public required short PacketType { get; init; } + + /// + /// Raw IR payload bytes (when present). + /// + public byte[]? RawPayload { get; init; } + + /// + /// True if the packet had checksum or framing errors. + /// + public bool IsMalformed { get; init; } + + /// + /// Debug description of the packet. + /// + public string DebugDescription { get; init; } = string.Empty; +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs new file mode 100644 index 0000000..f02a65e --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -0,0 +1,351 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Simulates a Broadlink IR device for E2E testing. +/// Accepts UDP packets and records commands according to the Broadlink protocol. +/// +public sealed class SimulatedBroadlinkDevice : ISimulatedBroadlinkDevice +{ + private const short DefaultDeviceType = 0x2737; // RM Mini 3 + private const int AuthenticateCommand = 0x65; + private const int SendDataCommand = 0x6A; + + private readonly ILogger _logger; + private readonly UdpClient _udpClient; + private readonly PhysicalAddress _macAddress; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private readonly ConcurrentBag _recordedPackets = new(); + private readonly Task _listenerTask; + + private short _deviceId; + private BroadlinkEncryption? _encryption; + private bool _disposed; + + internal SimulatedBroadlinkDevice(int port, ILogger logger) + { + _logger = logger; + _udpClient = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); + Port = ((IPEndPoint)_udpClient.Client.LocalEndPoint!).Port; + + // Generate a random MAC address for testing + byte[] macBytes = new byte[6]; + Random.Shared.NextBytes(macBytes); + macBytes[0] = (byte)((macBytes[0] & 0xFE) | 0x02); // Set locally administered bit + _macAddress = new PhysicalAddress(macBytes); + + _deviceId = 0; + _encryption = new BroadlinkEncryption(); + + _logger.LogInformation("SimulatedBroadlinkDevice started on UDP port {Port} with MAC {MacAddress}", Port, _macAddress); + + _listenerTask = Task.Run(() => ListenAsync(_cancellationTokenSource.Token)); + } + + /// + public int Port { get; } + + /// + public IReadOnlyList GetRecordedMessages() + { + // For compatibility with ISimulatedDevice interface + return _recordedPackets + .Select(p => new RecordedMessage + { + Timestamp = p.ReceivedAt, + Payload = p.DebugDescription, + Incoming = p.IsInbound + }) + .ToList(); + } + + /// + public void ClearRecordedMessages() + { + ClearRecordedPackets(); + } + + /// + public IReadOnlyList GetRecordedPackets() + { + return _recordedPackets.ToList(); + } + + /// + public void ClearRecordedPackets() + { + while (_recordedPackets.TryTake(out _)) + { + // Remove all items + } + _logger.LogInformation("Cleared recorded packets"); + } + + /// + public void Stop() + { + if (_disposed) + { + return; + } + + _logger.LogInformation("Stopping SimulatedBroadlinkDevice on port {Port}", Port); + + _cancellationTokenSource.Cancel(); + _udpClient.Close(); + + try + { + WaitHelpers.WaitForAsyncTask(_ => _listenerTask, TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error waiting for listener task to complete"); + } + + _disposed = true; + } + + /// + public void Dispose() + { + Stop(); + _cancellationTokenSource.Dispose(); + _udpClient.Dispose(); + _encryption?.Dispose(); + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + UdpReceiveResult result = await _udpClient.ReceiveAsync(cancellationToken); + _ = Task.Run(() => HandlePacketAsync(result.Buffer, result.RemoteEndPoint, cancellationToken), cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error receiving UDP packet"); + } + } + } + + private async Task HandlePacketAsync(byte[] data, IPEndPoint remoteEndPoint, CancellationToken _) + { + try + { + // Try to decode as a scan (discovery) request first + if (BroadlinkPacketDecoder.TryDecodeScanRequest(data, out DecodedScanRequest? scanRequest, out string? scanError)) + { + _logger.LogInformation("Received discovery request from {RemoteEndPoint}", remoteEndPoint); + await HandleDiscoveryRequestAsync(scanRequest!, remoteEndPoint); + return; + } + + // Try to decode as a regular packet + if (BroadlinkPacketDecoder.TryDecodePacket(data, remoteEndPoint, out DecodedPacket? packet, out string? error)) + { + await HandleCommandPacketAsync(packet!, remoteEndPoint); + } + else + { + _logger.LogWarning("Failed to decode packet from {RemoteEndPoint}: {Error}", remoteEndPoint, error); + RecordMalformedPacket(remoteEndPoint, error ?? "Unknown error"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling packet from {RemoteEndPoint}", remoteEndPoint); + } + } + + private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint) + { + try + { + // Build discovery response + byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( + DefaultDeviceType, + _macAddress, + isLocked: false); + + // Send response to the port specified in the request + // Convert signed short to unsigned port number (handles ports > 32767) + int localPort = request.LocalPort & 0xFFFF; + IPEndPoint responseEndPoint = new IPEndPoint(remoteEndPoint.Address, localPort); + + int bytesSent = await _udpClient.SendAsync(response, response.Length, responseEndPoint); + + _logger.LogInformation( + "Sent discovery response ({BytesSent} bytes) to {EndPoint}", + bytesSent, + responseEndPoint); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error sending discovery response to {RemoteEndPoint}: {ExceptionType} - {Message}", + remoteEndPoint, + ex.GetType().Name, + ex.Message); + throw; + } + } + + private async Task HandleCommandPacketAsync(DecodedPacket packet, IPEndPoint remoteEndPoint) + { + _logger.LogInformation("Received packet type 0x{PacketType:X2} from {RemoteEndPoint}", packet.PacketType, remoteEndPoint); + + switch (packet.PacketType) + { + case AuthenticateCommand: + await HandleAuthenticateAsync(packet, remoteEndPoint); + break; + + case SendDataCommand: + await HandleSendDataAsync(packet, remoteEndPoint); + break; + + default: + _logger.LogWarning("Unknown packet type 0x{PacketType:X2}", packet.PacketType); + break; + } + } + + private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remoteEndPoint) + { + _logger.LogInformation("Handling authentication request"); + + // Generate a new device ID + _deviceId = (short)Random.Shared.Next(0x1000, 0x7FFF); + + // Generate a new encryption key + byte[] newKey = new byte[16]; + Random.Shared.NextBytes(newKey); + + // Create response payload with device ID and encryption key + byte[] responsePayload = new byte[0x50]; + Array.Copy(BitConverter.GetBytes(_deviceId), 0, responsePayload, 0x00, 2); + Array.Copy(newKey, 0, responsePayload, 0x04, 16); + + // Encrypt the response with the default key + byte[] encryptedPayload = _encryption!.Encrypt(responsePayload); + + // Build response packet + byte[] response = BroadlinkPacketEncoder.EncodeResponse( + DefaultDeviceType, + AuthenticateCommand, + packet.MessageCount, + packet.HostAddress, + _deviceId, + encryptedPayload, + errorCode: 0); + + // Send response + await _udpClient.SendAsync(response, response.Length, remoteEndPoint); + + // Switch to the new encryption key + _encryption?.Dispose(); + _encryption = new BroadlinkEncryption(newKey); + + _logger.LogInformation("Authentication complete, assigned device ID 0x{DeviceId:X4}", _deviceId); + + // Record the packet + RecordPacket(new RecordedPacket + { + ReceivedAt = DateTimeOffset.UtcNow, + IsInbound = true, + PacketType = AuthenticateCommand, + DebugDescription = $"Authenticate request (Device ID: 0x{_deviceId:X4})" + }); + } + + private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEndPoint) + { + _logger.LogInformation("Handling send data command"); + + // Decrypt the payload + byte[] decryptedPayload; + try + { + decryptedPayload = _encryption!.Decrypt(packet.Payload); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to decrypt payload"); + RecordMalformedPacket(remoteEndPoint, "Decryption failed"); + return; + } + + // Extract IR data from the command payload + // Format: [length:2][command:4][data:...] + byte[]? irData = null; + if (decryptedPayload.Length >= 6) + { + short dataLength = BitConverter.ToInt16(decryptedPayload, 0); + // Command type at offset 2 (not currently validated) + + if (decryptedPayload.Length >= 6 + (dataLength - 4)) + { + irData = new byte[dataLength - 4]; + Array.Copy(decryptedPayload, 6, irData, 0, irData.Length); + } + } + + // Record the packet + RecordPacket(new RecordedPacket + { + ReceivedAt = DateTimeOffset.UtcNow, + IsInbound = true, + PacketType = SendDataCommand, + RawPayload = irData, + DebugDescription = $"Send IR command ({irData?.Length ?? 0} bytes)" + }); + + // Build response + byte[] responsePayload = _encryption!.Encrypt(Array.Empty()); + byte[] response = BroadlinkPacketEncoder.EncodeResponse( + DefaultDeviceType, + SendDataCommand, + packet.MessageCount, + packet.HostAddress, + _deviceId, + responsePayload, + errorCode: 0); + + // Send response + await _udpClient.SendAsync(response, response.Length, remoteEndPoint); + + _logger.LogInformation("Send data command complete, IR payload: {PayloadSize} bytes", irData?.Length ?? 0); + } + + private void RecordPacket(RecordedPacket packet) + { + _recordedPackets.Add(packet); + _logger.LogInformation("Recorded packet: {Description}", packet.DebugDescription); + } + + private void RecordMalformedPacket(EndPoint remoteEndPoint, string error) + { + RecordPacket(new RecordedPacket + { + ReceivedAt = DateTimeOffset.UtcNow, + IsInbound = true, + PacketType = 0, + IsMalformed = true, + DebugDescription = $"Malformed packet from {remoteEndPoint}: {error}" + }); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs new file mode 100644 index 0000000..be170e8 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs @@ -0,0 +1,36 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Builder for creating a simulated Broadlink device. +/// +public sealed class SimulatedBroadlinkDeviceBuilder : ISimulatedDeviceBuilder +{ + private readonly ILogger _logger; + + public SimulatedBroadlinkDeviceBuilder(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + public ISimulatedDeviceBuilder WithPort(int port) + { + // Port configuration is not supported - always use ephemeral port + return this; + } + + /// + public ISimulatedTiVoDevice Start() + { + return new SimulatedBroadlinkDevice(0, _logger); // Always use port 0 for ephemeral + } + + /// + public void Dispose() + { + // Nothing to dispose in the builder + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs index 907d3c6..0f38952 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs @@ -1,10 +1,10 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// -/// Represents a running simulated device. +/// Represents a running simulated TiVo device. /// All methods are synchronous for ease of use in test scenarios. /// -public interface ISimulatedDevice : IDisposable +public interface ISimulatedTiVoDevice : IDisposable { /// /// Stops the device and releases resources. Safe to call multiple times. diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs index 8c9c354..ebd5013 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs @@ -15,6 +15,6 @@ public interface ISimulatedDeviceBuilder : IDisposable /// /// Starts the device synchronously and returns the running device. /// - /// A running simulated device instance. - ISimulatedDevice Start(); + /// A running simulated TiVo device instance. + ISimulatedTiVoDevice Start(); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs index af15b29..8e8e5ad 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs @@ -10,7 +10,7 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// Simulates a TiVo device for E2E testing. /// Accepts TCP connections and records messages according to the TiVo protocol. /// -public sealed class SimulatedTiVoDevice : ISimulatedDevice +public sealed class SimulatedTiVoDevice : ISimulatedTiVoDevice { private readonly ILogger _logger; private readonly TcpListener _listener; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs index c435c31..63a23ea 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs @@ -13,10 +13,10 @@ public sealed class SimulatedTiVoDeviceBuilder : ISimulatedDeviceBuilder /// /// Initializes a new instance of the class. /// - /// Logger for the simulated device. - public SimulatedTiVoDeviceBuilder(ILogger logger) + /// Logger factory for creating the device logger. + public SimulatedTiVoDeviceBuilder(ILoggerFactory loggerFactory) { - _logger = logger; + _logger = loggerFactory.CreateLogger(); } /// @@ -27,7 +27,7 @@ public ISimulatedDeviceBuilder WithPort(int port) } /// - public ISimulatedDevice Start() + public ISimulatedTiVoDevice Start() { return new SimulatedTiVoDevice(_port, _logger); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md new file mode 100644 index 0000000..1b52f6f --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md @@ -0,0 +1,281 @@ +# Simulated Devices for E2E Testing + +## Overview + +The AdaptiveRemote test suite includes in-process simulated devices that enable end-to-end testing without requiring physical hardware. These simulators implement the actual wire protocols used by real devices, allowing comprehensive testing of device discovery, authentication, command transmission, and error handling. + +## Available Simulated Devices + +### SimulatedTiVoDevice + +Simulates a TiVo DVR device for testing TiVo command integration. + +**Protocol:** TCP-based ASCII protocol with carriage return (`\r`) line terminators +**Port:** Always uses ephemeral port (0) for test isolation +**Location:** [`test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/`](./SimulatedTiVo/) + +**Implementation:** See [`SimulatedTiVoDevice.cs`](./SimulatedTiVo/SimulatedTiVoDevice.cs) + +**Key Features:** +- TCP server accepting commands in TiVo IRCODE format (e.g., `IRCODE PLAY\r`) +- Records all incoming messages with timestamps +- Thread-safe message recording using `ConcurrentBag` +- Ephemeral port support for parallel test execution + +### SimulatedBroadlinkDevice + +Simulates a Broadlink IR controller for testing IR command transmission to TVs and AV equipment. + +**Protocol:** UDP-based binary protocol with authentication, encryption, and checksums +**Port:** Always uses ephemeral port (0) for test isolation +**Location:** [`test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/`](./SimulatedBroadlink/) + +**Implementation:** See [`SimulatedBroadlinkDevice.cs`](./SimulatedBroadlink/SimulatedBroadlinkDevice.cs) + +**Key Features:** +- UDP server handling discovery, authentication, and command packets +- Independent encoder/decoder implementation (separate from app code to catch encoding bugs) +- AES encryption for authenticated sessions +- Records packets with IR payload data for verification +- Supports discovery protocol with configurable endpoint +- Tracks malformed packets for error testing + +## Architecture + +### Core Interfaces + +The simulated device system is built around a hierarchy of interfaces: + +- **[`ISimulatedTiVoDevice`](./SimulatedTiVo/ISimulatedDevice.cs)** - Base interface for simulated devices +- **[`ISimulatedBroadlinkDevice`](./SimulatedBroadlink/ISimulatedBroadlinkDevice.cs)** - Extends base with Broadlink-specific packet recording +- **[`ISimulatedDeviceBuilder`](./SimulatedTiVo/ISimulatedDeviceBuilder.cs)** - Builder pattern for device creation +- **[`ISimulatedEnvironment`](./Host/ISimulatedEnvironment.cs)** - Container managing device lifecycle + +### Test Environment Setup + +The [`SimulatedEnvironment`](./Host/SimulatedEnvironment.cs) class manages device lifecycle: + +1. Accepts `ILoggerFactory` in constructor +2. Creates device builders internally +3. Starts both TiVo and Broadlink devices +4. Exposes devices via `TiVo` and `Broadlink` properties +5. Handles cleanup on disposal + +See [`HostSteps.cs`](../../AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs) for test setup implementation. + +## Test Integration + +### Lifecycle Management + +Simulated devices are automatically managed by the test framework via Reqnroll hooks in [`HostSteps.cs`](../../AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs): + +1. **BeforeScenario (Order=50):** Creates `SimulatedEnvironment` with `ILoggerFactory`, which internally starts both devices +2. **BeforeScenario (Order=200):** Starts host application with device configuration (IP:Port) +3. **AfterScenario:** Disposes environment, which stops and cleans up devices + +### Step Definitions + +Test logic is implemented as extension methods for reusability: + +- **TiVo Steps:** See [`TiVoSteps.cs`](../../AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs) +- **Broadlink Steps:** See [`BroadlinkSteps.cs`](../../AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs) +- **Broadlink Extensions:** See [`ISimulatedBroadlinkDeviceExtensions.cs`](./SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs) + +### Gherkin Features + +Test scenarios use Gherkin syntax: + +- **TiVo:** [`TiVoDevice.feature`](../../AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature) +- **Broadlink:** [`BroadlinkDevice.feature`](../../AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature) +- **Startup:** [`ApplicationStartupAndShutdown.feature`](../../AdaptiveRemote.EndToEndTests.Features/ApplicationStartupAndShutdown.feature) + +#### Broadlink Device Feature + +Example test scenario - see [`BroadlinkDevice.feature`](../../AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature) for full implementation. + +## Protocol Implementation Details + +### TiVo Protocol + +**Message Format:** ASCII text with `\r` line terminator +**Command Example:** `IRCODE PLAY\r` + +**Key Implementation Details:** +- Custom `ReadLineAsync` to handle `\r` terminator (not `\n` or `\r\n`) +- Messages recorded without line terminator +- Connection handling supports sequential connections (not simultaneous) + +See [`SimulatedTiVoDevice.cs`](./SimulatedTiVo/SimulatedTiVoDevice.cs) for complete implementation. + +### Broadlink Protocol + +**Message Format:** Binary packets with headers, checksums, and encrypted payloads + +**Packet Structure:** `[Preamble: 8 bytes] [Header: 0x38 bytes] [Payload: variable]` + +For protocol details, see: +- [`BroadlinkPacketDecoder.cs`](./SimulatedBroadlink/BroadlinkPacketDecoder.cs) - Packet parsing +- [`BroadlinkPacketEncoder.cs`](./SimulatedBroadlink/BroadlinkPacketEncoder.cs) - Response building +- [`BroadlinkEncryption.cs`](./SimulatedBroadlink/BroadlinkEncryption.cs) - AES encryption + +**Discovery Protocol:** +1. App broadcasts `ScanRequestPacket` to configured address/port +2. Device responds with `ScanResponsePacket` containing MAC, device type, and IP +3. App selects first discovered device + +**Authentication Flow:** +1. App sends authenticate request (command 0x65) with default encryption +2. Device generates session ID and encryption key +3. Device responds with encrypted session credentials +4. App switches to session-specific encryption for subsequent commands + +**Send Data Flow:** +1. App sends IR data packet (command 0x6A) with session encryption +2. Device decrypts payload and extracts IR bytes +3. Device records packet with IR payload for verification +4. Device sends success response + +**Encryption:** AES-128-CBC with device-specific keys after authentication + +**Checksums:** +- Payload checksum: Sum of payload bytes + 0xBEAF seed +- Packet checksum: Sum of entire packet (excluding checksum field) + 0xBEAF seed + +## Design Decisions + +### Why In-Process Simulation? + +**Benefits:** +- Simpler lifecycle management (no separate processes) +- Direct access to recorded data (no IPC overhead) +- Easier debugging (single process to attach debugger) +- Reduced test infrastructure complexity + +**Tradeoffs:** +- Shares process memory with application under test +- Cannot test process isolation scenarios + +### Why Independent Protocol Implementation? + +For Broadlink, the simulator uses an independent encoder/decoder separate from the application runtime code. + +**Benefits:** +- Catches encoding/decoding bugs that would be masked by shared implementation +- Validates protocol correctness against "real device" behavior +- Enables protocol evolution testing + +**Implementation:** +- Shared small types (e.g., constants, simple records) where safe +- Independent crypto/checksum implementations +- Independent packet parsing logic + +### Why Loopback and Ephemeral Ports? + +**Security:** +- No external network exposure +- No firewall configuration required + +**Parallel Testing:** +- Ephemeral ports (port 0) enable parallel test execution +- No port conflicts between test runs + +**CI/CD Compatibility:** +- Works in containerized environments +- No admin/root privileges required (except port 80, which we avoid) + +## Adding New Simulated Devices + +To add a new simulated device: + +1. **Create device implementation:** + - Implement `ISimulatedDevice` interface + - Create device-specific interface if needed (like `ISimulatedBroadlinkDevice`) + - Implement wire protocol (TCP, UDP, HTTP, etc.) + - Record messages/packets for verification + +2. **Create builder:** + - Implement `ISimulatedDeviceBuilder` + - Support port configuration + - Return running device instance + +3. **Update ISimulatedEnvironment:** + - Add typed property for new device (e.g., `IRoku`, `IAppleTV`) + - Update `SimulatedEnvironment` implementation + +4. **Create step definitions:** + - Add steps for device-specific verification + - Use existing patterns from `TiVoSteps` and `BroadlinkSteps` + +5. **Add Gherkin features:** + - Create feature file testing device integration + - Cover key scenarios (discovery, commands, errors) + +6. **Update test infrastructure:** + - Register device in `HostSteps.OnBeforeScenario_SetUpSimulatedEnvironment` + - Configure application to use simulated device + +## Testing Best Practices + +### Message/Packet Verification + +- **Clear before test:** Call `ClearRecordedMessages()` / `ClearRecordedPackets()` at test start +- **Use polling:** Use `WaitHelpers.ExecuteWithRetries` for assertions (accounts for timing) +- **Check specifics:** Verify command format, not just presence +- **Assert no errors:** Check for malformed packets/messages when relevant + +### Device Configuration + +- **Use loopback:** Always bind simulators to `127.0.0.1` for security +- **Use ephemeral ports:** Bind to port 0 to avoid conflicts +- **Read actual port:** Use `device.Port` to get assigned port number +- **Configure app:** Pass device endpoint to application via command-line args + +### Error Handling + +- **Record errors:** Simulators should record malformed packets/messages, not throw +- **Test negative cases:** Include tests for malformed input, timeouts, auth failures +- **Clear error messages:** Provide detailed failure messages with recorded data + +## Known Limitations + +### TiVo Device + +- **No response simulation:** Only records incoming messages, doesn't send responses +- **Single connection:** Handles connections sequentially, not simultaneously +- **No replay:** Messages recorded but not replayed + +### Broadlink Device + +- **Simplified protocol:** Implements minimum required for app testing, not full device spec +- **No real captures:** No real device packet captures for validation (could be added) +- **Basic error handling:** Records errors but doesn't simulate all device error conditions + +## Future Enhancements + +### General + +- **Response simulation:** Allow tests to script device responses +- **Message filtering:** Add query methods for filtering recorded messages +- **Performance metrics:** Track connection counts, durations, bandwidth +- **Snapshot/restore:** Save and restore device state for complex scenarios + +### TiVo Specific + +- **Bidirectional communication:** Send messages back to application +- **Connection metrics:** Track connection lifecycle + +### Broadlink Specific + +- **Exact byte verification:** Compare recorded IR bytes against expected values +- **Programmable responses:** Allow tests to define specific IR patterns +- **Real device validation:** Test against captured real device packets +- **Error injection:** Simulate checksum errors, malformed packets, timeouts + +## References + +- **TiVo Protocol:** I8Beef.TiVo library +- **Broadlink Protocol:** https://github.com/mjg59/python-broadlink/blob/master/protocol.md +- **Test Framework:** Reqnroll (SpecFlow successor) with MSTest +- **Original Specifications:** + - `_spec_SimulatedTiVoDevice.md` (superseded) + - `_spec_SimulatedBroadlinkDevice.md` (superseded) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md deleted file mode 100644 index 3903b4b..0000000 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md +++ /dev/null @@ -1,216 +0,0 @@ -# Simulated TiVo Device for Testing - -Issue: ADR-120 — Simulated TiVo device for testing - -## Overview - -The simulated TiVo device provides a locally runnable test double that implements the TiVo TCP-based protocol, enabling end-to-end tests to validate TiVo device interactions without requiring physical hardware. - -## Architecture - -### Components - -#### SimulatedTiVoDevice -The core TCP server that listens for connections and records incoming messages. - -**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs` - -**Key Features:** -- Binds to loopback (127.0.0.1) by default for security -- Supports ephemeral ports (port 0) for parallel test execution -- Records all incoming messages with timestamps -- Implements the TiVo line-based ASCII protocol (messages terminated with `\r`) -- Thread-safe message recording using `ConcurrentBag` -- Async TCP connection handling -- Clean shutdown support - -#### TestEnvironment -Manages the lifecycle of simulated devices during test runs. - -**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs` - -**Key Features:** -- Device registration and lifecycle management -- Name-based device lookup -- Automatic cleanup on disposal -- Builder pattern support - -#### Test Steps -Integration with Reqnroll test framework via step definitions. - -**Location:** `test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs` - -**Key Features:** -- `@tivo` tag support for scenarios requiring simulated device -- Automatic device startup before host initialization -- Message verification with configurable timeout -- Clear error messages with recorded message history - -### Interfaces - -```csharp -// Base recorded message structure -public sealed record RecordedMessage -{ - public DateTimeOffset Timestamp { get; init; } - public string Payload { get; init; } - public bool Incoming { get; init; } -} - -// Running device interface -public interface ITestDevice : IDisposable -{ - void Stop(); - int Port { get; } - IReadOnlyList GetRecordedMessages(); - void ClearRecordedMessages(); -} - -// Device builder interface -public interface ITestDeviceBuilder : IDisposable -{ - ITestDeviceBuilder WithPort(int port); - ITestDevice Start(); -} - -// Test environment interface -public interface ITestEnvironment : IDisposable -{ - void RegisterDevice(string name, ITestDeviceBuilder builder); - ITestDevice StartDevice(string name); - bool TryGetDevice(string name, out ITestDevice? device); -} -``` - -## Protocol Implementation - -### TiVo TCP Protocol -- **Port:** Configurable (default 31339 for real devices; ephemeral for tests) -- **Transport:** TCP over loopback -- **Message Format:** Line-based ASCII with `\r` (carriage return) terminator -- **Command Format:** `IRCODE {command}\r` (e.g., `IRCODE PLAY\r`) - -### Key Implementation Details - -**Line Terminator Handling:** -The TiVo protocol uses `\r` (carriage return) as the line terminator, not the standard `\n` (line feed) or `\r\n` (carriage return + line feed). This required a custom `ReadLineAsync` implementation to properly detect end-of-line. - -**Message Recording:** -All incoming messages are recorded immediately upon receipt with: -- UTC timestamp -- Raw payload (without line terminator) -- Direction flag (incoming vs. outgoing) - -## Usage - -### Basic Test Scenario - -```gherkin -@tivo -Feature: TiVo Device Integration - Scenario: TiVo receives Play command - Given there is a simulated TiVo device - And the application is not running - When I start the application - Then I should see the application in the Ready phase - When I click on the 'Play' button - Then I should see the TiVo receives a "PLAY" message -``` - -### Test Step Definitions - -```csharp -[Given(@"there is a simulated TiVo device")] -public void GivenThereIsASimulatedTiVoDevice() -{ - // Device is automatically started via BeforeScenario hook - // This step just verifies it's running -} - -[Then(@"I should see the TiVo receives a {string} message")] -public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) -{ - // Polls for message with 5-second timeout - // Returns detailed error with all recorded messages on failure -} -``` - -### Configuration - -The simulated device is automatically configured when a test scenario is tagged with `@tivo`: - -1. **BeforeScenario (Order=50):** TestEnvironment is created -2. **BeforeScenario (Order=100, @tivo):** Simulated TiVo device is started on ephemeral port -3. **BeforeScenario (Order=200):** Host is started with `--tivo:IP=127.0.0.1:{port}` argument -4. **AfterScenario:** Cleanup of devices and host - -## Testing - -### Running E2E Tests - -```bash -# Build headless host for Linux -dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj -r linux-x64 - -# Install Playwright browsers (one-time) -pwsh src/AdaptiveRemote.Headless/bin/Debug/net8.0/playwright.ps1 install chromium - -# Run all headless E2E tests -dotnet test test/AdaptiveRemote.EndToEndTests.HeadlessHost/AdaptiveRemote.EndToEndTests.HeadlessHost.csproj -``` - -### Test Results - -As of ADR-120 implementation: -- ✅ Application startup and shutdown without errors -- ✅ TiVo receives Play command - -Both tests pass consistently on Linux (Ubuntu) with Playwright headless browser. - -## Design Decisions - -### Why In-Process? -Running the simulated device in the test process (rather than as a separate service) provides: -- Simpler lifecycle management -- No inter-process communication overhead -- Direct access to recorded messages -- Easier debugging - -### Why Loopback Only? -Binding to loopback (127.0.0.1) by default ensures: -- No firewall configuration required -- No security risks from external connections -- Consistent behavior across environments - -### Why Ephemeral Ports? -Using port 0 (ephemeral) by default enables: -- Parallel test execution without port conflicts -- No need for port coordination between tests -- CI/CD pipeline compatibility - -### Why ConcurrentBag for Message Recording? -`ConcurrentBag` provides: -- Thread-safe message recording -- Low contention for add operations -- Simple API for test verification - -## Known Limitations - -1. **No Response Simulation:** The current implementation only records incoming messages. It does not send responses back to the client. This is sufficient for testing command transmission but not for testing response handling. - -2. **Single Connection:** While the device accepts multiple connections sequentially, it does not handle multiple simultaneous connections. This is not a limitation for current test scenarios. - -3. **No Message Replay:** Messages are only recorded, not replayed. Tests must poll for messages within the assertion timeout. - -## Future Enhancements - -- **Response Simulation:** Add support for scripted responses to enable testing of bidirectional communication -- **Message Filtering:** Add query methods for filtering recorded messages by timestamp, content, or pattern -- **Connection Metrics:** Track connection count, duration, and bandwidth for performance testing -- **Multi-Device Support:** Extend TestEnvironment to support multiple device types (e.g., Broadlink) - -## References - -- TiVo Protocol: Uses I8Beef.TiVo library implementation as reference -- Test Framework: Reqnroll (SpecFlow successor) with MSTest -- Original Specification: `test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedTiVoDevice.md` (superseded by this document) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedBroadlinkDevice.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedBroadlinkDevice.md deleted file mode 100644 index 6d5679e..0000000 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedBroadlinkDevice.md +++ /dev/null @@ -1,121 +0,0 @@ -# Simulated Broadlink device for E2E testing - -Initial notes: -# Simulated Broadlink device for E2E testing - -Purpose -------- -Provide an in-process simulated Broadlink IR controller for end-to-end tests that behaves like the real device on the wire (UDP) but is controllable and observable by tests. - -Goals ------ -- Run in-process following the `SimulatedTiVoDevice` pattern so tests can access recorded messages via DI/service interfaces. -- Implement a simplified discovery protocol so the app will connect to the simulator during tests instead of a real device. -- Use an independent encoder/decoder implementation (not reuse the app's runtime encoder) to avoid masking encoding bugs; share small helper types where safe. -- Expose a test-friendly API to read recorded packets/messages (in-memory) and allow tests to assert non-empty IR payloads. Later we can extend to exact-byte checks and programmable-button behavior. -- Configure simulator activation via .NET `IOptions` and command-line flags (e.g. `--broadlink:Fake=True`) so it integrates with existing launch settings. - -Non-goals ---------- -- Full, drop-in binary-accurate Broadlink device with every manufacturer nuance. Start with behavior necessary for our services and tests. - -Requirements ------------- -- The simulator runs in-process and registers services via DI so tests can retrieve a `ISimulatedBroadlinkDevice` interface. -- `ISimulatedEnvironment` will expose two concrete properties: `TiVo` and `Broadlink`. We will remove the generic "get named device" API. Each simulated device may expose its own device-specific API surface; shared environment behavior should be limited to lifecycle and global test helpers. -- The simulator listens for UDP packets on a configurable endpoint and responds using the same packet framing used by the real device (simplified where safe). -- Discovery: implement a test-friendly discovery response that the app's `DeviceLocator` will accept, preventing accidental connections to real devices. -- Encoding/decoding: provide an independent encoder/decoder pair that reproduces the packet layout read by `DeviceConnection` but produces writable packet objects for the simulator to manipulate. -- Recording: record received and sent packets in-memory with timestamps; expose a query API for tests to fetch recorded messages per-test. -- Verification: tests initially only assert that IR payloads are present (non-empty). The simulator should record raw IR bytes so future tests can assert equality. - -Design ------- - -- Project placement: tests/service host area — mirror `SimulatedTiVoDevice` implementation and lifecycle in `test/AdaptiveRemote.EndtoEndTests.TestServices`. - -- Public test-facing interface (in-process): - - `interface ISimulatedBroadlinkDevice` - - `IReadOnlyList GetRecordedPackets()` - - `void ClearRecordedPackets()` - - `Task StartAsync(CancellationToken)` / `Task StopAsync(CancellationToken)` (optional lifecycle control) - -- RecordedPacket shape: - - `DateTimeOffset ReceivedAt` - - `bool IsInbound` (from app -> device) - - `PacketType` / CommandCode - - `byte[] RawPayload` (raw IR bytes when present) - - `string DebugDescription` - -- Discovery behavior: - - Simulator responds to the UDP discovery probe using a simplified payload that `DeviceLocator` will accept. - - Discovery response fields (MAC/IP/port) must make the app treat the simulator as a legitimate device. - - Make discovery configurable (enable/disable) so tests can simulate discovery or pre-configure the app to connect directly. - -- Wire protocol handling: - - Implement an independent `BroadlinkPacketEncoder` and `BroadlinkPacketDecoder` inside the test services area. - - Decoder must produce writable packet objects (so simulator can mutate fields). Implement the full Broadlink authentication handshake (authenticate request/response, exchange of encryption key, subsequent encrypted payloads) so tests validate auth behavior in the app. - - Checksum/framing behavior: the simulator should log checksum or framing errors and respond permissively where practical (log + respond), but record a malformed/errored flag on the `RecordedPacket`. Tests should assert on recorded errors when appropriate (so malformed packets become test failures when expected). - - Do not reference or reuse the app's internal encoder at runtime to avoid masking bugs; unit-test the decoder against sample real-device captures if available. (Note: no real-device captures currently exist in the repo; the author may be able to provide captures later.) - -- Integration with tests: - - The simulator registers `ISimulatedBroadlinkDevice` into DI when the `--broadlink:Fake=True` option is set (bind via `IOptions` in the test host builder). - - Tests obtain the `ISimulatedBroadlinkDevice` from the test host's service provider and call `ClearRecordedPackets()` at test start and `GetRecordedPackets()` for assertions. - - For tests that run the full app host, ensure the app's `DeviceLocator` prefers devices discovered on the loopback address/port used by the simulator when `Fake=True` is set. - -Verification strategy ---------------------- -- Initial checks: tests assert that a command resulted in at least one recorded outbound packet with a non-empty `RawPayload`. -- Later extensions: allow tests to provide expected raw bytes and assert equality; add programmable-button flows that return specific IR bytes for verification. - -Configuration -------------- - - Expose options type `BroadlinkTestOptions` with: - - `bool Fake` (default false) — controlled by `--broadlink:Fake=True` - - `string BindAddress` (restricted to loopback; default `127.0.0.1`) - - `int DiscoveryPort` / `int DevicePort` (configurable; default ephemeral port for device — bind port `0` — tests should read the actual bound port via the simulator API) - - `bool EnableDiscoveryResponse` (default true) - - `string DeviceAddressFilter` (optional) — when provided tests will pass a filter so discovery continues to be exercised but real devices are rejected unless they match the filter. - -Implementation clarifications ------------------------------ -1. Real-device captures: None exist currently in the repo; the author may provide captures later. The decoder must be unit-testable against captures when they are available. -2. Encryption/algorithm: Implement the Broadlink authentication/encryption algorithm independently inside the test services (do not call into the app runtime implementation). -3. Device discovery filtering: Tests will pass a `DeviceAddressFilter` so discovery is exercised but real devices will be rejected unless they match the filter. The simulator should honor this filter when composing discovery responses. -4. Bound port visibility: The simulator must expose the actual bound device port via a property on `ISimulatedBroadlinkDevice` (e.g., `BoundPort`) so tests can read it if needed. -5. `ISimulatedEnvironment` shape: expose `TiVo` and `Broadlink` properties as singletons: `ISimulatedEnvironment.TiVo` and `ISimulatedEnvironment.Broadlink`. -6. Malformed packets: The simulator records malformed/errored packets (with a flag on `RecordedPacket`). Tests are responsible for asserting there were no malformed packets when appropriate; the simulator will not throw/fail on receipt. -7. Gherkin placement & step definitions: Add the feature file to the `AdaptiveRemote.EndToEndTests.Features` project. Create new `BroadlinkSteps` step definitions modeled after `TiVoSteps`. -8. Host inclusion: Adding the feature to `AdaptiveRemote.EndToEndTests.Features` will include it in tests for all hosts automatically. - -Lifecycle ---------- -- Follow `SimulatedTiVoDevice` lifecycle: start when the test host starts, stop when host stops. Support explicit `StartAsync`/`StopAsync` for per-test control if needed. - -Implementation notes --------------------- -- Reference implementations: `SimulatedTiVoDevice` (test services) and `src/AdaptiveRemote.App/Services/Broadlink/DeviceConnection.cs` for protocol expectations. -- Keep encoder/decoder code in `test/AdaptiveRemote.EndtoEndTests.TestServices/Broadlink` to avoid coupling with runtime app code. -- Provide a small suite of unit tests for the decoder/encoder using recorded real-device captures (if available) or synthetic packets. -- Create a Gherkin feature that tests a Broadlink command (Power or VolumeUp), similar to the TiVo command test that exists today. - -Sample Gherkin feature ----------------------- -Scenario: Send Power command records non-empty IR payload - When the app sends the "Power" command to the target device - Then the simulated Broadlink device should have recorded at least one outbound packet - And the recorded packet's `RawPayload` should not be empty - And no recorded packets should be marked as malformed - -- After completing this spec, replace it and `_doc_SimulatedTiVoDevice.md` with a combined `_doc_SimulatedDevices.md` that captures the final implementation of both devices, as well as notes on how to add more devices in the future. - -Next steps ----------- -- Implement `ISimulatedBroadlinkDevice` and in-process simulator following `SimulatedTiVoDevice` skeleton. -- Add options wiring to test host builder and update test launch configs to pass `--broadlink:Fake=True` where appropriate. -- Provide sample test that asserts a non-empty IR payload was recorded. - -References ----------- -- `src/AdaptiveRemote.App/Services/Broadlink/DeviceConnection.cs` -- `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVoDevice` (pattern)