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 #### #### Core EditorConfig Options ####
# Indentation and spacing # Indentation and spacing
indent_size = 4 indent_size = 2
tab_width = 4 tab_width = 2
max_line_length = 140
# New line preferences # New line preferences
end_of_line = crlf end_of_line = crlf
insert_final_newline = false insert_final_newline = false

View File

@ -5,18 +5,18 @@ namespace SddpViewer;
public partial class App : Application 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() base.OnFrameworkInitializationCompleted();
{ }
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
} }

View File

@ -1,75 +1,106 @@
namespace SddpViewer; using System.Net;
namespace SddpViewer;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Rssdp; using Rssdp;
public partial class DiscoveredDeviceViewModel : ObservableObject public partial class DiscoveredDeviceViewModel : ObservableObject
{ {
private readonly DiscoveredSsdpDevice _device; private readonly DiscoveredSsdpDevice _device;
private SsdpDevice? _ssdpDevice;
public DiscoveredDeviceViewModel(DiscoveredSsdpDevice device) public DiscoveredDeviceViewModel(DiscoveredSsdpDevice device)
{ {
_device = device; _device = device;
ResponseHeader = GetResponseHeader(); ResponseHeader = GetResponseHeader();
} DiscoveredAt = DateTime.Now;
IpAddress = EvaluateIpAddress(device);
MacAddress = EvaluateMacAddress();
HostName = EvaluateHostName();
}
private string GetResponseHeader() public string HostName { get; set; }
{
return string.Join(
"," + Environment.NewLine,
_device.ResponseHeaders.Select(x => $"{{{x.Key} : {string.Join(";", x.Value)}}}")
);
}
public string ResponseHeader { get; } public string MacAddress { get; set; }
/// <summary> public string IpAddress { get; set; }
/// 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> public DateTime DiscoveredAt { get; }
/// Sets or returns the universal service name (USN) of the device.
/// </summary>
public string Usn => _device.Usn;
/// <summary> [ObservableProperty]
/// Sets or returns a URL pointing to the device description document for this device. public partial string ResponseHeader { get; set; }
/// </summary>
public Uri DescriptionLocation => _device.DescriptionLocation;
/// <summary> /// <summary>
/// Sets or returns the length of time this information is valid for (from the <see cref="P:Rssdp.DiscoveredSsdpDevice.AsAt" /> time). /// Sets or returns the type of notification, being either a uuid, device type, service type or upnp:rootdevice.
/// </summary> /// </summary>
public TimeSpan CacheLifetime => _device.CacheLifetime; public string NotificationType => _device.NotificationType;
/// <summary> /// <summary>
/// Sets or returns the date and time this information was received. /// Sets or returns the universal service name (USN) of the device.
/// </summary> /// </summary>
public DateTimeOffset AsAt => _device.AsAt; public string Usn => _device.Usn;
[ObservableProperty] /// <summary>
private string _friendlyName = ""; /// Sets or returns a URL pointing to the device description document for this device.
/// </summary>
public Uri DescriptionLocation => _device.DescriptionLocation;
[ObservableProperty] /// <summary>
private SsdpDeviceIcon? _icon; /// 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] /// <summary>
private Uri? _presentationUrl; /// Sets or returns the date and time this information was received.
/// </summary>
public DateTimeOffset AsAt => _device.AsAt;
[ObservableProperty] [ObservableProperty]
private string _modelNumber = ""; public partial string FriendlyName { get; set; } = "";
public async Task GetFurtherInformationAsync() [ObservableProperty]
{ public partial SsdpDeviceIcon? Icon { get; set; }
var ssdpDevice = await _device.GetDeviceInfo().ConfigureAwait(false);
FriendlyName = ssdpDevice.FriendlyName; [ObservableProperty]
Icon = ssdpDevice.Icons.MinBy(x => x.Height); public partial Uri? PresentationUrl { get; set; }
PresentationUrl = ssdpDevice.PresentationUrl;
ModelNumber = ssdpDevice.ModelNumber; [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> <Window.DataContext>
<sddpViewer:MainWindowViewModel /> <sddpViewer:MainWindowViewModel />
</Window.DataContext> </Window.DataContext>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto, *"> <Grid ColumnDefinitions="*,*" RowDefinitions="180, *">
<StackPanel Margin="10" Spacing="5"> <StackPanel Margin="10" Spacing="5">
<TextBlock Text="Device IP Address" /> <TextBlock Text="Device IP Address" />
<ComboBox <ComboBox
@ -24,25 +24,36 @@
SelectedItem="{Binding SelectedNetworkAdapter}" /> SelectedItem="{Binding SelectedNetworkAdapter}" />
<TextBlock Text="Notification filter" /> <TextBlock Text="Notification filter" />
<TextBox Text="{Binding NotificationFilter}" /> <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> </StackPanel>
<Grid Grid.Row="0" Grid.Column="1">
<TextBox
IsReadOnly="True"
Text="{ReflectionBinding SelectedItem.ResponseHeader,
ElementName=DevicesDataGrid}"
TextWrapping="Wrap" />
</Grid>
<DataGrid <DataGrid
Grid.Row="1" Grid.ColumnSpan="2" x:Name="DevicesDataGrid"
Grid.Row="1"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="2"
AutoGenerateColumns="False" AutoGenerateColumns="False"
IsReadOnly="True" IsReadOnly="True"
ItemsSource="{Binding SddpDevices}"> ItemsSource="{Binding SddpDevices}">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Usn}" Header="Usn" />
<DataGridTextColumn Binding="{Binding FriendlyName}" Header="Name" /> <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.Columns>
<DataGrid.RowDetailsTemplate>
<DataTemplate DataType="sddpViewer:DiscoveredDeviceViewModel">
<StackPanel>
<TextBlock Text="{Binding ResponseHeader}" />
</StackPanel>
</DataTemplate>
</DataGrid.RowDetailsTemplate>
</DataGrid> </DataGrid>
</Grid> </Grid>
</Window> </Window>

View File

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

View File

@ -1,85 +1,129 @@
namespace SddpViewer; using System.Collections.Specialized;
using Avalonia.Threading; namespace SddpViewer;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Rssdp; using Rssdp;
public sealed partial class MainWindowViewModel : ObservableObject, IDisposable public sealed partial class MainWindowViewModel : ObservableObject, IDisposable
{ {
public MainWindowViewModel() public MainWindowViewModel()
{ {
NetworkAdapters = NetworkAdapter.GetAvailableNetworkAdapter().ToArray(); NetworkAdapters = NetworkAdapter.GetAvailableNetworkAdapter().ToArray();
SelectedNetworkAdapter = NetworkAdapters.FirstOrDefault(); 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( foreach (object eNewItem in e.NewItems ?? Array.Empty<object>())
x => string.Equals(x.Usn, e.DiscoveredDevice.Usn, StringComparison.Ordinal) {
); if (eNewItem is DiscoveredDeviceViewModel discoveredDeviceViewModel)
if (existingDevice is null)
return;
Dispatcher.UIThread.Invoke(() => SddpDevices.Remove(existingDevice));
}
private async void LocatorOnDeviceAvailable(object? sender, DeviceAvailableEventArgs e)
{
if (!e.IsNewlyDiscovered)
{ {
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)); Dispatcher.UIThread.Invoke(() => SddpDevices.Add(discoveredDeviceViewModel));
await discoveredDeviceViewModel.GetFurtherInformationAsync(); }
} });
}
[ObservableProperty] [RelayCommand]
private IReadOnlyList<NetworkAdapter> _networkAdapters; private async Task ResearchAsync()
{
[ObservableProperty] if (_locator is not null)
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()
{ {
SddpDevices.Clear(); await _locator.SearchAsync();
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();
} }
}
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 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.OperationalStatus != OperationalStatus.Up) if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
continue; continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) var physicalAddress = nic.GetIPProperties()
continue; .UnicastAddresses.FirstOrDefault(x => x.Address.AddressFamily == AddressFamily.InterNetwork);
var physicalAddress = nic.GetIPProperties() if (physicalAddress is not null)
.UnicastAddresses.FirstOrDefault( {
x => x.Address.AddressFamily == AddressFamily.InterNetwork yield return new NetworkAdapter(nic.Name, physicalAddress.Address);
); }
if (physicalAddress is not null)
{
yield return new NetworkAdapter(nic.Name, physicalAddress.Address);
}
}
} }
}
} }

View File

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

View File

@ -7,17 +7,23 @@
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.5" /> <PackageReference Include="ArpLookup" Version="2.0.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" /> <PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" /> <PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.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.--> <!--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 Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" /> <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" /> <PackageReference Include="rssdp" Version="4.0.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>