Add MusicCast discovery service

This commit is contained in:
2025-05-25 19:30:00 +02:00
parent 9904616751
commit 8bf0c981f9
11 changed files with 242 additions and 2479 deletions

View File

@@ -7,7 +7,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MusicCast.Net.Api.Client\MusicCast.Net.Api.Client.csproj" PrivateAssets="*" />
<PackageReference Include="Microsoft.Kiota.Bundle" Version="1.17.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MusicCast.Net.Api.Client\MusicCast.Net.Api.Client.csproj" PrivateAssets="All" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,53 @@
using Microsoft.Kiota.Abstractions.Authentication;
using Microsoft.Kiota.Http.HttpClientLibrary;
using MusicCast.Net.Api.Client;
using MusicCast.Net.Api.Client.Models;
using MusicCast.Net.Api.Client.YamahaExtendedControl.V1.Main.SetPower;
namespace MusicCast.Net.Client;
public class MusicCastClient : IDisposable
{
private readonly string _deviceAddress;
private readonly HttpClientRequestAdapter _apiClientRequestAdaptor;
private readonly MusicCastApiClient _apiClient;
public MusicCastClient(string deviceAddress)
{
_deviceAddress = deviceAddress;
_apiClientRequestAdaptor = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider())
{
BaseUrl = _deviceAddress
};
_apiClient = new MusicCastApiClient(_apiClientRequestAdaptor);
}
public async Task<bool> PowerOn()
{
var response = await _apiClient.YamahaExtendedControl.V1.Main.SetPower.GetAsync(r =>
r.QueryParameters.Power = GetPowerQueryParameterType.On
);
return IsSuccess(response);
}
public async Task<bool> PowerOff()
{
var response = await _apiClient.YamahaExtendedControl.V1.Main.SetPower.GetAsync(r =>
r.QueryParameters.Power = GetPowerQueryParameterType.Standby
);
return IsSuccess(response);
}
private bool IsSuccess(BaseResponse? response)
{
return response is { ResponseCode: 0 };
}
public void Dispose()
{
_apiClientRequestAdaptor.Dispose();
}
}

View File

@@ -0,0 +1,162 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
namespace MusicCast.Net.Client;
public partial class MusicCastDiscoveryService
{
private static readonly byte[] MulticastPacket = """
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
ST: urn:schemas-upnp-org:device:MediaRenderer:1
MX: 1
"""u8.ToArray();
private static readonly HttpClient _httpClient = new();
private const string LocationUrlGroupName = "url";
[GeneratedRegex(@$"Location:\s(?<{LocationUrlGroupName}>http[s]?:\/\/.+\.xml)", RegexOptions.IgnoreCase)]
private static partial Regex LocationRegex();
/// <summary>
/// Get the ip address associated with the current local network.
/// If multiple networks cards are installed it only returns the first one.
/// </summary>
/// <returns></returns>
public IPAddress? GetLocalSubnetAddress()
{
// Get a list of all network interfaces (usually one per network card, dialup, and VPN connection)
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (NetworkInterface network in networkInterfaces)
{
// Read the IP configuration for each network
IPInterfaceProperties properties = network.GetIPProperties();
if (network.NetworkInterfaceType is NetworkInterfaceType.Ethernet or NetworkInterfaceType.Wireless80211 &&
network.OperationalStatus == OperationalStatus.Up &&
!network.Description.Contains("virtual", StringComparison.OrdinalIgnoreCase) &&
!network.Description.Contains("pseudo", StringComparison.OrdinalIgnoreCase))
{
// Each network interface may have multiple IP addresses
foreach (UnicastIPAddressInformation address in properties.UnicastAddresses)
{
// We're only interested in IPv4 addresses for now
if (address.Address.AddressFamily != AddressFamily.InterNetwork)
continue;
// Ignore loopback addresses (e.g., 127.0.0.1)
if (IPAddress.IsLoopback(address.Address))
continue;
return address.Address;
}
}
}
return null;
}
public Task<HashSet<string>> GetMusicCastDeviceAddresses(IPAddress bindAddress)
=> GetMusicCastDeviceAddresses(bindAddress, TimeSpan.FromSeconds(2));
/// <summary>
/// Scan for MusicCast devices on the network associated with the bind address. Only return devices that support
/// the YamahaExtendedControl v1 api
/// </summary>
/// <param name="bindAddress"></param>
/// <param name="scanDuration">Duration of the scan</param>
/// <returns></returns>
public async Task<HashSet<string>> GetMusicCastDeviceAddresses(IPAddress bindAddress, TimeSpan scanDuration)
{
using var client = new UdpClient(AddressFamily.InterNetwork);
client.Client.Bind(new IPEndPoint(bindAddress, 0));
client.JoinMulticastGroup(IPAddress.Parse("239.255.255.250"));
await client.SendAsync(MulticastPacket, "239.255.255.250", 1900);
await client.SendAsync(MulticastPacket, "239.255.255.250", 1900);
await client.SendAsync(MulticastPacket, "239.255.255.250", 1900);
var musicCastDevices = new HashSet<string>();
// Start scanning
var cancellationToken = new CancellationTokenSource(scanDuration).Token;
try
{
while (!cancellationToken.IsCancellationRequested)
{
var received = await client.ReceiveAsync(cancellationToken);
var message = Encoding.UTF8.GetString(received.Buffer);
// Try to find the location of the DLNA description file
var match = LocationRegex().Match(message);
if (!match.Success || !match.Groups.TryGetValue(LocationUrlGroupName, out var group))
{
continue;
}
var location = group.Value;
var apiUrl = await GetApiUrlFromDlnaDescriptionFile(location, cancellationToken);
if (apiUrl is not null)
{
musicCastDevices.Add(apiUrl);
}
}
}
catch (OperationCanceledException)
{
// Normal flow after scan duration
}
return musicCastDevices;
}
private static async Task<string?> GetApiUrlFromDlnaDescriptionFile(string location, CancellationToken cancellationToken)
{
try
{
var response = await _httpClient.GetAsync(location, cancellationToken);
await using var descriptionFileStream = await response.Content.ReadAsStreamAsync(cancellationToken);
var document = await XDocument.LoadAsync(descriptionFileStream, LoadOptions.None, cancellationToken);
XNamespace yamahaNamespace = "urn:schemas-yamaha-com:device-1-0";
var urlBaseNode = document.Descendants(yamahaNamespace + "X_URLBase").FirstOrDefault();
if (urlBaseNode is null)
{
return null;
}
var yamahaControlUrlNodes = document.Descendants(yamahaNamespace + "X_yxcControlURL");
foreach (var yamahaControlUrlNode in yamahaControlUrlNodes)
{
if (yamahaControlUrlNode is { Value: "/YamahaExtendedControl/v1/" })
{
return urlBaseNode.Value;
}
}
}
catch (Exception)
{
// ignored
}
return null;
}
}