Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/AdaptiveRemote.App/Services/Broadlink/BroadlinkSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,14 @@ public class BroadlinkSettings
/// for devices.
/// </summary>
public double ScanTimeout { get; set; } = 3;

/// <summary>
/// Optional discovery port override for testing. If not set, uses port 80.
/// </summary>
public int? DiscoveryPort { get; set; }

/// <summary>
/// Optional discovery address override for testing. If not set, uses broadcast address.
/// </summary>
public string? DiscoveryAddress { get; set; }
}
5 changes: 5 additions & 0 deletions src/AdaptiveRemote.App/Services/Broadlink/ISocket.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ namespace AdaptiveRemote.Services.Broadlink;
/// </summary>
public interface ISocket : IDisposable
{
/// <summary>
/// Gets the local endpoint of the socket.
/// </summary>
EndPoint? LocalEndPoint { get; }

/// <summary>
/// Sends data to the specified remote host.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/AdaptiveRemote.App/Services/Broadlink/SocketWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ private SocketWrapper(Socket socket)
_socket = socket;
}

EndPoint? ISocket.LocalEndPoint => _socket.LocalEndPoint;

ValueTask<int> ISocket.SendToAsync(ReadOnlyMemory<byte> packet, EndPoint endPoint, CancellationToken cancellationToken)
=> _socket.SendToAsync(packet, endPoint, cancellationToken);
ValueTask<SocketReceiveFromResult> ISocket.ReceiveFromAsync(Memory<byte> buffer, EndPoint remoteEP, CancellationToken cancellationToken)
Expand Down
14 changes: 13 additions & 1 deletion src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ IAsyncEnumerable<ScanResponsePacket> 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);
Expand All @@ -44,6 +49,13 @@ IAsyncEnumerable<ScanResponsePacket> 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);
Expand Down
17 changes: 17 additions & 0 deletions test/AdaptiveRemote.EndToEndTests.Features/BroadlinkDevice.feature
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions test/AdaptiveRemote.EndToEndTests.Steps/BroadlinkSteps.cs
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally there would not be so much logic in the Step implementations themselves. The logic should be in the TestServices assembly, and the step definitions should be about translating and validating steps and parameters. That way if I need to build non-Gherkin tests, all the logic is available.

I'm not sure the best way to handle this. Either the logic could be build directly into the SimulatedBroadlinkDevice itself, or it could be build as extension methods on ISimulatedBroadlinkDevice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved logic to extension methods in ISimulatedBroadlinkDeviceExtensions.cs - HasRecordedInboundPacketWithIrData(), GetRecordedPacketsDebugString(), GetFirstPacketWithIrData(), and GetFirstMalformedPacket(). Step definitions now just call these methods. (7e3b34f)

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");
}
}
29 changes: 16 additions & 13 deletions test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ISimulatedEnvironment>(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<ISimulatedEnvironment>(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)]
Expand All @@ -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 =>
Expand Down
9 changes: 4 additions & 5 deletions test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -23,14 +22,14 @@ public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand)
bool found = WaitHelpers.ExecuteWithRetries(
() =>
{
IReadOnlyList<RecordedMessage> messages = device!.GetRecordedMessages();
IReadOnlyList<RecordedMessage> messages = device.GetRecordedMessages();
return messages.Any(m => m.Incoming && m.Payload.Equals($"IRCODE {expectedCommand}", StringComparison.OrdinalIgnoreCase));
},
timeoutInSeconds: 5);

if (!found)
{
IReadOnlyList<RecordedMessage> messages = device!.GetRecordedMessages();
IReadOnlyList<RecordedMessage> 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}");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink;

namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo;

/// <summary>
Expand All @@ -6,24 +8,12 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo;
public interface ISimulatedEnvironment : IDisposable
{
/// <summary>
/// Registers a device builder with the given name.
/// </summary>
/// <param name="name">The unique name for the device.</param>
/// <param name="builder">The device builder to register.</param>
void RegisterDevice(string name, ISimulatedDeviceBuilder builder);

/// <summary>
/// Starts a registered device and returns the running device instance.
/// Gets the simulated TiVo device, if started.
/// </summary>
/// <param name="name">The name of the device to start.</param>
/// <returns>The running simulated device.</returns>
ISimulatedDevice StartDevice(string name);
ISimulatedTiVoDevice? TiVo { get; }

/// <summary>
/// Attempts to retrieve a running device by name.
/// Gets the simulated Broadlink device, if started.
/// </summary>
/// <param name="name">The name of the device to retrieve.</param>
/// <param name="device">The running device, if found.</param>
/// <returns>True if the device was found; otherwise, false.</returns>
bool TryGetDevice(string name, out ISimulatedDevice? device);
ISimulatedBroadlinkDevice? Broadlink { get; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink;
using AdaptiveRemote.EndtoEndTests.SimulatedTiVo;
using Microsoft.Extensions.Logging;

namespace AdaptiveRemote.EndtoEndTests.Host;

Expand All @@ -7,44 +9,24 @@ namespace AdaptiveRemote.EndtoEndTests.Host;
/// </summary>
public sealed class SimulatedEnvironment : ISimulatedEnvironment
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking this could be implemented like

Suggested change
public sealed class SimulatedEnvironment : ISimulatedEnvironment
public sealed class SimulatedEnvironment : ISimulatedEnvironment
{
public SimulatedEnvironment(SimulatedBroadlinkDeviceBuilder broadlinkBuilder, SimulatedTiVoDeviceBuilder tivoBuilder)
{
// Set the parameters into the properties here
}
}

Then all these objects can be created via Reqnroll's DI system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to accept ILoggerFactory in constructor. The environment now creates the builders internally and starts the devices. (7e3b34f)

{
private readonly Dictionary<string, ISimulatedDeviceBuilder> _builders = new();
private readonly Dictionary<string, ISimulatedDevice> _devices = new();
private ISimulatedTiVoDevice? _tivo;
private ISimulatedBroadlinkDevice? _broadlink;
private bool _disposed;

/// <inheritdoc/>
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;
}

/// <inheritdoc/>
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;

/// <inheritdoc/>
public bool TryGetDevice(string name, out ISimulatedDevice? device)
{
return _devices.TryGetValue(name, out device);
}
public ISimulatedBroadlinkDevice? Broadlink => _broadlink;

/// <inheritdoc/>
public void Dispose()
Expand All @@ -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;
}
}
Loading