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