Many cool stuff

This commit is contained in:
Holger Börchers 2025-04-02 15:09:40 +02:00
parent fd054d854b
commit 0e75065ff7
9 changed files with 264 additions and 176 deletions

View File

@ -14,9 +14,9 @@ indent_size = 2
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
tab_width = 4
indent_size = 2
tab_width = 2
max_line_length = 140
# New line preferences
end_of_line = crlf
insert_final_newline = false

View File

@ -5,18 +5,18 @@ namespace SddpViewer;
public partial class App : Application
{
public override void Initialize()
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
AvaloniaXamlLoader.Load(this);
desktop.MainWindow = new MainWindow();
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@ -1,75 +1,106 @@
namespace SddpViewer;
using System.Net;
namespace SddpViewer;
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Rssdp;
public partial class DiscoveredDeviceViewModel : ObservableObject
{
private readonly DiscoveredSsdpDevice _device;
private readonly DiscoveredSsdpDevice _device;
private SsdpDevice? _ssdpDevice;
public DiscoveredDeviceViewModel(DiscoveredSsdpDevice device)
{
_device = device;
ResponseHeader = GetResponseHeader();
}
public DiscoveredDeviceViewModel(DiscoveredSsdpDevice device)
{
_device = device;
ResponseHeader = GetResponseHeader();
DiscoveredAt = DateTime.Now;
IpAddress = EvaluateIpAddress(device);
MacAddress = EvaluateMacAddress();
HostName = EvaluateHostName();
}
private string GetResponseHeader()
{
return string.Join(
"," + Environment.NewLine,
_device.ResponseHeaders.Select(x => $"{{{x.Key} : {string.Join(";", x.Value)}}}")
);
}
public string HostName { get; set; }
public string ResponseHeader { get; }
public string MacAddress { get; set; }
/// <summary>
/// Sets or returns the type of notification, being either a uuid, device type, service type or upnp:rootdevice.
/// </summary>
public string NotificationType => _device.NotificationType;
public string IpAddress { get; set; }
/// <summary>
/// Sets or returns the universal service name (USN) of the device.
/// </summary>
public string Usn => _device.Usn;
public DateTime DiscoveredAt { get; }
/// <summary>
/// Sets or returns a URL pointing to the device description document for this device.
/// </summary>
public Uri DescriptionLocation => _device.DescriptionLocation;
[ObservableProperty]
public partial string ResponseHeader { get; set; }
/// <summary>
/// Sets or returns the length of time this information is valid for (from the <see cref="P:Rssdp.DiscoveredSsdpDevice.AsAt" /> time).
/// </summary>
public TimeSpan CacheLifetime => _device.CacheLifetime;
/// <summary>
/// Sets or returns the type of notification, being either a uuid, device type, service type or upnp:rootdevice.
/// </summary>
public string NotificationType => _device.NotificationType;
/// <summary>
/// Sets or returns the date and time this information was received.
/// </summary>
public DateTimeOffset AsAt => _device.AsAt;
/// <summary>
/// Sets or returns the universal service name (USN) of the device.
/// </summary>
public string Usn => _device.Usn;
[ObservableProperty]
private string _friendlyName = "";
/// <summary>
/// Sets or returns a URL pointing to the device description document for this device.
/// </summary>
public Uri DescriptionLocation => _device.DescriptionLocation;
[ObservableProperty]
private SsdpDeviceIcon? _icon;
/// <summary>
/// Sets or returns the length of time this information is valid for (from the <see cref="P:Rssdp.DiscoveredSsdpDevice.AsAt" /> time).
/// </summary>
public TimeSpan CacheLifetime => _device.CacheLifetime;
[ObservableProperty]
private Uri? _presentationUrl;
/// <summary>
/// Sets or returns the date and time this information was received.
/// </summary>
public DateTimeOffset AsAt => _device.AsAt;
[ObservableProperty]
private string _modelNumber = "";
[ObservableProperty]
public partial string FriendlyName { get; set; } = "";
public async Task GetFurtherInformationAsync()
{
var ssdpDevice = await _device.GetDeviceInfo().ConfigureAwait(false);
FriendlyName = ssdpDevice.FriendlyName;
Icon = ssdpDevice.Icons.MinBy(x => x.Height);
PresentationUrl = ssdpDevice.PresentationUrl;
ModelNumber = ssdpDevice.ModelNumber;
}
[ObservableProperty]
public partial SsdpDeviceIcon? Icon { get; set; }
[ObservableProperty]
public partial Uri? PresentationUrl { get; set; }
[ObservableProperty]
public partial string ModelNumber { get; set; } = "";
[ObservableProperty]
public partial string Version { get; set; } = "";
public async Task GetFurtherInformationAsync()
{
_ssdpDevice = await _device.GetDeviceInfo().ConfigureAwait(false);
FriendlyName = _ssdpDevice.ModelDescription;
Icon = _ssdpDevice.Icons.MinBy(x => x.Height);
PresentationUrl = _ssdpDevice.PresentationUrl;
ModelNumber = _ssdpDevice.ModelNumber;
Version = _ssdpDevice.SerialNumber?.Split(',').Last() ?? new Version().ToString();
HttpClient client = new HttpClient();
var response = await client.GetAsync(_ssdpDevice.ModelUrl).ConfigureAwait(false);
ResponseHeader = await response.Content.ReadAsStringAsync();
}
private string EvaluateHostName() => Dns.GetHostEntry(IpAddress).HostName;
private string GetResponseHeader() =>
string.Join("," + Environment.NewLine, _device.ResponseHeaders.Select(x => $"{{{x.Key} : {string.Join(";", x.Value)}}}"));
private string EvaluateIpAddress(DiscoveredSsdpDevice device) => device.DescriptionLocation.Host;
private string EvaluateMacAddress()
{
var lookupResult = ArpLookup.Arp.Lookup(IPAddress.Parse(IpAddress));
return lookupResult is null ? "Unknown" : string.Join(":", lookupResult.GetAddressBytes().Select(b => $"{b:x2}"));
}
public override bool Equals(object? obj)
{
return obj is DiscoveredDeviceViewModel viewModel && Equals(viewModel.Usn, Usn);
}
}

View File

@ -14,7 +14,7 @@
<Window.DataContext>
<sddpViewer:MainWindowViewModel />
</Window.DataContext>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto, *">
<Grid ColumnDefinitions="*,*" RowDefinitions="180, *">
<StackPanel Margin="10" Spacing="5">
<TextBlock Text="Device IP Address" />
<ComboBox
@ -24,25 +24,36 @@
SelectedItem="{Binding SelectedNetworkAdapter}" />
<TextBlock Text="Notification filter" />
<TextBox Text="{Binding NotificationFilter}" />
<Button Command="{Binding SearchDevicesNowCommand}" Content="Search now" />
<StackPanel Orientation="Horizontal" Spacing="5">
<Button Command="{Binding StartListeningCommand}" Content="Listen for devices" />
<Button Command="{Binding SearchDevicesNowCommand}" Content="Search now" />
<Button Command="{Binding ResearchCommand}" Content="Search more" />
</StackPanel>
</StackPanel>
<Grid Grid.Row="0" Grid.Column="1">
<TextBox
IsReadOnly="True"
Text="{ReflectionBinding SelectedItem.ResponseHeader,
ElementName=DevicesDataGrid}"
TextWrapping="Wrap" />
</Grid>
<DataGrid
Grid.Row="1" Grid.ColumnSpan="2"
x:Name="DevicesDataGrid"
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
AutoGenerateColumns="False"
IsReadOnly="True"
ItemsSource="{Binding SddpDevices}">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Usn}" Header="Usn" />
<DataGridTextColumn Binding="{Binding FriendlyName}" Header="Name" />
<DataGridTextColumn Binding="{Binding Usn}" Header="Usn" />
<DataGridTextColumn Binding="{Binding IpAddress}" Header="Ip address" />
<DataGridTextColumn Binding="{Binding HostName}" Header="Hostname" />
<DataGridTextColumn Binding="{Binding MacAddress}" Header="Mac address" />
<DataGridTextColumn Binding="{Binding Version}" Header="Version" />
<DataGridTextColumn Binding="{Binding DiscoveredAt}" Header="Discovered at" />
</DataGrid.Columns>
<DataGrid.RowDetailsTemplate>
<DataTemplate DataType="sddpViewer:DiscoveredDeviceViewModel">
<StackPanel>
<TextBlock Text="{Binding ResponseHeader}" />
</StackPanel>
</DataTemplate>
</DataGrid.RowDetailsTemplate>
</DataGrid>
</Grid>
</Window>

View File

@ -4,8 +4,8 @@ namespace SddpViewer;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
public MainWindow()
{
InitializeComponent();
}
}

View File

@ -1,85 +1,129 @@
namespace SddpViewer;
using System.Collections.Specialized;
using Avalonia.Threading;
namespace SddpViewer;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Rssdp;
public sealed partial class MainWindowViewModel : ObservableObject, IDisposable
{
public MainWindowViewModel()
{
NetworkAdapters = NetworkAdapter.GetAvailableNetworkAdapter().ToArray();
SelectedNetworkAdapter = NetworkAdapters.FirstOrDefault();
}
public MainWindowViewModel()
{
NetworkAdapters = NetworkAdapter.GetAvailableNetworkAdapter().ToArray();
SelectedNetworkAdapter = NetworkAdapters.FirstOrDefault();
SddpDevices = new ObservableCollection<DiscoveredDeviceViewModel>();
SddpDevices.CollectionChanged += SddpDevices_OnCollectionChanged;
}
private void LocatorOnDeviceUnavailable(object? sender, DeviceUnavailableEventArgs e)
private static async void SddpDevices_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
try
{
var existingDevice = SddpDevices.FirstOrDefault(
x => string.Equals(x.Usn, e.DiscoveredDevice.Usn, StringComparison.Ordinal)
);
if (existingDevice is null)
return;
Dispatcher.UIThread.Invoke(() => SddpDevices.Remove(existingDevice));
}
private async void LocatorOnDeviceAvailable(object? sender, DeviceAvailableEventArgs e)
{
if (!e.IsNewlyDiscovered)
foreach (object eNewItem in e.NewItems ?? Array.Empty<object>())
{
if (eNewItem is DiscoveredDeviceViewModel discoveredDeviceViewModel)
{
return;
await discoveredDeviceViewModel.GetFurtherInformationAsync();
}
}
}
catch (Exception ex)
{
throw; // TODO handle exception
}
}
var discoveredDeviceViewModel = new DiscoveredDeviceViewModel(e.DiscoveredDevice);
private void LocatorOnDeviceUnavailable(object? sender, DeviceUnavailableEventArgs e)
{
var existingDevice = SddpDevices.FirstOrDefault(x => string.Equals(x.Usn, e.DiscoveredDevice.Usn, StringComparison.Ordinal));
if (existingDevice is null)
return;
Dispatcher.UIThread.Invoke(() => SddpDevices.Remove(existingDevice));
}
private void LocatorOnDeviceAvailable(object? sender, DeviceAvailableEventArgs e)
{
if (!e.IsNewlyDiscovered)
{
return;
}
var discoveredDeviceViewModel = new DiscoveredDeviceViewModel(e.DiscoveredDevice);
if (!SddpDevices.Contains(discoveredDeviceViewModel))
Dispatcher.UIThread.Invoke(() => SddpDevices.Add(discoveredDeviceViewModel));
}
[ObservableProperty]
private IReadOnlyList<NetworkAdapter> _networkAdapters;
[ObservableProperty]
private NetworkAdapter? _selectedNetworkAdapter;
[ObservableProperty]
private string _deviceIpAddress = "192.168.42.193";
[ObservableProperty]
private string _notificationFilter = "upnp:rootdevice";
private SsdpDeviceLocator? _locator;
public ObservableCollection<DiscoveredDeviceViewModel> SddpDevices { get; }
[RelayCommand]
private async Task SearchDevicesNowAsync()
{
SddpDevices.Clear();
if (_locator is not null)
{
_locator.StopListeningForNotifications();
_locator.DeviceAvailable -= LocatorOnDeviceAvailable;
_locator.DeviceUnavailable -= LocatorOnDeviceUnavailable;
}
await StartListening(async locator =>
{
foreach (var discoveredSsdpDevice in await locator.SearchAsync())
{
var discoveredDeviceViewModel = new DiscoveredDeviceViewModel(discoveredSsdpDevice);
Dispatcher.UIThread.Invoke(() => SddpDevices.Add(discoveredDeviceViewModel));
await discoveredDeviceViewModel.GetFurtherInformationAsync();
}
}
});
}
[ObservableProperty]
private IReadOnlyList<NetworkAdapter> _networkAdapters;
[ObservableProperty]
private NetworkAdapter? _selectedNetworkAdapter;
[ObservableProperty]
private string _deviceIpAddress = "192.168.42.193";
[ObservableProperty]
private string _notificationFilter = "upnp:rootdevice";
private SsdpDeviceLocator? _locator;
public ObservableCollection<DiscoveredDeviceViewModel> SddpDevices { get; } = new();
[RelayCommand]
private async Task SearchDevicesNowAsync()
[RelayCommand]
private async Task ResearchAsync()
{
if (_locator is not null)
{
SddpDevices.Clear();
if (_locator is not null)
{
_locator.StopListeningForNotifications();
_locator.DeviceAvailable -= LocatorOnDeviceAvailable;
_locator.DeviceUnavailable -= LocatorOnDeviceUnavailable;
}
_locator = new SsdpDeviceLocator(SelectedNetworkAdapter?.IpAddress.ToString());
if (!string.IsNullOrWhiteSpace(NotificationFilter))
{
_locator.NotificationFilter = NotificationFilter;
}
_locator.DeviceAvailable += LocatorOnDeviceAvailable;
_locator.DeviceUnavailable += LocatorOnDeviceUnavailable;
_locator.StartListeningForNotifications();
await _locator.SearchAsync();
await _locator.SearchAsync();
}
}
public void Dispose()
[RelayCommand]
private async Task StartListening(Func<SsdpDeviceLocator, Task>? action = null)
{
_locator = new SsdpDeviceLocator(SelectedNetworkAdapter?.IpAddress.ToString());
if (!string.IsNullOrWhiteSpace(NotificationFilter))
{
_locator?.Dispose();
_locator.NotificationFilter = NotificationFilter;
}
if (action is not null)
{
await action.Invoke(_locator);
}
_locator.DeviceAvailable += LocatorOnDeviceAvailable;
_locator.DeviceUnavailable += LocatorOnDeviceUnavailable;
_locator.StartListeningForNotifications();
}
public void Dispose()
{
_locator?.Dispose();
}
}

View File

@ -6,24 +6,22 @@ namespace SddpViewer;
public record NetworkAdapter(string Name, IPAddress IpAddress)
{
public string DisplayName => $"{Name} - {IpAddress}";
public string DisplayName => $"{Name} - {IpAddress}";
public static IEnumerable<NetworkAdapter> GetAvailableNetworkAdapter()
public static IEnumerable<NetworkAdapter> GetAvailableNetworkAdapter()
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
continue;
var physicalAddress = nic.GetIPProperties()
.UnicastAddresses.FirstOrDefault(
x => x.Address.AddressFamily == AddressFamily.InterNetwork
);
if (physicalAddress is not null)
{
yield return new NetworkAdapter(nic.Name, physicalAddress.Address);
}
}
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
continue;
var physicalAddress = nic.GetIPProperties()
.UnicastAddresses.FirstOrDefault(x => x.Address.AddressFamily == AddressFamily.InterNetwork);
if (physicalAddress is not null)
{
yield return new NetworkAdapter(nic.Name, physicalAddress.Address);
}
}
}
}

View File

@ -2,14 +2,12 @@
internal static class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>().UsePlatformDetect().WithInterFont().LogToTrace();
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>().UsePlatformDetect().WithInterFont().LogToTrace();
}

View File

@ -7,17 +7,23 @@
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.5" />
<PackageReference Include="ArpLookup" Version="2.0.3" />
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="rssdp" Version="4.0.4" />
</ItemGroup>
</Project>