diff --git a/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs b/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs
index 6342ae7..a50984f 100644
--- a/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs
+++ b/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs
@@ -38,11 +38,11 @@ public sealed class StringToVisibilityConverter : IValueConverter
throw new NotSupportedException();
}
-/// Sichtbar, wenn der gebundene Wert nicht null ist.
+/// Sichtbar, wenn der gebundene Wert nicht null ist. Mit ConverterParameter="Invert" umgekehrt.
public sealed class NullToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is null ? Visibility.Collapsed : Visibility.Visible;
+ (value is null) == string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase) ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs
index 6776d6f..90493c6 100644
--- a/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs
+++ b/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs
@@ -41,6 +41,54 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
public bool ShowTestEnvironment => UserSettings.Current.TestEnvironment;
+ /// Per MNDP gefundene Router (wie „Neighbors" in WinBox), Mac v1.6.0.
+ public ObservableCollection Neighbors { get; } = [];
+
+ [ObservableProperty]
+ private bool _isSearchingNeighbors;
+
+ [ObservableProperty]
+ private string? _neighborSearchMessage;
+
+ [RelayCommand]
+ private async Task SearchNeighborsAsync()
+ {
+ IsSearchingNeighbors = true;
+ NeighborSearchMessage = null;
+ Neighbors.Clear();
+ try
+ {
+ foreach (var neighbor in await MndpDiscovery.DiscoverAsync(TimeSpan.FromSeconds(4)))
+ {
+ Neighbors.Add(neighbor);
+ }
+
+ NeighborSearchMessage = Neighbors.Count == 0
+ ? "Kein MikroTik-Router gefunden. Ist der PC per Kabel oder WLAN mit dem Router verbunden?"
+ : null;
+ }
+ catch (System.Net.Sockets.SocketException)
+ {
+ NeighborSearchMessage = "Die Router-Suche ist gerade nicht möglich. Läuft auf diesem PC ein anderes Programm, das Port 5678 belegt?";
+ }
+ finally
+ {
+ IsSearchingNeighbors = false;
+ }
+ }
+
+ [RelayCommand]
+ private void UseNeighbor(MndpNeighbor neighbor)
+ {
+ if (neighbor.IPv4Address is { } address)
+ {
+ Host = address;
+ }
+ }
+
+ [RelayCommand]
+ private void CopyMac(MndpNeighbor neighbor) => System.Windows.Clipboard.SetText(neighbor.MacAddress);
+
[RelayCommand]
private async Task ConnectToTestDeviceAsync(DemoDeviceProfile profile)
{
diff --git a/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml
index e58fd8e..7179b01 100644
--- a/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml
+++ b/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml
@@ -14,8 +14,65 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs b/src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs
new file mode 100644
index 0000000..0921883
--- /dev/null
+++ b/src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs
@@ -0,0 +1,58 @@
+using System.Text;
+using MikrotikInstaller.Core.Devices;
+
+namespace MikrotikInstaller.Core.Tests.Devices;
+
+public class MndpDiscoveryTests
+{
+ [Fact]
+ public void Parse_ReadsAnnouncement()
+ {
+ var packet = Packet(
+ (1, [0x48, 0x8F, 0x5A, 0x01, 0x02, 0x03]),
+ (5, Encoding.UTF8.GetBytes("Keller")),
+ (7, Encoding.UTF8.GetBytes("7.15.2 (stable)")),
+ (12, Encoding.UTF8.GetBytes("RB750Gr3")),
+ (10, [0x10, 0x0E, 0x00, 0x00]),
+ (16, Encoding.UTF8.GetBytes("bridge")),
+ (17, [192, 168, 88, 1]));
+
+ var neighbor = MndpDiscovery.Parse(packet);
+
+ Assert.NotNull(neighbor);
+ Assert.Equal("48:8F:5A:01:02:03", neighbor.MacAddress);
+ Assert.Equal("Keller", neighbor.Identity);
+ Assert.Equal("RB750Gr3", neighbor.Board);
+ Assert.Equal("192.168.88.1", neighbor.IPv4Address);
+ Assert.Equal("bridge", neighbor.InterfaceName);
+ Assert.Equal(3600u, neighbor.UptimeSeconds);
+ }
+
+ [Fact]
+ public void Parse_OwnRequestWithoutMac_IsIgnored() =>
+ Assert.Null(MndpDiscovery.Parse(new byte[4]));
+
+ [Fact]
+ public void Parse_TruncatedField_KeepsWhatIsComplete()
+ {
+ var packet = Packet((1, [1, 2, 3, 4, 5, 6])).Concat(new byte[] { 0, 5, 0, 50, 65 }).ToArray();
+
+ var neighbor = MndpDiscovery.Parse(packet);
+
+ Assert.NotNull(neighbor);
+ Assert.Null(neighbor.Identity);
+ Assert.Null(neighbor.IPv4Address);
+ }
+
+ private static byte[] Packet(params (ushort Type, byte[] Value)[] fields)
+ {
+ var bytes = new List { 0, 0, 0, 1 };
+ foreach (var (type, value) in fields)
+ {
+ bytes.AddRange([(byte)(type >> 8), (byte)type, (byte)(value.Length >> 8), (byte)value.Length]);
+ bytes.AddRange(value);
+ }
+
+ return bytes.ToArray();
+ }
+}
diff --git a/src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs b/src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs
new file mode 100644
index 0000000..b8637ee
--- /dev/null
+++ b/src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs
@@ -0,0 +1,92 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace MikrotikInstaller.Core.Devices;
+
+/// Ein Router, der sich per MikroTik Neighbor Discovery (MNDP) meldet — dieselbe Liste wie „Neighbors" in WinBox.
+public sealed record MndpNeighbor(
+ string MacAddress, string? Identity, string? Version, string? Platform, string? Board, string? IPv4Address, string? InterfaceName, uint? UptimeSeconds);
+
+///
+/// MNDP über UDP 5678 (Aufbau wie Wiresharks packet-mndp.c, übernommen aus der Mac-Version v1.6.0):
+/// 4 Byte Kopf, danach TLVs aus Typ (2 Byte, Big Endian), Länge (2 Byte, Big Endian) und Wert.
+/// Router antworten auf eine Broadcast-Anfrage und melden sich außerdem etwa minütlich von selbst.
+/// Findet Router über ihre MAC-Adresse — also auch solche mit kaputter oder unpassender IP-Konfiguration.
+///
+public static class MndpDiscovery
+{
+ public const int Port = 5678;
+
+ /// Sendet eine Anfrage und sammelt lang alle Antworten, eine je MAC-Adresse.
+ public static async Task> DiscoverAsync(TimeSpan duration, CancellationToken cancellationToken = default)
+ {
+ using var udp = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true, ExclusiveAddressUse = false };
+ // Teilt sich den Port mit z. B. einem laufenden WinBox, statt mit „Adresse wird bereits verwendet" zu scheitern.
+ udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
+ udp.Client.Bind(new IPEndPoint(IPAddress.Any, Port));
+
+ await udp.SendAsync(new byte[4], new IPEndPoint(IPAddress.Broadcast, Port), cancellationToken);
+
+ var found = new Dictionary();
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(duration);
+ try
+ {
+ while (true)
+ {
+ var result = await udp.ReceiveAsync(timeout.Token);
+ // Die eigene 4-Byte-Anfrage kommt ebenfalls zurück; ohne MAC-Feld verwirft Parse sie.
+ if (Parse(result.Buffer) is { } neighbor)
+ {
+ found[neighbor.MacAddress] = neighbor;
+ }
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Suchzeit abgelaufen.
+ }
+
+ return found.Values.OrderBy(n => n.Identity ?? n.MacAddress, StringComparer.CurrentCultureIgnoreCase).ToList();
+ }
+
+ public static MndpNeighbor? Parse(ReadOnlySpan packet)
+ {
+ var fields = new Dictionary();
+ var offset = 4;
+ while (offset + 4 <= packet.Length)
+ {
+ var type = BinaryPrimitives.ReadUInt16BigEndian(packet[offset..]);
+ var length = BinaryPrimitives.ReadUInt16BigEndian(packet[(offset + 2)..]);
+ offset += 4;
+ if (offset + length > packet.Length)
+ {
+ break;
+ }
+
+ fields[type] = packet.Slice(offset, length).ToArray();
+ offset += length;
+ }
+
+ if (!fields.TryGetValue(1, out var mac) || mac.Length != 6)
+ {
+ return null;
+ }
+
+ string? Text(ushort type) =>
+ fields.TryGetValue(type, out var value) && value.Length > 0 ? Encoding.UTF8.GetString(value) : null;
+
+ return new MndpNeighbor(
+ MacAddress: string.Join(":", mac.Select(b => b.ToString("X2"))),
+ Identity: Text(5),
+ Version: Text(7),
+ Platform: Text(8),
+ Board: Text(12),
+ IPv4Address: fields.TryGetValue(17, out var ip) && ip.Length == 4 ? new IPAddress(ip).ToString() : null,
+ InterfaceName: Text(16),
+ // Little Endian, anders als der TLV-Kopf (live gegen /system resource geprüft, Mac v1.6.0).
+ UptimeSeconds: fields.TryGetValue(10, out var uptime) && uptime.Length == 4 ? BinaryPrimitives.ReadUInt32LittleEndian(uptime) : null);
+ }
+}