From 96cc530d99d17565fc6ff62b7432ca7efde08d31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:57:03 +0000 Subject: [PATCH 01/10] Initial plan From 2878e1eb55ba1141673b5ff1dbbaf4b3ba919ebf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:01:56 +0000 Subject: [PATCH 02/10] Add simulated Broadlink device infrastructure and test integration Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../BroadlinkDevice.feature | 17 + .../BroadlinkSteps.cs | 80 +++++ .../HostSteps.cs | 31 +- .../Host/ISimulatedEnvironment.cs | 12 + .../Host/SimulatedEnvironment.cs | 10 + .../SimulatedBroadlink/BroadlinkEncryption.cs | 62 ++++ .../BroadlinkPacketDecoder.cs | 152 ++++++++ .../BroadlinkPacketEncoder.cs | 99 +++++ .../ISimulatedBroadlinkDevice.cs | 24 ++ .../SimulatedBroadlink/RecordedPacket.cs | 37 ++ .../SimulatedBroadlinkDevice.cs | 337 ++++++++++++++++++ .../SimulatedBroadlinkDeviceBuilder.cs | 37 ++ 12 files changed, 892 insertions(+), 6 deletions(-) create mode 100644 test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/RecordedPacket.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs diff --git a/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature b/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature new file mode 100644 index 0000000..20fc595 --- /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 outbound 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..2f41e79 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs @@ -0,0 +1,80 @@ +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 outbound packet")] + public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneOutboundPacket() + { + 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( + () => + { + IReadOnlyList packets = device.GetRecordedPackets(); + return packets.Any(p => p.IsInbound && p.RawPayload != null && p.RawPayload.Length > 0); + }, + timeoutInSeconds: 10); + + if (!found) + { + IReadOnlyList packets = device.GetRecordedPackets(); + string recordedPackets = string.Join(", ", packets.Select(p => + $"[{p.ReceivedAt:HH:mm:ss.fff}] {(p.IsInbound ? "←" : "→")} {p.DebugDescription}")); + Assert.Fail($"Expected Broadlink device to record at least one outbound packet with IR data, but none were found. Recorded packets: {recordedPackets}"); + } + + TestContext.WriteLine("Successfully verified Broadlink device recorded outbound 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"); + } + + IReadOnlyList packets = device.GetRecordedPackets(); + RecordedPacket? irPacket = packets.FirstOrDefault(p => p.IsInbound && p.RawPayload != null); + + 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"); + } + + IReadOnlyList packets = device.GetRecordedPackets(); + RecordedPacket? malformedPacket = packets.FirstOrDefault(p => p.IsMalformed); + + 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..bb67331 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -1,6 +1,7 @@ using AdaptiveRemote.EndtoEndTests; using AdaptiveRemote.EndtoEndTests.Host; using AdaptiveRemote.EndtoEndTests.Logging; +using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -12,6 +13,7 @@ namespace AdaptiveRemote.EndToEndTests.Steps; public class HostSteps : StepsBase { private const string TiVoDeviceName = "TiVo"; + private const string BroadlinkDeviceName = "Broadlink"; private string LogFilePath => Path.Combine(TestContext.TestResultsDirectory!, TestContext.TestName + ".log"); [BeforeScenario(Order = 50)] @@ -26,13 +28,22 @@ public void OnBeforeScenario_SetUpSimulatedEnvironment() { builder.AddProvider(new TestContextLoggerProvider(TestContext)); }); - ILogger logger = loggerFactory.CreateLogger("SimulatedTiVoDevice"); + ILogger tivoLogger = loggerFactory.CreateLogger("SimulatedTiVoDevice"); + ILogger broadlinkLogger = loggerFactory.CreateLogger("SimulatedBroadlinkDevice"); - ISimulatedDeviceBuilder builder = new SimulatedTiVoDeviceBuilder(logger); - simulatedEnvironment.RegisterDevice(TiVoDeviceName, builder); - ISimulatedDevice device = simulatedEnvironment.StartDevice(TiVoDeviceName); + // Register and start TiVo device + ISimulatedDeviceBuilder tivoBuilder = new SimulatedTiVoDeviceBuilder(tivoLogger); + simulatedEnvironment.RegisterDevice(TiVoDeviceName, tivoBuilder); + ISimulatedDevice tivoDevice = simulatedEnvironment.StartDevice(TiVoDeviceName); - TestContext.WriteLine($"Simulated TiVo device started on port {device.Port}"); + TestContext.WriteLine($"Simulated TiVo device started on port {tivoDevice.Port}"); + + // Register and start Broadlink device + ISimulatedDeviceBuilder broadlinkBuilder = new SimulatedBroadlinkDeviceBuilder(broadlinkLogger); + simulatedEnvironment.RegisterDevice(BroadlinkDeviceName, broadlinkBuilder); + ISimulatedDevice broadlinkDevice = simulatedEnvironment.StartDevice(BroadlinkDeviceName); + + TestContext.WriteLine($"Simulated Broadlink device started on port {broadlinkDevice.Port}"); } [BeforeScenario(Order = 200)] @@ -55,7 +66,15 @@ public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSet tivoArgs = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; } - hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} --broadlink:Fake=True --log:FilePath=\"{LogFilePath}\""); + // Use the simulated Broadlink device + string broadlinkArgs = string.Empty; + if (Environment.TryGetDevice(BroadlinkDeviceName, out ISimulatedDevice? broadlinkDevice) && broadlinkDevice != null) + { + // Configure the app to use the simulated device's discovery port + broadlinkArgs = $"--broadlink:DiscoveryPort={broadlinkDevice.Port}"; + } + + hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} {broadlinkArgs} --log:FilePath=\"{LogFilePath}\""); ProvideContainerObjectFactory(() => AdaptiveRemoteHost.CreateBuilder(hostSettings) .ConfigureLogging(builder => diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs index e182f10..61cce5d 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; /// @@ -5,6 +7,16 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// public interface ISimulatedEnvironment : IDisposable { + /// + /// Gets the simulated TiVo device, if started. + /// + ISimulatedDevice? TiVo { get; } + + /// + /// Gets the simulated Broadlink device, if started. + /// + ISimulatedBroadlinkDevice? Broadlink { get; } + /// /// Registers a device builder with the given name. /// diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index 674f270..21135d0 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -1,3 +1,4 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; namespace AdaptiveRemote.EndtoEndTests.Host; @@ -7,10 +8,19 @@ namespace AdaptiveRemote.EndtoEndTests.Host; /// public sealed class SimulatedEnvironment : ISimulatedEnvironment { + private const string TiVoDeviceName = "TiVo"; + private const string BroadlinkDeviceName = "Broadlink"; + private readonly Dictionary _builders = new(); private readonly Dictionary _devices = new(); private bool _disposed; + /// + public ISimulatedDevice? TiVo => _devices.TryGetValue(TiVoDeviceName, out ISimulatedDevice? device) ? device : null; + + /// + public ISimulatedBroadlinkDevice? Broadlink => _devices.TryGetValue(BroadlinkDeviceName, out ISimulatedDevice? device) ? device as ISimulatedBroadlinkDevice : null; + /// public void RegisterDevice(string name, ISimulatedDeviceBuilder builder) { diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs new file mode 100644 index 0000000..4b4dfc5 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs @@ -0,0 +1,62 @@ +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 = 256; + _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 if needed + 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..bc5fa64 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs @@ -0,0 +1,152 @@ +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); + if (calculatedChecksum != packetChecksum) + { + error = $"Checksum mismatch: expected 0x{packetChecksum: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..d5c768f --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs @@ -0,0 +1,99 @@ +using System.Net; +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(IPEndPoint localEndPoint, 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..63b1aed --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs @@ -0,0 +1,24 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; + +/// +/// Interface for the simulated Broadlink device, used by tests to verify packet transmission. +/// +public interface ISimulatedBroadlinkDevice : ISimulatedDevice +{ + /// + /// Gets the actual UDP port the device is bound to (useful when using ephemeral ports). + /// + int BoundPort { get; } + + /// + /// 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/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..bfabd4b --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -0,0 +1,337 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +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 const int DiscoveryResponsePort = 80; // Real devices respond on port 80 + + 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; + BoundPort = 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 int BoundPort { 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 cancellationToken) + { + try + { + // Try to decode as a scan (discovery) request first + if (BroadlinkPacketDecoder.TryDecodeScanRequest(data, out DecodedScanRequest? scanRequest, out _)) + { + _logger.LogInformation("Received discovery request from {RemoteEndPoint}", remoteEndPoint); + await HandleDiscoveryRequestAsync(scanRequest!, remoteEndPoint, cancellationToken); + return; + } + + // Try to decode as a regular packet + if (BroadlinkPacketDecoder.TryDecodePacket(data, remoteEndPoint, out DecodedPacket? packet, out string? error)) + { + await HandleCommandPacketAsync(packet!, remoteEndPoint, cancellationToken); + } + else + { + _logger.LogWarning("Failed to decode packet from {RemoteEndPoint}: {Error}", remoteEndPoint, error); + RecordMalformedPacket(remoteEndPoint, data, error ?? "Unknown error"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling packet from {RemoteEndPoint}", remoteEndPoint); + } + } + + private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + { + // Build discovery response + byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( + new IPEndPoint(IPAddress.Loopback, Port), + DefaultDeviceType, + _macAddress, + isLocked: false); + + // Send response to the port specified in the request + IPEndPoint responseEndPoint = new IPEndPoint(remoteEndPoint.Address, request.LocalPort); + await _udpClient.SendAsync(response, response.Length, responseEndPoint); + + _logger.LogInformation("Sent discovery response to {EndPoint}", responseEndPoint); + } + + private async Task HandleCommandPacketAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + { + _logger.LogInformation("Received packet type 0x{PacketType:X2} from {RemoteEndPoint}", packet.PacketType, remoteEndPoint); + + switch (packet.PacketType) + { + case AuthenticateCommand: + await HandleAuthenticateAsync(packet, remoteEndPoint, cancellationToken); + break; + + case SendDataCommand: + await HandleSendDataAsync(packet, remoteEndPoint, cancellationToken); + break; + + default: + _logger.LogWarning("Unknown packet type 0x{PacketType:X2}", packet.PacketType); + break; + } + } + + private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + { + _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, CancellationToken cancellationToken) + { + _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, packet.Payload, "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); + int command = BitConverter.ToInt32(decryptedPayload, 2); + + 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(new byte[0]); + 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, byte[] data, 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..b2ce52a --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs @@ -0,0 +1,37 @@ +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; + private int _port; + + public SimulatedBroadlinkDeviceBuilder(ILogger logger) + { + _logger = logger; + } + + /// + public ISimulatedDeviceBuilder WithPort(int port) + { + _port = port; + return this; + } + + /// + public ISimulatedDevice Start() + { + return new SimulatedBroadlinkDevice(_port, _logger); + } + + /// + public void Dispose() + { + // Nothing to dispose in the builder + } +} From eb3f11b1265669a1ee0b428b7dde0398a19c07f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:04:48 +0000 Subject: [PATCH 03/10] Fix code warnings and build errors in simulated Broadlink device Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../BroadlinkSteps.cs | 2 +- .../SimulatedBroadlink/BroadlinkEncryption.cs | 2 +- .../BroadlinkPacketEncoder.cs | 10 +++++----- .../SimulatedBroadlinkDevice.cs | 17 ++++++++++------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs index 2f41e79..835fa2a 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs @@ -29,7 +29,7 @@ public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneOutboundPacket() if (!found) { IReadOnlyList packets = device.GetRecordedPackets(); - string recordedPackets = string.Join(", ", packets.Select(p => + string recordedPackets = string.Join(", ", packets.Select(p => $"[{p.ReceivedAt:HH:mm:ss.fff}] {(p.IsInbound ? "←" : "→")} {p.DebugDescription}")); Assert.Fail($"Expected Broadlink device to record at least one outbound packet with IR data, but none were found. Recorded packets: {recordedPackets}"); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs index 4b4dfc5..c8f30dc 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs @@ -31,7 +31,7 @@ public byte[] Encrypt(byte[] data) using CryptoStream cryptoStream = new CryptoStream(output, encryptor, CryptoStreamMode.Write); cryptoStream.Write(data); - + // Add padding if needed int padding = (8192 - data.Length) % 16; if (padding > 0) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs index d5c768f..cce6b64 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs @@ -12,7 +12,7 @@ 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(IPEndPoint localEndPoint, short deviceType, PhysicalAddress macAddress, bool isLocked) + public static byte[] EncodeScanResponse(IPEndPoint _, short deviceType, PhysicalAddress macAddress, bool isLocked) { byte[] response = new byte[0x80]; @@ -40,15 +40,15 @@ public static byte[] EncodeResponse(short deviceType, short packetType, short me 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); diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs index bfabd4b..6161da6 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; @@ -23,7 +24,7 @@ public sealed class SimulatedBroadlinkDevice : ISimulatedBroadlinkDevice private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly ConcurrentBag _recordedPackets = new(); private readonly Task _listenerTask; - + private short _deviceId; private BroadlinkEncryption? _encryption; private bool _disposed; @@ -174,7 +175,7 @@ private async Task HandlePacketAsync(byte[] data, IPEndPoint remoteEndPoint, Can } } - private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint, CancellationToken _) { // Build discovery response byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( @@ -210,7 +211,7 @@ private async Task HandleCommandPacketAsync(DecodedPacket packet, IPEndPoint rem } } - private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken _) { _logger.LogInformation("Handling authentication request"); @@ -258,7 +259,7 @@ private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remo }); } - private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken _) { _logger.LogInformation("Handling send data command"); @@ -281,8 +282,10 @@ private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEn if (decryptedPayload.Length >= 6) { short dataLength = BitConverter.ToInt16(decryptedPayload, 0); +#pragma warning disable IDE0059 // Unnecessary assignment of a value int command = BitConverter.ToInt32(decryptedPayload, 2); - +#pragma warning restore IDE0059 // Unnecessary assignment of a value + if (decryptedPayload.Length >= 6 + (dataLength - 4)) { irData = new byte[dataLength - 4]; @@ -301,7 +304,7 @@ private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEn }); // Build response - byte[] responsePayload = _encryption!.Encrypt(new byte[0]); + byte[] responsePayload = _encryption!.Encrypt(Array.Empty()); byte[] response = BroadlinkPacketEncoder.EncodeResponse( DefaultDeviceType, SendDataCommand, @@ -323,7 +326,7 @@ private void RecordPacket(RecordedPacket packet) _logger.LogInformation("Recorded packet: {Description}", packet.DebugDescription); } - private void RecordMalformedPacket(EndPoint remoteEndPoint, byte[] data, string error) + private void RecordMalformedPacket(EndPoint remoteEndPoint, byte[] _, string error) { RecordPacket(new RecordedPacket { From b553a0d17444504e621483eb03b83d5453aff168 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:05:57 +0000 Subject: [PATCH 04/10] Add configurable discovery endpoint for Broadlink testing Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Broadlink/BroadlinkSettings.cs | 10 ++++++++++ .../Services/Broadlink/UdpService.cs | 7 ++++++- test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs | 4 ++-- 3 files changed, 18 insertions(+), 3 deletions(-) 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/UdpService.cs b/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs index 5c65874..17e6e9a 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); diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index bb67331..642faf3 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -70,8 +70,8 @@ public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSet string broadlinkArgs = string.Empty; if (Environment.TryGetDevice(BroadlinkDeviceName, out ISimulatedDevice? broadlinkDevice) && broadlinkDevice != null) { - // Configure the app to use the simulated device's discovery port - broadlinkArgs = $"--broadlink:DiscoveryPort={broadlinkDevice.Port}"; + // Configure the app to discover the simulated device on loopback at its port + broadlinkArgs = $"--broadlink:DiscoveryAddress=127.0.0.1 --broadlink:DiscoveryPort={broadlinkDevice.Port}"; } hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} {broadlinkArgs} --log:FilePath=\"{LogFilePath}\""); From c83ec96b3cb64a5cb8673f565148917f3f86b136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:11:40 +0000 Subject: [PATCH 05/10] Fix UDP client SendAsync calls to use correct API Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../SimulatedBroadlinkDevice.cs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs index 6161da6..ca84afd 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -146,22 +146,22 @@ private async Task ListenAsync(CancellationToken cancellationToken) } } - private async Task HandlePacketAsync(byte[] data, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + 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 _)) + if (BroadlinkPacketDecoder.TryDecodeScanRequest(data, out DecodedScanRequest? scanRequest, out string? scanError)) { _logger.LogInformation("Received discovery request from {RemoteEndPoint}", remoteEndPoint); - await HandleDiscoveryRequestAsync(scanRequest!, remoteEndPoint, cancellationToken); + 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, cancellationToken); + await HandleCommandPacketAsync(packet!, remoteEndPoint); } else { @@ -175,7 +175,7 @@ private async Task HandlePacketAsync(byte[] data, IPEndPoint remoteEndPoint, Can } } - private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint, CancellationToken _) + private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint) { // Build discovery response byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( @@ -186,23 +186,23 @@ private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEnd // Send response to the port specified in the request IPEndPoint responseEndPoint = new IPEndPoint(remoteEndPoint.Address, request.LocalPort); - await _udpClient.SendAsync(response, response.Length, responseEndPoint); + int bytesSent = await _udpClient.SendAsync(response, responseEndPoint); - _logger.LogInformation("Sent discovery response to {EndPoint}", responseEndPoint); + _logger.LogInformation("Sent discovery response ({BytesSent} bytes) to {EndPoint}", bytesSent, responseEndPoint); } - private async Task HandleCommandPacketAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + 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, cancellationToken); + await HandleAuthenticateAsync(packet, remoteEndPoint); break; case SendDataCommand: - await HandleSendDataAsync(packet, remoteEndPoint, cancellationToken); + await HandleSendDataAsync(packet, remoteEndPoint); break; default: @@ -211,7 +211,7 @@ private async Task HandleCommandPacketAsync(DecodedPacket packet, IPEndPoint rem } } - private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken _) + private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remoteEndPoint) { _logger.LogInformation("Handling authentication request"); @@ -241,7 +241,7 @@ private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remo errorCode: 0); // Send response - await _udpClient.SendAsync(response, response.Length, remoteEndPoint); + await _udpClient.SendAsync(response, remoteEndPoint); // Switch to the new encryption key _encryption?.Dispose(); @@ -259,7 +259,7 @@ private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remo }); } - private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEndPoint, CancellationToken _) + private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEndPoint) { _logger.LogInformation("Handling send data command"); @@ -315,7 +315,7 @@ private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEn errorCode: 0); // Send response - await _udpClient.SendAsync(response, response.Length, remoteEndPoint); + await _udpClient.SendAsync(response, remoteEndPoint); _logger.LogInformation("Send data command complete, IR payload: {PayloadSize} bytes", irData?.Length ?? 0); } From a6cff4c5eeb63d01bf92b93a349d667e6be0bbfb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:20:39 +0000 Subject: [PATCH 06/10] Fix simulated Broadlink device discovery and verification Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Broadlink/ISocket.cs | 5 ++ .../Services/Broadlink/SocketWrapper.cs | 2 + .../Services/Broadlink/UdpService.cs | 7 +++ .../BroadlinkPacketDecoder.cs | 5 +- .../SimulatedBroadlinkDevice.cs | 47 +++++++++++++------ 5 files changed, 50 insertions(+), 16 deletions(-) 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 17e6e9a..af06d48 100644 --- a/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs +++ b/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs @@ -49,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.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs index bc5fa64..c12c6fa 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketDecoder.cs @@ -54,9 +54,10 @@ public static bool TryDecodePacket(byte[] data, EndPoint remoteEndPoint, out Dec // Verify packet checksum int calculatedChecksum = ComputeChecksum(data); - if (calculatedChecksum != packetChecksum) + int expectedChecksum = packetChecksum & 0xFFFF; // Convert signed short to unsigned comparison + if (calculatedChecksum != expectedChecksum) { - error = $"Checksum mismatch: expected 0x{packetChecksum:X4}, calculated 0x{calculatedChecksum:X4}"; + error = $"Checksum mismatch: expected 0x{expectedChecksum:X4}, calculated 0x{calculatedChecksum:X4}"; return false; } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs index ca84afd..eb00e10 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -177,18 +177,37 @@ private async Task HandlePacketAsync(byte[] data, IPEndPoint remoteEndPoint, Can private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEndPoint remoteEndPoint) { - // Build discovery response - byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( - new IPEndPoint(IPAddress.Loopback, Port), - DefaultDeviceType, - _macAddress, - isLocked: false); - - // Send response to the port specified in the request - IPEndPoint responseEndPoint = new IPEndPoint(remoteEndPoint.Address, request.LocalPort); - int bytesSent = await _udpClient.SendAsync(response, responseEndPoint); - - _logger.LogInformation("Sent discovery response ({BytesSent} bytes) to {EndPoint}", bytesSent, responseEndPoint); + try + { + // Build discovery response + byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( + new IPEndPoint(IPAddress.Loopback, Port), + 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) @@ -241,7 +260,7 @@ private async Task HandleAuthenticateAsync(DecodedPacket packet, IPEndPoint remo errorCode: 0); // Send response - await _udpClient.SendAsync(response, remoteEndPoint); + await _udpClient.SendAsync(response, response.Length, remoteEndPoint); // Switch to the new encryption key _encryption?.Dispose(); @@ -315,7 +334,7 @@ private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEn errorCode: 0); // Send response - await _udpClient.SendAsync(response, remoteEndPoint); + await _udpClient.SendAsync(response, response.Length, remoteEndPoint); _logger.LogInformation("Send data command complete, IR payload: {PayloadSize} bytes", irData?.Length ?? 0); } From 0e9cb948b471fb5818f09d200f52eac8f483cab9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:22:42 +0000 Subject: [PATCH 07/10] Add comprehensive documentation for simulated devices Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../_doc_SimulatedDevices.md | 397 ++++++++++++++++++ .../_doc_SimulatedTiVoDevice.md | 216 ---------- .../_spec_SimulatedBroadlinkDevice.md | 121 ------ 3 files changed, 397 insertions(+), 337 deletions(-) create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md delete mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md delete mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedBroadlinkDevice.md diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md new file mode 100644 index 0000000..4c4b6df --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md @@ -0,0 +1,397 @@ +# 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:** Configurable (ephemeral port for tests, default 31339 for real devices) +**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/` + +**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:** Configurable (ephemeral port for tests, port 80 for discovery on real devices) +**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/` + +**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 + +```csharp +/// +/// Base interface for all simulated devices. +/// +public interface ISimulatedDevice : IDisposable +{ + void Stop(); + int Port { get; } + IReadOnlyList GetRecordedMessages(); + void ClearRecordedMessages(); +} + +/// +/// Builder pattern for creating simulated devices. +/// +public interface ISimulatedDeviceBuilder : IDisposable +{ + ISimulatedDeviceBuilder WithPort(int port); + ISimulatedDevice Start(); +} + +/// +/// Test environment managing simulated devices. +/// +public interface ISimulatedEnvironment : IDisposable +{ + ISimulatedDevice? TiVo { get; } + ISimulatedBroadlinkDevice? Broadlink { get; } + void RegisterDevice(string name, ISimulatedDeviceBuilder builder); + ISimulatedDevice StartDevice(string name); + bool TryGetDevice(string name, out ISimulatedDevice? device); +} +``` + +### Device-Specific Interfaces + +#### ISimulatedBroadlinkDevice + +Extends `ISimulatedDevice` with Broadlink-specific capabilities: + +```csharp +public interface ISimulatedBroadlinkDevice : ISimulatedDevice +{ + int BoundPort { get; } + IReadOnlyList GetRecordedPackets(); + void ClearRecordedPackets(); +} + +public sealed record RecordedPacket +{ + public DateTimeOffset ReceivedAt { get; init; } + public bool IsInbound { get; init; } + public short PacketType { get; init; } + public byte[]? RawPayload { get; init; } // IR data + public bool IsMalformed { get; init; } + public string DebugDescription { get; init; } +} +``` + +## Test Integration + +### Lifecycle Management + +Simulated devices are automatically managed by the test framework: + +1. **BeforeScenario (Order=50):** `ISimulatedEnvironment` is created +2. **BeforeScenario (Order=50):** Simulated devices are registered and started +3. **BeforeScenario (Order=200):** Host application is started with device configuration +4. **AfterScenario:** Devices and host are cleaned up + +### Configuration + +#### TiVo Device Configuration + +```csharp +ISimulatedDeviceBuilder tivoBuilder = new SimulatedTiVoDeviceBuilder(logger); +simulatedEnvironment.RegisterDevice("TiVo", tivoBuilder); +ISimulatedDevice tivoDevice = simulatedEnvironment.StartDevice("TiVo"); + +// Configure host to connect to simulated device +string args = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; +``` + +#### Broadlink Device Configuration + +```csharp +ISimulatedDeviceBuilder broadlinkBuilder = new SimulatedBroadlinkDeviceBuilder(logger); +simulatedEnvironment.RegisterDevice("Broadlink", broadlinkBuilder); +ISimulatedDevice broadlinkDevice = simulatedEnvironment.StartDevice("Broadlink"); + +// Configure host for discovery on loopback +string args = $"--broadlink:DiscoveryAddress=127.0.0.1 --broadlink:DiscoveryPort={broadlinkDevice.Port}"; +``` + +### Step Definitions + +#### TiVo Steps + +```csharp +[Then(@"I should see the TiVo receives a {string} message")] +public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) +{ + ISimulatedDevice? device = Environment.TiVo; + Assert.IsNotNull(device, "TiVo device is not running"); + + bool found = WaitHelpers.ExecuteWithRetries(() => + { + IReadOnlyList messages = device.GetRecordedMessages(); + return messages.Any(m => m.Incoming && + m.Payload.Equals($"IRCODE {expectedCommand}", StringComparison.OrdinalIgnoreCase)); + }, timeoutInSeconds: 5); + + Assert.IsTrue(found, $"Expected TiVo to receive message 'IRCODE {expectedCommand}'"); +} +``` + +#### Broadlink Steps + +```csharp +[Then(@"I should see the Broadlink device recorded at least one outbound packet")] +public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneOutboundPacket() +{ + ISimulatedBroadlinkDevice? device = Environment.Broadlink; + Assert.IsNotNull(device, "Broadlink device is not running"); + + bool found = WaitHelpers.ExecuteWithRetries(() => + { + IReadOnlyList packets = device.GetRecordedPackets(); + return packets.Any(p => p.IsInbound && p.RawPayload != null && p.RawPayload.Length > 0); + }, timeoutInSeconds: 10); + + Assert.IsTrue(found, "Expected Broadlink device to record at least one packet with IR data"); +} + +[Then(@"no Broadlink packets should be marked as malformed")] +public void ThenNoBroadlinkPacketsShouldBeMarkedAsMalformed() +{ + ISimulatedBroadlinkDevice? device = Environment.Broadlink; + IReadOnlyList packets = device!.GetRecordedPackets(); + RecordedPacket? malformedPacket = packets.FirstOrDefault(p => p.IsMalformed); + + Assert.IsNull(malformedPacket, $"Found malformed packet: {malformedPacket?.DebugDescription}"); +} +``` + +### Gherkin Features + +#### TiVo Device Feature + +```gherkin +Feature: TiVo Device Integration + Scenario: TiVo receives Play command + Given 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 +``` + +#### Broadlink Device Feature + +```gherkin +Feature: Broadlink Device Integration + 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 + When I click on the 'Power' button + Then I should see the Broadlink device recorded at least one outbound packet + And the recorded Broadlink packet's raw payload should not be empty + And no Broadlink packets should be marked as malformed +``` + +## 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) + +### Broadlink Protocol + +**Message Format:** Binary packets with headers, checksums, and encrypted payloads + +**Packet Structure:** +``` +[Preamble: 8 bytes] [Header: 0x38 bytes] [Payload: variable] +``` + +**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) From e36cc05c42539d174077bd42c75a50b8414f0dea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:26:56 +0000 Subject: [PATCH 08/10] Address code review feedback: fix terminology, remove unused params, improve documentation Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../BroadlinkDevice.feature | 2 +- .../BroadlinkSteps.cs | 8 ++++---- .../SimulatedBroadlink/BroadlinkEncryption.cs | 6 ++++-- .../SimulatedBroadlink/BroadlinkPacketEncoder.cs | 3 +-- .../SimulatedBroadlink/SimulatedBroadlinkDevice.cs | 12 ++++-------- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature b/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature index 20fc595..25c8f3d 100644 --- a/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature +++ b/test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature @@ -9,7 +9,7 @@ Scenario: Broadlink receives Power command 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 outbound packet + 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 diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs index 835fa2a..eb9b413 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs @@ -8,8 +8,8 @@ namespace AdaptiveRemote.EndToEndTests.Steps; [Binding] public class BroadlinkSteps : StepsBase { - [Then(@"I should see the Broadlink device recorded at least one outbound packet")] - public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneOutboundPacket() + [Then(@"I should see the Broadlink device recorded at least one inbound packet")] + public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneInboundPacket() { ISimulatedBroadlinkDevice? device = Environment.Broadlink; if (device == null) @@ -31,10 +31,10 @@ public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneOutboundPacket() IReadOnlyList packets = device.GetRecordedPackets(); string recordedPackets = string.Join(", ", packets.Select(p => $"[{p.ReceivedAt:HH:mm:ss.fff}] {(p.IsInbound ? "←" : "→")} {p.DebugDescription}")); - Assert.Fail($"Expected Broadlink device to record at least one outbound packet with IR data, but none were found. Recorded packets: {recordedPackets}"); + 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 outbound packet with IR data"); + TestContext.WriteLine("Successfully verified Broadlink device recorded inbound packet with IR data"); } [Then(@"the recorded Broadlink packet's raw payload should not be empty")] diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs index c8f30dc..18db375 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkEncryption.cs @@ -16,7 +16,7 @@ internal sealed class BroadlinkEncryption : IDisposable public BroadlinkEncryption(byte[]? key = null) { _aes = Aes.Create(); - _aes.KeySize = 256; + _aes.KeySize = 128; // 128-bit AES (key is 16 bytes) _aes.BlockSize = 128; _aes.Mode = CipherMode.CBC; _aes.IV = InitialVector; @@ -32,7 +32,9 @@ public byte[] Encrypt(byte[] data) cryptoStream.Write(data); - // Add padding if needed + // 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) { diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs index cce6b64..2b0ea44 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/BroadlinkPacketEncoder.cs @@ -1,4 +1,3 @@ -using System.Net; using System.Net.NetworkInformation; namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; @@ -12,7 +11,7 @@ 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(IPEndPoint _, short deviceType, PhysicalAddress macAddress, bool isLocked) + public static byte[] EncodeScanResponse(short deviceType, PhysicalAddress macAddress, bool isLocked) { byte[] response = new byte[0x80]; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs index eb00e10..08d6568 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -16,7 +16,6 @@ public sealed class SimulatedBroadlinkDevice : ISimulatedBroadlinkDevice private const short DefaultDeviceType = 0x2737; // RM Mini 3 private const int AuthenticateCommand = 0x65; private const int SendDataCommand = 0x6A; - private const int DiscoveryResponsePort = 80; // Real devices respond on port 80 private readonly ILogger _logger; private readonly UdpClient _udpClient; @@ -166,7 +165,7 @@ private async Task HandlePacketAsync(byte[] data, IPEndPoint remoteEndPoint, Can else { _logger.LogWarning("Failed to decode packet from {RemoteEndPoint}: {Error}", remoteEndPoint, error); - RecordMalformedPacket(remoteEndPoint, data, error ?? "Unknown error"); + RecordMalformedPacket(remoteEndPoint, error ?? "Unknown error"); } } catch (Exception ex) @@ -181,7 +180,6 @@ private async Task HandleDiscoveryRequestAsync(DecodedScanRequest request, IPEnd { // Build discovery response byte[] response = BroadlinkPacketEncoder.EncodeScanResponse( - new IPEndPoint(IPAddress.Loopback, Port), DefaultDeviceType, _macAddress, isLocked: false); @@ -291,7 +289,7 @@ private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEn catch (Exception ex) { _logger.LogError(ex, "Failed to decrypt payload"); - RecordMalformedPacket(remoteEndPoint, packet.Payload, "Decryption failed"); + RecordMalformedPacket(remoteEndPoint, "Decryption failed"); return; } @@ -301,9 +299,7 @@ private async Task HandleSendDataAsync(DecodedPacket packet, IPEndPoint remoteEn if (decryptedPayload.Length >= 6) { short dataLength = BitConverter.ToInt16(decryptedPayload, 0); -#pragma warning disable IDE0059 // Unnecessary assignment of a value - int command = BitConverter.ToInt32(decryptedPayload, 2); -#pragma warning restore IDE0059 // Unnecessary assignment of a value + // Command type at offset 2 (not currently validated) if (decryptedPayload.Length >= 6 + (dataLength - 4)) { @@ -345,7 +341,7 @@ private void RecordPacket(RecordedPacket packet) _logger.LogInformation("Recorded packet: {Description}", packet.DebugDescription); } - private void RecordMalformedPacket(EndPoint remoteEndPoint, byte[] _, string error) + private void RecordMalformedPacket(EndPoint remoteEndPoint, string error) { RecordPacket(new RecordedPacket { From 7e3b34f70053394ffe8e124482f0e33581d03504 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:16:46 +0000 Subject: [PATCH 09/10] Refactor simulated devices per code review feedback - Move test logic from BroadlinkSteps to extension methods in TestServices - Remove registration methods from ISimulatedEnvironment (use constructor injection) - Rename ISimulatedDevice to ISimulatedTiVoDevice for clarity - Remove BoundPort property (use Port from base interface) - Update builders to accept ILoggerFactory instead of ILogger - Remove WithPort customization (always use ephemeral ports) - Simplify SimulatedEnvironment to construct devices directly Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../BroadlinkSteps.cs | 18 +--- .../HostSteps.cs | 34 ++------ .../TiVoSteps.cs | 9 +- .../Host/ISimulatedEnvironment.cs | 24 +----- .../Host/SimulatedEnvironment.cs | 83 ++++++------------- .../ISimulatedBroadlinkDevice.cs | 7 +- .../ISimulatedBroadlinkDeviceExtensions.cs | 52 ++++++++++++ .../SimulatedBroadlinkDevice.cs | 4 - .../SimulatedBroadlinkDeviceBuilder.cs | 11 ++- .../SimulatedTiVo/ISimulatedDevice.cs | 4 +- .../SimulatedTiVo/ISimulatedDeviceBuilder.cs | 4 +- .../SimulatedTiVo/SimulatedTiVoDevice.cs | 2 +- .../SimulatedTiVoDeviceBuilder.cs | 8 +- 13 files changed, 109 insertions(+), 151 deletions(-) create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs index eb9b413..9a062a6 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs @@ -18,19 +18,11 @@ public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneInboundPacket() } // Poll for packets with a timeout of 10 seconds - bool found = WaitHelpers.ExecuteWithRetries( - () => - { - IReadOnlyList packets = device.GetRecordedPackets(); - return packets.Any(p => p.IsInbound && p.RawPayload != null && p.RawPayload.Length > 0); - }, - timeoutInSeconds: 10); + bool found = WaitHelpers.ExecuteWithRetries(device.HasRecordedInboundPacketWithIrData, timeoutInSeconds: 10); if (!found) { - IReadOnlyList packets = device.GetRecordedPackets(); - string recordedPackets = string.Join(", ", packets.Select(p => - $"[{p.ReceivedAt:HH:mm:ss.fff}] {(p.IsInbound ? "←" : "→")} {p.DebugDescription}")); + 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}"); } @@ -46,8 +38,7 @@ public void ThenTheRecordedBroadlinkPacketRawPayloadShouldNotBeEmpty() Assert.Fail("Broadlink device is not running"); } - IReadOnlyList packets = device.GetRecordedPackets(); - RecordedPacket? irPacket = packets.FirstOrDefault(p => p.IsInbound && p.RawPayload != null); + RecordedPacket? irPacket = device.GetFirstPacketWithIrData(); if (irPacket == null) { @@ -67,8 +58,7 @@ public void ThenNoBroadlinkPacketsShouldBeMarkedAsMalformed() Assert.Fail("Broadlink device is not running"); } - IReadOnlyList packets = device.GetRecordedPackets(); - RecordedPacket? malformedPacket = packets.FirstOrDefault(p => p.IsMalformed); + RecordedPacket? malformedPacket = device.GetFirstMalformedPacket(); if (malformedPacket != null) { diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index 642faf3..ee618ea 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -1,7 +1,6 @@ using AdaptiveRemote.EndtoEndTests; using AdaptiveRemote.EndtoEndTests.Host; using AdaptiveRemote.EndtoEndTests.Logging; -using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -12,38 +11,23 @@ namespace AdaptiveRemote.EndToEndTests.Steps; [Binding] public class HostSteps : StepsBase { - private const string TiVoDeviceName = "TiVo"; - private const string BroadlinkDeviceName = "Broadlink"; 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 tivoLogger = loggerFactory.CreateLogger("SimulatedTiVoDevice"); - ILogger broadlinkLogger = loggerFactory.CreateLogger("SimulatedBroadlinkDevice"); - - // Register and start TiVo device - ISimulatedDeviceBuilder tivoBuilder = new SimulatedTiVoDeviceBuilder(tivoLogger); - simulatedEnvironment.RegisterDevice(TiVoDeviceName, tivoBuilder); - ISimulatedDevice tivoDevice = simulatedEnvironment.StartDevice(TiVoDeviceName); - TestContext.WriteLine($"Simulated TiVo device started on port {tivoDevice.Port}"); - - // Register and start Broadlink device - ISimulatedDeviceBuilder broadlinkBuilder = new SimulatedBroadlinkDeviceBuilder(broadlinkLogger); - simulatedEnvironment.RegisterDevice(BroadlinkDeviceName, broadlinkBuilder); - ISimulatedDevice broadlinkDevice = simulatedEnvironment.StartDevice(BroadlinkDeviceName); + // Create environment with devices + SimulatedEnvironment simulatedEnvironment = new SimulatedEnvironment(loggerFactory); + ProvideContainerObject(simulatedEnvironment); - TestContext.WriteLine($"Simulated Broadlink device started on port {broadlinkDevice.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)] @@ -61,17 +45,17 @@ 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:{tivoDevice.Port}"; + tivoArgs = $"--tivo:IP=127.0.0.1:{Environment.TiVo.Port}"; } // Use the simulated Broadlink device string broadlinkArgs = string.Empty; - if (Environment.TryGetDevice(BroadlinkDeviceName, out ISimulatedDevice? broadlinkDevice) && broadlinkDevice != null) + if (Environment.Broadlink != null) { // Configure the app to discover the simulated device on loopback at its port - broadlinkArgs = $"--broadlink:DiscoveryAddress=127.0.0.1 --broadlink:DiscoveryPort={broadlinkDevice.Port}"; + broadlinkArgs = $"--broadlink:DiscoveryAddress=127.0.0.1 --broadlink:DiscoveryPort={Environment.Broadlink.Port}"; } hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} {broadlinkArgs} --log:FilePath=\"{LogFilePath}\""); 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 61cce5d..4ed5436 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs @@ -10,32 +10,10 @@ public interface ISimulatedEnvironment : IDisposable /// /// Gets the simulated TiVo device, if started. /// - ISimulatedDevice? TiVo { get; } + ISimulatedTiVoDevice? TiVo { get; } /// /// Gets the simulated Broadlink device, if started. /// ISimulatedBroadlinkDevice? Broadlink { get; } - - /// - /// 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. - /// - /// The name of the device to start. - /// The running simulated device. - ISimulatedDevice StartDevice(string name); - - /// - /// Attempts to retrieve a running device by name. - /// - /// 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); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index 21135d0..0a3aa27 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -1,5 +1,6 @@ using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.Host; @@ -8,53 +9,24 @@ namespace AdaptiveRemote.EndtoEndTests.Host; /// public sealed class SimulatedEnvironment : ISimulatedEnvironment { - private const string TiVoDeviceName = "TiVo"; - private const string BroadlinkDeviceName = "Broadlink"; - - private readonly Dictionary _builders = new(); - private readonly Dictionary _devices = new(); + private ISimulatedTiVoDevice? _tivo; + private ISimulatedBroadlinkDevice? _broadlink; private bool _disposed; - /// - public ISimulatedDevice? TiVo => _devices.TryGetValue(TiVoDeviceName, out ISimulatedDevice? device) ? device : null; - - /// - public ISimulatedBroadlinkDevice? Broadlink => _devices.TryGetValue(BroadlinkDeviceName, out ISimulatedDevice? device) ? device as ISimulatedBroadlinkDevice : null; - - /// - 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() @@ -64,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/ISimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs index 63b1aed..15c91a3 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDevice.cs @@ -5,13 +5,8 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; /// /// Interface for the simulated Broadlink device, used by tests to verify packet transmission. /// -public interface ISimulatedBroadlinkDevice : ISimulatedDevice +public interface ISimulatedBroadlinkDevice : ISimulatedTiVoDevice { - /// - /// Gets the actual UDP port the device is bound to (useful when using ephemeral ports). - /// - int BoundPort { get; } - /// /// Gets all packets recorded since the device started or since the last clear. /// 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/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs index 08d6568..f02a65e 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -33,7 +33,6 @@ internal SimulatedBroadlinkDevice(int port, ILogger logger) _logger = logger; _udpClient = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); Port = ((IPEndPoint)_udpClient.Client.LocalEndPoint!).Port; - BoundPort = Port; // Generate a random MAC address for testing byte[] macBytes = new byte[6]; @@ -52,9 +51,6 @@ internal SimulatedBroadlinkDevice(int port, ILogger logger) /// public int Port { get; } - /// - public int BoundPort { get; } - /// public IReadOnlyList GetRecordedMessages() { diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs index b2ce52a..be170e8 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDeviceBuilder.cs @@ -9,24 +9,23 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; public sealed class SimulatedBroadlinkDeviceBuilder : ISimulatedDeviceBuilder { private readonly ILogger _logger; - private int _port; - public SimulatedBroadlinkDeviceBuilder(ILogger logger) + public SimulatedBroadlinkDeviceBuilder(ILoggerFactory loggerFactory) { - _logger = logger; + _logger = loggerFactory.CreateLogger(); } /// public ISimulatedDeviceBuilder WithPort(int port) { - _port = port; + // Port configuration is not supported - always use ephemeral port return this; } /// - public ISimulatedDevice Start() + public ISimulatedTiVoDevice Start() { - return new SimulatedBroadlinkDevice(_port, _logger); + return new SimulatedBroadlinkDevice(0, _logger); // Always use port 0 for ephemeral } /// 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); } From 0146614dd5f5620c7d0f60eed4b7f4e082d3447a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:18:57 +0000 Subject: [PATCH 10/10] Update documentation to link to code files instead of embedding samples Replace code samples with links to actual implementation files in _doc_SimulatedDevices.md Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../_doc_SimulatedDevices.md | 206 ++++-------------- 1 file changed, 45 insertions(+), 161 deletions(-) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md index 4c4b6df..1b52f6f 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedDevices.md @@ -11,8 +11,10 @@ The AdaptiveRemote test suite includes in-process simulated devices that enable Simulates a TiVo DVR device for testing TiVo command integration. **Protocol:** TCP-based ASCII protocol with carriage return (`\r`) line terminators -**Port:** Configurable (ephemeral port for tests, default 31339 for real devices) -**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/` +**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`) @@ -25,8 +27,10 @@ Simulates a TiVo DVR device for testing TiVo command integration. 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:** Configurable (ephemeral port for tests, port 80 for discovery on real devices) -**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/` +**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 @@ -40,178 +44,54 @@ Simulates a Broadlink IR controller for testing IR command transmission to TVs a ### Core Interfaces -```csharp -/// -/// Base interface for all simulated devices. -/// -public interface ISimulatedDevice : IDisposable -{ - void Stop(); - int Port { get; } - IReadOnlyList GetRecordedMessages(); - void ClearRecordedMessages(); -} - -/// -/// Builder pattern for creating simulated devices. -/// -public interface ISimulatedDeviceBuilder : IDisposable -{ - ISimulatedDeviceBuilder WithPort(int port); - ISimulatedDevice Start(); -} - -/// -/// Test environment managing simulated devices. -/// -public interface ISimulatedEnvironment : IDisposable -{ - ISimulatedDevice? TiVo { get; } - ISimulatedBroadlinkDevice? Broadlink { get; } - void RegisterDevice(string name, ISimulatedDeviceBuilder builder); - ISimulatedDevice StartDevice(string name); - bool TryGetDevice(string name, out ISimulatedDevice? device); -} -``` - -### Device-Specific Interfaces - -#### ISimulatedBroadlinkDevice - -Extends `ISimulatedDevice` with Broadlink-specific capabilities: - -```csharp -public interface ISimulatedBroadlinkDevice : ISimulatedDevice -{ - int BoundPort { get; } - IReadOnlyList GetRecordedPackets(); - void ClearRecordedPackets(); -} - -public sealed record RecordedPacket -{ - public DateTimeOffset ReceivedAt { get; init; } - public bool IsInbound { get; init; } - public short PacketType { get; init; } - public byte[]? RawPayload { get; init; } // IR data - public bool IsMalformed { get; init; } - public string DebugDescription { get; init; } -} -``` - -## Test Integration - -### Lifecycle Management +The simulated device system is built around a hierarchy of interfaces: -Simulated devices are automatically managed by the test framework: +- **[`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 -1. **BeforeScenario (Order=50):** `ISimulatedEnvironment` is created -2. **BeforeScenario (Order=50):** Simulated devices are registered and started -3. **BeforeScenario (Order=200):** Host application is started with device configuration -4. **AfterScenario:** Devices and host are cleaned up +### Test Environment Setup -### Configuration +The [`SimulatedEnvironment`](./Host/SimulatedEnvironment.cs) class manages device lifecycle: -#### TiVo Device Configuration +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 -```csharp -ISimulatedDeviceBuilder tivoBuilder = new SimulatedTiVoDeviceBuilder(logger); -simulatedEnvironment.RegisterDevice("TiVo", tivoBuilder); -ISimulatedDevice tivoDevice = simulatedEnvironment.StartDevice("TiVo"); +See [`HostSteps.cs`](../../AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs) for test setup implementation. -// Configure host to connect to simulated device -string args = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; -``` +## Test Integration -#### Broadlink Device Configuration +### Lifecycle Management -```csharp -ISimulatedDeviceBuilder broadlinkBuilder = new SimulatedBroadlinkDeviceBuilder(logger); -simulatedEnvironment.RegisterDevice("Broadlink", broadlinkBuilder); -ISimulatedDevice broadlinkDevice = simulatedEnvironment.StartDevice("Broadlink"); +Simulated devices are automatically managed by the test framework via Reqnroll hooks in [`HostSteps.cs`](../../AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs): -// Configure host for discovery on loopback -string args = $"--broadlink:DiscoveryAddress=127.0.0.1 --broadlink:DiscoveryPort={broadlinkDevice.Port}"; -``` +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 -#### TiVo Steps - -```csharp -[Then(@"I should see the TiVo receives a {string} message")] -public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) -{ - ISimulatedDevice? device = Environment.TiVo; - Assert.IsNotNull(device, "TiVo device is not running"); - - bool found = WaitHelpers.ExecuteWithRetries(() => - { - IReadOnlyList messages = device.GetRecordedMessages(); - return messages.Any(m => m.Incoming && - m.Payload.Equals($"IRCODE {expectedCommand}", StringComparison.OrdinalIgnoreCase)); - }, timeoutInSeconds: 5); - - Assert.IsTrue(found, $"Expected TiVo to receive message 'IRCODE {expectedCommand}'"); -} -``` - -#### Broadlink Steps - -```csharp -[Then(@"I should see the Broadlink device recorded at least one outbound packet")] -public void ThenIShouldSeeTheBroadlinkDeviceRecordedAtLeastOneOutboundPacket() -{ - ISimulatedBroadlinkDevice? device = Environment.Broadlink; - Assert.IsNotNull(device, "Broadlink device is not running"); - - bool found = WaitHelpers.ExecuteWithRetries(() => - { - IReadOnlyList packets = device.GetRecordedPackets(); - return packets.Any(p => p.IsInbound && p.RawPayload != null && p.RawPayload.Length > 0); - }, timeoutInSeconds: 10); - - Assert.IsTrue(found, "Expected Broadlink device to record at least one packet with IR data"); -} - -[Then(@"no Broadlink packets should be marked as malformed")] -public void ThenNoBroadlinkPacketsShouldBeMarkedAsMalformed() -{ - ISimulatedBroadlinkDevice? device = Environment.Broadlink; - IReadOnlyList packets = device!.GetRecordedPackets(); - RecordedPacket? malformedPacket = packets.FirstOrDefault(p => p.IsMalformed); - - Assert.IsNull(malformedPacket, $"Found malformed packet: {malformedPacket?.DebugDescription}"); -} -``` +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 -#### TiVo Device Feature +Test scenarios use Gherkin syntax: -```gherkin -Feature: TiVo Device Integration - Scenario: TiVo receives Play command - Given 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 -``` +- **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 -```gherkin -Feature: Broadlink Device Integration - 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 - When I click on the 'Power' button - Then I should see the Broadlink device recorded at least one outbound packet - And the recorded Broadlink packet's raw payload should not be empty - And no Broadlink packets should be marked as malformed -``` +Example test scenario - see [`BroadlinkDevice.feature`](../../AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature) for full implementation. ## Protocol Implementation Details @@ -225,14 +105,18 @@ Feature: Broadlink Device Integration - 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] -``` +**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