From a4b81bb6e9c695ce188d9cdff1e57e1711c966d1 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 31 May 2026 22:33:59 +0200 Subject: [PATCH 01/16] Feature: WiFi Channel Width + LiveChart2 migration --- .../LvlChartsHeaderConverter.cs | 24 -- .../NETworkManager.Converters.csproj | 2 - .../WiFiDBMReverseConverter.cs | 18 - .../Resources/Strings.Designer.cs | 11 +- .../Resources/Strings.resx | 3 + Source/NETworkManager.Models/Network/WiFi.cs | 53 ++- .../Network/WiFiNetworkInfo.cs | 5 + .../NETworkManager.Models/Network/WlanApi.cs | 404 ++++++++++++++++++ .../LiveChartsWiFiChannelTooltip.xaml | 37 ++ .../LiveChartsWiFiChannelTooltip.xaml.cs | 95 ++++ .../Controls/LvlChartsWiFiChannelTooltip.xaml | 57 --- .../LvlChartsWiFiChannelTooltip.xaml.cs | 35 -- .../Controls/WiFiChannelPoint.cs | 21 + Source/NETworkManager/NETworkManager.csproj | 1 - .../ViewModels/WiFiViewModel.cs | 404 +++++++++++++++--- Source/NETworkManager/Views/WiFiView.xaml | 232 +++++----- Source/NETworkManager/Views/WiFiView.xaml.cs | 24 ++ 17 files changed, 1097 insertions(+), 329 deletions(-) delete mode 100644 Source/NETworkManager.Converters/LvlChartsHeaderConverter.cs delete mode 100644 Source/NETworkManager.Converters/WiFiDBMReverseConverter.cs create mode 100644 Source/NETworkManager.Models/Network/WlanApi.cs create mode 100644 Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml create mode 100644 Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs delete mode 100644 Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml delete mode 100644 Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml.cs create mode 100644 Source/NETworkManager/Controls/WiFiChannelPoint.cs diff --git a/Source/NETworkManager.Converters/LvlChartsHeaderConverter.cs b/Source/NETworkManager.Converters/LvlChartsHeaderConverter.cs deleted file mode 100644 index d89474cab7..0000000000 --- a/Source/NETworkManager.Converters/LvlChartsHeaderConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using LiveCharts.Wpf; - -namespace NETworkManager.Converters; - -public sealed class LvlChartsHeaderConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not TooltipData info) - return "-/-"; - - var index = info.SharedValue ?? -1; - - return Math.Abs(index - -1) < 0 ? "-/-" : info.XFormatter.Invoke(index); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/NETworkManager.Converters.csproj b/Source/NETworkManager.Converters/NETworkManager.Converters.csproj index 0457284fc5..82df0afa01 100644 --- a/Source/NETworkManager.Converters/NETworkManager.Converters.csproj +++ b/Source/NETworkManager.Converters/NETworkManager.Converters.csproj @@ -27,8 +27,6 @@ - - diff --git a/Source/NETworkManager.Converters/WiFiDBMReverseConverter.cs b/Source/NETworkManager.Converters/WiFiDBMReverseConverter.cs deleted file mode 100644 index 7160f17813..0000000000 --- a/Source/NETworkManager.Converters/WiFiDBMReverseConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace NETworkManager.Converters; - -public sealed class WiFiDBMReverseConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return !(value is double) ? "-/-" : $"-{100 - (double)value} dBm"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 08463b220d..c03df53bca 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1673,7 +1673,16 @@ public static string Channels { return ResourceManager.GetString("Channels", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Channel width. + /// + public static string ChannelWidth { + get { + return ResourceManager.GetString("ChannelWidth", resourceCulture); + } + } + /// /// Looks up a localized string similar to Chassis Id. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 886495042e..7d10eb50e0 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2274,6 +2274,9 @@ $$hostname$$ --> Hostname Channel + + Channel width + 2.4 GHz diff --git a/Source/NETworkManager.Models/Network/WiFi.cs b/Source/NETworkManager.Models/Network/WiFi.cs index 0066ee502a..94fe53bb61 100644 --- a/Source/NETworkManager.Models/Network/WiFi.cs +++ b/Source/NETworkManager.Models/Network/WiFi.cs @@ -1,11 +1,13 @@ using log4net; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Windows.Devices.WiFi; using Windows.Networking.Connectivity; using Windows.Security.Credentials; +using Microsoft.CodeAnalysis.Emit; using NETworkManager.Models.Lookup; namespace NETworkManager.Models.Network; @@ -68,18 +70,25 @@ public static async Task GetNetworksAsync(WiFiAdapter adapt // Try to get the current connected Wi-Fi network of this network adapter var (_, bssid) = TryGetConnectedNetworkFromWiFiAdapter(adapter.NetworkAdapter.NetworkAdapterId.ToString()); + // The WinRT API does not expose the channel bandwidth, so it is read from the native + // BSS list (wlanapi.dll) and matched by BSSID below. Failures yield an empty map and we + // fall back to a heuristic per network. + var channelWidths = WlanApi.GetBssChannelWidths(adapter.NetworkAdapter.NetworkAdapterId); + var wifiNetworkInfos = new List(); foreach (var availableNetwork in adapter.NetworkReport.AvailableNetworks) { var channelFrequencyInGigahertz = ConvertChannelFrequencyToGigahertz(availableNetwork.ChannelCenterFrequencyInKilohertz); + var radio = GetWiFiRadioFromChannelFrequency(channelFrequencyInGigahertz); var wifiNetworkInfo = new WiFiNetworkInfo { AvailableNetwork = availableNetwork, - Radio = GetWiFiRadioFromChannelFrequency(channelFrequencyInGigahertz), + Radio = radio, ChannelCenterFrequencyInGigahertz = channelFrequencyInGigahertz, Channel = GetChannelFromChannelFrequency(channelFrequencyInGigahertz), + ChannelBandwidth = GetChannelBandwidth(channelWidths, availableNetwork.Bssid, radio, availableNetwork.PhyKind), IsHidden = string.IsNullOrEmpty(availableNetwork.Ssid), IsConnected = availableNetwork.Bssid.Equals(bssid, StringComparison.OrdinalIgnoreCase), NetworkAuthenticationType = GetHumanReadableNetworkAuthenticationType(availableNetwork.SecuritySettings.NetworkAuthenticationType), @@ -258,8 +267,8 @@ public static async Task IsWpsAvailable(WiFiAdapter adapter, WiFiAvailable /// Get the Wi-Fi channel from channel frequency. /// /// Input like 2.422 or 5.240. - /// WiFi channel like 3 or 48. - public static int GetChannelFromChannelFrequency(double gigahertz) + /// Wi-Fi channel like 3 or 48. + private static int GetChannelFromChannelFrequency(double gigahertz) { return gigahertz switch { @@ -313,7 +322,7 @@ public static int GetChannelFromChannelFrequency(double gigahertz) /// /// Frequency in kilohertz like 2422000 or 5240000. /// Frequency in gigahertz like 2.422 or 5.240. - public static double ConvertChannelFrequencyToGigahertz(int kilohertz) + private static double ConvertChannelFrequencyToGigahertz(int kilohertz) { return Convert.ToDouble(kilohertz) / 1000 / 1000; } @@ -323,7 +332,7 @@ public static double ConvertChannelFrequencyToGigahertz(int kilohertz) /// /// Frequency in gigahertz like 2.412 or 5.180. /// Radio like 2.4 GHz, 5 GHz, etc. as . - public static WiFiRadio GetWiFiRadioFromChannelFrequency(double gigahertz) + private static WiFiRadio GetWiFiRadioFromChannelFrequency(double gigahertz) { return gigahertz switch { @@ -334,6 +343,40 @@ public static WiFiRadio GetWiFiRadioFromChannelFrequency(double gigahertz) }; } + /// + /// Determines the channel bandwidth (MHz) for a network. Prefers the value parsed from the + /// native BSS list (); if unavailable, falls back to a heuristic based + /// on the radio band and PHY kind, and finally to 20 MHz. + /// + private static int GetChannelBandwidth(IReadOnlyDictionary channelWidths, string networkBssid, + WiFiRadio radio, WiFiPhyKind phyKind) + { + if (!string.IsNullOrEmpty(networkBssid) && channelWidths.TryGetValue(networkBssid, out var width) && width > 0) + return width; + + return GetHeuristicChannelBandwidth(radio, phyKind); + } + + /// + /// Estimates the channel bandwidth (MHz) from the radio band and PHY kind when the exact + /// value could not be read from the BSS list. + /// + private static int GetHeuristicChannelBandwidth(WiFiRadio radio, WiFiPhyKind phyKind) + { + return radio switch + { + WiFiRadio.GHz2dot4 => phyKind == WiFiPhyKind.HT ? 40 : 20, + WiFiRadio.GHz5 => phyKind switch + { + WiFiPhyKind.Vht or WiFiPhyKind.HE or WiFiPhyKind.Eht => 80, + WiFiPhyKind.HT => 40, + _ => 20 + }, + WiFiRadio.GHz6 => phyKind is WiFiPhyKind.HE or WiFiPhyKind.Eht ? 160 : 20, + _ => 20 + }; + } + /// /// Get the human-readable network authentication type. /// diff --git a/Source/NETworkManager.Models/Network/WiFiNetworkInfo.cs b/Source/NETworkManager.Models/Network/WiFiNetworkInfo.cs index 9f49908d03..e4bd401946 100644 --- a/Source/NETworkManager.Models/Network/WiFiNetworkInfo.cs +++ b/Source/NETworkManager.Models/Network/WiFiNetworkInfo.cs @@ -37,6 +37,11 @@ public WiFiNetworkInfo() /// public int Channel { get; set; } + /// + /// The channel bandwidth in MHz (e.g. 20, 40, 80, 160). A value of 0 means unknown. + /// + public int ChannelBandwidth { get; set; } + /// /// Indicates if the Wi-Fi network Ssid is hidden. /// diff --git a/Source/NETworkManager.Models/Network/WlanApi.cs b/Source/NETworkManager.Models/Network/WlanApi.cs new file mode 100644 index 0000000000..6979946b30 --- /dev/null +++ b/Source/NETworkManager.Models/Network/WlanApi.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using log4net; + +namespace NETworkManager.Models.Network; + +/// +/// Native Wi-Fi (wlanapi.dll) interop used to determine the channel bandwidth +/// (20 / 40 / 80 / 160 MHz) of nearby access points. +/// +/// The Windows Runtime Wi-Fi API () does +/// not expose the channel width. The native BSS list (WlanGetNetworkBssList) however +/// returns the raw 802.11 information elements (IEs) of each beacon / probe response. The width +/// is parsed from the HT-Operation (802.11n), VHT-Operation (802.11ac) and HE-Operation +/// (802.11ax / 6 GHz) elements. +/// +/// All calls are wrapped in try/catch; on any failure an empty result is returned so the caller +/// can fall back to a heuristic (see ). +/// +public static class WlanApi +{ + private static readonly ILog Log = LogManager.GetLogger(typeof(WlanApi)); + + private const uint WlanApiVersion2 = 0x00000002; + private const uint ErrorSuccess = 0; + + // 802.11 information element identifiers. + private const byte EidHtOperation = 61; + private const byte EidVhtOperation = 192; + private const byte EidExtension = 255; + private const byte ExtEidHeOperation = 36; + + private enum DOT11_BSS_TYPE + { + // ReSharper disable UnusedMember.Local + Infrastructure = 1, + Independent = 2, + Any = 3 + // ReSharper restore UnusedMember.Local + } + + [DllImport("wlanapi.dll")] + private static extern uint WlanOpenHandle(uint dwClientVersion, IntPtr pReserved, + out uint pdwNegotiatedVersion, out IntPtr phClientHandle); + + [DllImport("wlanapi.dll")] + private static extern uint WlanCloseHandle(IntPtr hClientHandle, IntPtr pReserved); + + [DllImport("wlanapi.dll")] + private static extern void WlanFreeMemory(IntPtr pMemory); + + [DllImport("wlanapi.dll")] + private static extern uint WlanGetNetworkBssList(IntPtr hClientHandle, ref Guid pInterfaceGuid, + IntPtr pDot11Ssid, DOT11_BSS_TYPE dot11BssType, [MarshalAs(UnmanagedType.Bool)] bool bSecurityEnabled, + IntPtr pReserved, out IntPtr ppWlanBssList); + + [DllImport("wlanapi.dll")] + private static extern uint WlanEnumInterfaces(IntPtr hClientHandle, IntPtr pReserved, + out IntPtr ppInterfaceList); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WLAN_INTERFACE_INFO + { + public Guid InterfaceGuid; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strInterfaceDescription; + + public uint isState; + } + + [StructLayout(LayoutKind.Sequential)] + private struct DOT11_SSID + { + public uint uSSIDLength; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + public byte[] ucSSID; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WLAN_RATE_SET + { + public uint uRateSetLength; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 126)] + public ushort[] usRateSet; + } + + /// + /// Mirror of the native WLAN_BSS_ENTRY structure. Only , + /// and are used; the remaining fields are + /// required so the layout (and therefore ) matches the + /// native struct exactly. + /// + [StructLayout(LayoutKind.Sequential)] + private struct WLAN_BSS_ENTRY + { + public DOT11_SSID dot11Ssid; + public uint uPhyId; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)] + public byte[] dot11Bssid; + + public uint dot11BssType; + public uint dot11BssPhyType; + public int lRssi; + public uint uLinkQuality; + + // Native type is BOOLEAN (1 byte), not BOOL (4 bytes). + [MarshalAs(UnmanagedType.U1)] + public bool bInRegDomain; + + public ushort usBeaconPeriod; + public ulong ullTimestamp; + public ulong ullHostTimestamp; + public ushort usCapabilityInformation; + public uint ulChCenterFrequency; + public WLAN_RATE_SET wlanRateSet; + public uint ulIeOffset; + public uint ulIeSize; + } + + /// + /// Returns a map of BSSID (lowercase, colon-separated, e.g. aa:bb:cc:dd:ee:ff) to the + /// channel bandwidth in MHz for all access points visible to the given Wi-Fi interface. + /// BSSIDs whose width could not be parsed are omitted from the result. + /// + /// GUID of the Wi-Fi interface (NetworkAdapter.NetworkAdapterId). + /// A (possibly empty) map of BSSID to channel bandwidth in MHz. + public static Dictionary GetBssChannelWidths(Guid interfaceGuid) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var clientHandle = IntPtr.Zero; + + try + { + var ret = WlanOpenHandle(WlanApiVersion2, IntPtr.Zero, out _, out clientHandle); + + if (ret != ErrorSuccess) + { + Log.Warn($"WlanOpenHandle failed with error code {ret}."); + return result; + } + + // The WinRT NetworkAdapterId normally matches the native WLAN interface GUID. If it does + // not (driver/OS specifics), fall back to enumerating all WLAN interfaces and merging + // their BSS lists (matching is done by BSSID, so mixing interfaces is harmless). + if (!TryQueryBssList(clientHandle, interfaceGuid, result)) + foreach (var guid in EnumerateInterfaces(clientHandle)) + TryQueryBssList(clientHandle, guid, result); + } + catch (Exception ex) + { + Log.Error("Error while reading the Wi-Fi BSS list via wlanapi.dll.", ex); + } + finally + { + if (clientHandle != IntPtr.Zero) + WlanCloseHandle(clientHandle, IntPtr.Zero); + } + + return result; + } + + /// + /// Queries the BSS list for a single WLAN interface and adds the parsed channel widths to + /// . Returns if the query succeeded. + /// + private static bool TryQueryBssList(IntPtr clientHandle, Guid interfaceGuid, Dictionary result) + { + var bssListPtr = IntPtr.Zero; + + try + { + var guid = interfaceGuid; + + var ret = WlanGetNetworkBssList(clientHandle, ref guid, IntPtr.Zero, DOT11_BSS_TYPE.Any, false, + IntPtr.Zero, out bssListPtr); + + if (ret != ErrorSuccess || bssListPtr == IntPtr.Zero) + { + Log.Warn($"WlanGetNetworkBssList failed with error code {ret}."); + return false; + } + + // WLAN_BSS_LIST: DWORD dwTotalSize; DWORD dwNumberOfItems; WLAN_BSS_ENTRY[] entries. + // The entry array starts after the two DWORDs (8-byte aligned). + var totalSize = (long)(uint)Marshal.ReadInt32(bssListPtr, 0); + var numberOfItems = Marshal.ReadInt32(bssListPtr, 4); + var entrySize = Marshal.SizeOf(); + + for (var i = 0; i < numberOfItems; i++) + { + var entryByteOffset = 8 + i * entrySize; + var entry = Marshal.PtrToStructure(IntPtr.Add(bssListPtr, entryByteOffset)); + + // Skip invalid entries and guard against reading outside the allocated buffer + // (defensive: a struct layout mismatch would otherwise risk an invalid read). + // The documented maximum IE data blob size is 2,324 bytes. + if (entry.dot11Bssid is not { Length: 6 } || entry.ulIeSize is 0 or > 2324 || entry.ulIeOffset == 0) + continue; + + var ieStart = entryByteOffset + (long)entry.ulIeOffset; + + if (ieStart + entry.ulIeSize > totalSize) + continue; + + var ie = new byte[entry.ulIeSize]; + Marshal.Copy(IntPtr.Add(bssListPtr, (int)ieStart), ie, 0, (int)entry.ulIeSize); + + var width = GetBandwidthFromInformationElements(ie); + + if (width > 0) + result[FormatBssid(entry.dot11Bssid)] = width; + } + + return true; + } + finally + { + if (bssListPtr != IntPtr.Zero) + WlanFreeMemory(bssListPtr); + } + } + + /// + /// Returns the GUIDs of all WLAN interfaces known to the native Wi-Fi service. + /// + private static List EnumerateInterfaces(IntPtr clientHandle) + { + var guids = new List(); + + var listPtr = IntPtr.Zero; + + try + { + if (WlanEnumInterfaces(clientHandle, IntPtr.Zero, out listPtr) != ErrorSuccess || listPtr == IntPtr.Zero) + return guids; + + // WLAN_INTERFACE_INFO_LIST: DWORD dwNumberOfItems; DWORD dwIndex; WLAN_INTERFACE_INFO[] entries. + var numberOfItems = Marshal.ReadInt32(listPtr, 0); + var infoSize = Marshal.SizeOf(); + var arrayPtr = IntPtr.Add(listPtr, 8); + + for (var i = 0; i < numberOfItems; i++) + { + var info = Marshal.PtrToStructure(IntPtr.Add(arrayPtr, i * infoSize)); + guids.Add(info.InterfaceGuid); + } + } + catch (Exception ex) + { + Log.Error("Error while enumerating Wi-Fi interfaces via wlanapi.dll.", ex); + } + finally + { + if (listPtr != IntPtr.Zero) + WlanFreeMemory(listPtr); + } + + return guids; + } + + /// + /// Walks the TLV-encoded 802.11 information elements and returns the widest channel + /// bandwidth (MHz) advertised by the HT-, VHT- or HE-Operation element. Returns 0 if none + /// of these elements are present. + /// + private static int GetBandwidthFromInformationElements(byte[] ie) + { + int htWidth = 0, vhtWidth = 0, heWidth = 0; + + var pos = 0; + + while (pos + 2 <= ie.Length) + { + var id = ie[pos]; + int len = ie[pos + 1]; + var data = pos + 2; + + if (data + len > ie.Length) + break; + + switch (id) + { + case EidHtOperation when len >= 2: + { + // HT Operation Information byte 0: bits 0-1 secondary channel offset, + // bit 2 STA channel width (0 = 20 MHz, 1 = any/40 MHz). + var info = ie[data + 1]; + var secondaryChannelOffset = info & 0x03; + var staChannelWidth = (info >> 2) & 0x01; + htWidth = staChannelWidth == 1 && secondaryChannelOffset != 0 ? 40 : 20; + break; + } + case EidVhtOperation when len >= 3: + vhtWidth = ParseVhtWidth(ie[data], ie[data + 1], ie[data + 2], htWidth); + break; + case EidExtension when len >= 1 && ie[data] == ExtEidHeOperation: + heWidth = ParseHeWidth(ie, data + 1, len - 1, htWidth); + break; + } + + pos = data + len; + } + + return Math.Max(htWidth, Math.Max(vhtWidth, heWidth)); + } + + /// + /// Resolves the channel width from a VHT-Operation channel-width field plus its two center + /// frequency segments. Falls back to when the VHT field signals + /// "20/40 MHz". + /// + private static int ParseVhtWidth(int channelWidth, int segment0, int segment1, int htWidth) + { + switch (channelWidth) + { + case 0: // 20/40 MHz -> defer to HT + return htWidth > 0 ? htWidth : 20; + case 1: // 80 MHz, unless the segments indicate 160 / 80+80 MHz + if (segment1 == 0) + return 80; + var diff = Math.Abs(segment0 - segment1); + return diff is 8 or > 16 ? 160 : 80; + case 2: // 160 MHz (deprecated encoding) + case 3: // 80+80 MHz (deprecated encoding) - treated as 160 MHz for display + return 160; + default: + return 80; + } + } + + /// + /// Parses the HE-Operation element (802.11ax). Optional sub-fields (VHT Operation + /// Information, 6 GHz Operation Information) are only present when the corresponding bit in + /// the HE Operation Parameters is set; their offsets therefore depend on the preceding + /// fields. This is a best-effort parse: on any ambiguity 0 is returned so the caller can + /// fall back to a heuristic. + /// + /// The full information element buffer. + /// Offset of the HE Operation Parameters (after the extension id byte). + /// Remaining length of the HE-Operation element after the extension id. + /// Width parsed from the HT-Operation element (used as VHT fallback). + private static int ParseHeWidth(byte[] ie, int offset, int length, int htWidth) + { + // HE Operation Parameters (3) + BSS Color (1) + Basic HE-MCS And NSS Set (2) = 6 bytes minimum. + if (length < 6) + return 0; + + var end = offset + length; + + // HE Operation Parameters bit layout: B14 VHT Operation Information Present, + // B15 Co-Hosted BSS, B16 ER SU Disable, B17 6 GHz Operation Information Present. + var heOperationParameters = ie[offset] | (ie[offset + 1] << 8) | (ie[offset + 2] << 16); + var vhtOperationInformationPresent = ((heOperationParameters >> 14) & 1) == 1; + var coHostedBss = ((heOperationParameters >> 15) & 1) == 1; + var sixGhzOperationInformationPresent = ((heOperationParameters >> 17) & 1) == 1; + + var cursor = offset + 3 + 1 + 2; // skip parameters, BSS color, basic HE-MCS + var width = 0; + + if (vhtOperationInformationPresent) + { + if (cursor + 3 <= end) + width = Math.Max(width, ParseVhtWidth(ie[cursor], ie[cursor + 1], ie[cursor + 2], htWidth)); + + cursor += 3; + } + + if (coHostedBss) + cursor += 1; + + if (sixGhzOperationInformationPresent && cursor + 2 <= end) + { + // 6 GHz Operation Information: Primary Channel (1), Control (1), ... + // Control byte bits 0-1 = channel width (0 = 20, 1 = 40, 2 = 80, 3 = 160/80+80 MHz). + var channelWidth = ie[cursor + 1] & 0x03; + var sixGhzWidth = channelWidth switch + { + 0 => 20, + 1 => 40, + 2 => 80, + 3 => 160, + _ => 0 + }; + width = Math.Max(width, sixGhzWidth); + } + + return width; + } + + /// + /// Formats a 6-byte BSSID into the lowercase, colon-separated representation used by the + /// Windows Runtime API (e.g. aa:bb:cc:dd:ee:ff). + /// + private static string FormatBssid(byte[] mac) + { + return $"{mac[0]:x2}:{mac[1]:x2}:{mac[2]:x2}:{mac[3]:x2}:{mac[4]:x2}:{mac[5]:x2}"; + } +} diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml new file mode 100644 index 0000000000..30ae582560 --- /dev/null +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs new file mode 100644 index 0000000000..6184ce5716 --- /dev/null +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using LiveChartsCore; +using LiveChartsCore.Kernel; +using LiveChartsCore.Kernel.Sketches; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; +using SkiaSharp; + +namespace NETworkManager.Controls; + +public partial class LiveChartsWiFiChannelTooltip : IChartTooltip, INotifyPropertyChanged +{ + private readonly Popup _popup; + + public LiveChartsWiFiChannelTooltip() + { + InitializeComponent(); + DataContext = this; + _popup = new Popup + { + AllowsTransparency = true, + Placement = PlacementMode.MousePoint, + StaysOpen = true, + Child = this + }; + } + + public event PropertyChangedEventHandler PropertyChanged; + + public ObservableCollection TooltipEntries { get; } = []; + + public void Show(IEnumerable tooltipPoints, Chart chart) + { + // Each network is drawn as a trapezoid (multiple points). List every network once, + // regardless of which vertex the pointer is closest to, so the tooltip works across the + // whole trapezoid and not only at its center. + var seenNetworks = new HashSet(); + + TooltipEntries.Clear(); + + foreach (var point in tooltipPoints) + { + if (point.Context.DataSource is not WiFiChannelPoint info) + continue; + + var network = info.Network; + + if (!seenNetworks.Add(network)) + continue; + + var ssid = network.IsHidden ? Strings.HiddenNetwork : network.AvailableNetwork.Ssid; + var detail = + $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm · {Strings.Channel} {network.Channel} · {network.ChannelBandwidth} MHz"; + + TooltipEntries.Add(new TooltipEntry(SkColorToBrush(GetSeriesColor(point)), ssid, detail)); + } + + if (TooltipEntries.Count == 0) + { + _popup.IsOpen = false; + return; + } + + _popup.PlacementTarget = chart.View as FrameworkElement; + _popup.IsOpen = true; + } + + public void Hide(Chart chart) + { + _popup.IsOpen = false; + } + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + private static SKColor GetSeriesColor(ChartPoint point) + { + if (point.Context.Series is LineSeries ls && ls.Stroke is SolidColorPaint paint) + return paint.Color; + return SKColors.Gray; + } + + private static Brush SkColorToBrush(SKColor color) + => new SolidColorBrush(Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue)); + + public record TooltipEntry(Brush SeriesColor, string Ssid, string Detail); +} diff --git a/Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml b/Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml deleted file mode 100644 index 3b44e78a3e..0000000000 --- a/Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml.cs b/Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml.cs deleted file mode 100644 index a0960b68f4..0000000000 --- a/Source/NETworkManager/Controls/LvlChartsWiFiChannelTooltip.xaml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using LiveCharts; -using LiveCharts.Wpf; - -namespace NETworkManager.Controls; - -public partial class LvlChartsWiFiChannelTooltip : IChartTooltip -{ - public LvlChartsWiFiChannelTooltip() - { - InitializeComponent(); - - DataContext = this; - } - - public event PropertyChangedEventHandler PropertyChanged; - - public TooltipData Data - { - get; - set - { - field = value; - OnPropertyChanged(); - } - } - - public TooltipSelectionMode? SelectionMode { get; set; } - - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } -} \ No newline at end of file diff --git a/Source/NETworkManager/Controls/WiFiChannelPoint.cs b/Source/NETworkManager/Controls/WiFiChannelPoint.cs new file mode 100644 index 0000000000..5b93dd292d --- /dev/null +++ b/Source/NETworkManager/Controls/WiFiChannelPoint.cs @@ -0,0 +1,21 @@ +using NETworkManager.Models.Network; + +namespace NETworkManager.Controls; + +/// +/// A single data point of a Wi-Fi channel chart series. The X coordinate is expressed in +/// channel-number space (which is linear with the channel frequency), the Y coordinate is the +/// signal strength in dBm. Each point carries a reference to the originating network so the +/// chart tooltip can display its details. +/// +/// X coordinate in channel-number space. +/// Signal strength in dBm (Y coordinate). +/// The Wi-Fi network this point belongs to. +public class WiFiChannelPoint(double channelAxis, double dbm, WiFiNetworkInfo network) +{ + public double ChannelAxis { get; } = channelAxis; + + public double Dbm { get; } = dbm; + + public WiFiNetworkInfo Network { get; } = network; +} diff --git a/Source/NETworkManager/NETworkManager.csproj b/Source/NETworkManager/NETworkManager.csproj index 817363ce92..4852efab27 100644 --- a/Source/NETworkManager/NETworkManager.csproj +++ b/Source/NETworkManager/NETworkManager.csproj @@ -60,7 +60,6 @@ - diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs index 254601c740..deb9903eb3 100644 --- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs +++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs @@ -1,7 +1,12 @@ -using LiveCharts; -using LiveCharts.Wpf; +using LiveChartsCore; +using LiveChartsCore.Drawing; +using LiveChartsCore.Kernel; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; +using LiveChartsCore.SkiaSharpView.Painting.Effects; using log4net; using MahApps.Metro.SimpleChildWindow; +using NETworkManager.Controls; using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models.Export; @@ -9,6 +14,7 @@ using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; +using SkiaSharp; using System; using System.Collections; using System.Collections.Generic; @@ -191,7 +197,7 @@ public string Search } } - public bool Show2dot4GHzNetworks + public bool Show2Dot4GHzNetworks { get; set @@ -251,7 +257,7 @@ public bool Show6GHzNetworks public ObservableCollection Networks { get; - set + init { if (value != null && value == field) return; @@ -289,28 +295,64 @@ public IList SelectedNetworks } } = new ArrayList(); - public SeriesCollection Radio2dot4GHzSeries { get; set; } = []; + public ISeries[] Radio2Dot4GHzSeries + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; - public string[] Radio2dot4GHzLabels { get; set; } = - [" ", " ", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", " ", " "]; + public ISeries[] Radio5GHzSeries + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; - public SeriesCollection Radio5GHzSeries { get; set; } = []; + // 6 GHz spans a very wide range (channels 1-233). It is split into a lower (1-125) and an upper + // (129-233) chart for readability, similar to the UniFi channel view. + public ISeries[] Radio6GHzLowerSeries + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; - public string[] Radio5GHzLabels { get; set; } = - [ - " ", " ", "36", "40", "44", "48", "52", "56", "60", "64", "", "", "", "", "100", "104", "108", "112", "116", - "120", "124", "128", "132", "136", "140", "144", "149", "153", "157", "161", "165", " ", " " - ]; + public ISeries[] Radio6GHzUpperSeries + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; - public SeriesCollection Radio6GHzSeries { get; set; } = []; + public Axis[] Radio2Dot4GHzXAxes { get; private set; } + public Axis[] Radio5GHzXAxes { get; private set; } + public Axis[] Radio6GHzLowerXAxes { get; private set; } + public Axis[] Radio6GHzUpperXAxes { get; private set; } - public string[] Radio6GHzLabels { get; set; } = - [ + public Axis[] Radio2Dot4GHzYAxes { get; private set; } + public Axis[] Radio5GHzYAxes { get; private set; } + public Axis[] Radio6GHzLowerYAxes { get; private set; } + public Axis[] Radio6GHzUpperYAxes { get; private set; } - ]; + public RectangularSection[] Radio2Dot4GHzSections { get; private set; } + public RectangularSection[] Radio5GHzSections { get; private set; } + public RectangularSection[] Radio6GHzLowerSections { get; private set; } + public RectangularSection[] Radio6GHzUpperSections { get; private set; } - public Func FormattedDbm { get; set; } = - value => $"- {100 - value} dBm"; // Reverse y-axis 0 to -100 + public SolidColorPaint LegendTextPaint { get; private set; } public bool IsStatusMessageDisplayed { @@ -398,7 +440,11 @@ public WiFiViewModel() { _isLoading = true; - // Check if Microsoft.Windows.SDK.Contracts is available + // Set up the channel charts (axes, sections, paints) unconditionally so the chart bindings + // are never null, even on the code paths that return early below. + InitializeCharts(); + + // Check if Microsoft.Windows.SDK.Contracts is available SdkContractAvailable = ApiInformation.IsTypePresent("Windows.Devices.WiFi.WiFiAdapter"); if (!SdkContractAvailable) @@ -429,7 +475,7 @@ public WiFiViewModel() return false; // Filter by frequency - if ((info.Radio == WiFiRadio.GHz2dot4 && !Show2dot4GHzNetworks) || + if ((info.Radio == WiFiRadio.GHz2dot4 && !Show2Dot4GHzNetworks) || (info.Radio == WiFiRadio.GHz5 && !Show5GHzNetworks) || (info.Radio == WiFiRadio.GHz6 && !Show6GHzNetworks)) { @@ -474,7 +520,7 @@ public WiFiViewModel() private void LoadSettings() { - Show2dot4GHzNetworks = SettingsManager.Current.WiFi_Show2dot4GHzNetworks; + Show2Dot4GHzNetworks = SettingsManager.Current.WiFi_Show2dot4GHzNetworks; Show5GHzNetworks = SettingsManager.Current.WiFi_Show5GHzNetworks; Show6GHzNetworks = SettingsManager.Current.WiFi_Show6GHzNetworks; } @@ -633,10 +679,13 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals // Clear the values after the scan to make the UI smoother Log.Debug("ScanAsync - Clearing old values..."); Networks.Clear(); - Radio2dot4GHzSeries.Clear(); - Radio5GHzSeries.Clear(); Log.Debug("ScanAsync - Adding new values..."); + List series2Dot4GHz = []; + List series5GHz = []; + List series6GHzLower = []; + List series6GHzUpper = []; + foreach (var network in wiFiNetworkScanInfo.WiFiNetworkInfos) { Log.Debug("ScanAsync - Add network: " + network.AvailableNetwork.Ssid + " with channel frequency: " + @@ -647,21 +696,32 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals switch (network.Radio) { case WiFiRadio.GHz2dot4: - Radio2dot4GHzSeries.Add(GetSeriesCollection(network)); + series2Dot4GHz.Add(BuildNetworkSeries(network, series2Dot4GHz.Count)); break; - case WiFiRadio.GHz5: - Radio5GHzSeries.Add(GetSeriesCollection(network)); + case WiFiRadio.GHz5: + series5GHz.Add(BuildNetworkSeries(network, series5GHz.Count)); break; - // ToDo: Implement 6 GHz - /* - case WiFiRadio.GHz6: - break; - */ + case WiFiRadio.GHz6: + // Split by channel center into the lower (1-125) and upper (129-233) chart. + var centerChannel = FrequencyToChannelAxis( + network.ChannelCenterFrequencyInGigahertz * 1000, WiFiRadio.GHz6); + + if (centerChannel < SixGHzSplitChannel) + series6GHzLower.Add(BuildNetworkSeries(network, series6GHzLower.Count)); + else + series6GHzUpper.Add(BuildNetworkSeries(network, series6GHzUpper.Count)); + + break; } } + Radio2Dot4GHzSeries = [.. series2Dot4GHz]; + Radio5GHzSeries = [.. series5GHz]; + Radio6GHzLowerSeries = [.. series6GHzLower]; + Radio6GHzUpperSeries = [.. series6GHzUpper]; + statusMessage = string.Format(Strings.LastScanAtX, wiFiNetworkScanInfo.Timestamp.ToLongTimeString()); } @@ -674,8 +734,10 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals // Clear the existing old values if an error occurs Networks.Clear(); - Radio2dot4GHzSeries.Clear(); - Radio5GHzSeries.Clear(); + Radio2Dot4GHzSeries = []; + Radio5GHzSeries = []; + Radio6GHzLowerSeries = []; + Radio6GHzUpperSeries = []; } finally { @@ -689,62 +751,262 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals } } - private ChartValues GetDefaultChartValues(WiFiRadio radio) - { - ChartValues values = []; + /// + /// Color palette used to assign a distinct, deterministic color to each network series. + /// Assigning the stroke explicitly (instead of relying on the LiveCharts2 theme) keeps the + /// legend, the chart and the tooltip in sync. + /// + private static readonly SKColor[] ChartPalette = + [ + SKColor.Parse("#1ba1e2"), SKColor.Parse("#a4c400"), SKColor.Parse("#f0a30a"), + SKColor.Parse("#e51400"), SKColor.Parse("#6a00ff"), SKColor.Parse("#00aba9"), + SKColor.Parse("#d80073"), SKColor.Parse("#60a917"), SKColor.Parse("#fa6800"), + SKColor.Parse("#0050ef"), SKColor.Parse("#aa00ff"), SKColor.Parse("#825a2c") + ]; - var size = radio switch - { - WiFiRadio.GHz2dot4 => Radio2dot4GHzLabels.Length, - WiFiRadio.GHz5 => Radio5GHzLabels.Length, - WiFiRadio.GHz6 => Radio6GHzLabels.Length, - _ => 0 - }; + // The Y axis is plotted in 0..100 space (signal strength + 100) so the area fill drops to the + // bottom baseline; the axis labeler converts back to real dBm for display. + private const double SignalOffset = 100; + + // 2.4 GHz operating channels are 1, 2, 3, ... 14 (every channel number), labeled with the channel + private static readonly HashSet ValidChannels2Dot4GHz = BuildChannelSet(1, 14, 1); + + // 5 GHz operating channels are 36, 40, 44, ... 165 (every 4 in channel-number space), labeled with + private static readonly HashSet ValidChannels5GHz = + [ + .. BuildChannelSet(36, 64, 4), + .. BuildChannelSet(100, 165, 4) + ]; + + // 6 GHz operating channels are 1, 5, 9, ... 233 (every 4 in channel-number space), labeled with the + private static readonly HashSet ValidChannels6GHz = BuildChannelSet(1, 233, 4); + + // Channel that splits the 6 GHz band into the lower (1-125) and upper (129-233) chart. 127 sits + // cleanly between the operating channels 125 and 129. + private const int SixGHzSplitChannel = 127; - for (var i = 0; i < size; i++) - values.Add(-1); + // The 5 GHz band has a large unused range between channel 64 (UNII-2) and channel 100 + // (UNII-2 Extended). On a frequency-linear axis this would render as dead space, so it is + // compressed: channels above 64 are shifted left, leaving only a small visual gap that still + // provides room for the channel-width trapezoids. + private const int FiveGHzGapStartChannel = 64; + private const int FiveGHzGapEndChannel = 100; + private const int FiveGHzGapDisplayUnits = 16; + private const int FiveGHzGapShift = FiveGHzGapEndChannel - FiveGHzGapStartChannel - FiveGHzGapDisplayUnits; - return values; + private static HashSet BuildChannelSet(int first, int last, int step) + { + var set = new HashSet(); + + for (var channel = first; channel <= last; channel += step) + set.Add(channel); + + return set; } - private ChartValues GetChartValues(WiFiNetworkInfo network, int index) + /// + /// Builds axes, signal-quality sections and paints for all channel charts. Called once from + /// the constructor; the per-network series are (re)built on each scan. + /// + private void InitializeCharts() { - var values = GetDefaultChartValues(network.Radio); + var labelColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray5") is System.Windows.Media.SolidColorBrush gray5 + ? new SKColor(gray5.Color.R, gray5.Color.G, gray5.Color.B, gray5.Color.A) + : new SKColor(0x68, 0x68, 0x68); + + var separatorColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray8") is System.Windows.Media.SolidColorBrush gray8 + ? new SKColor(gray8.Color.R, gray8.Color.G, gray8.Color.B, gray8.Color.A) + : new SKColor(0x80, 0x80, 0x80); + + LegendTextPaint = new SolidColorPaint(labelColor) { SKTypeface = SKTypeface.Default }; + + // (min, max) in display space. The lower bound is extended below the first channel so the + // left flank of its trapezoid reaches the baseline instead of being clipped. The 5 GHz + // upper bound is reduced because the 64..100 dead zone is compressed. + Radio2Dot4GHzXAxes = BuildXAxes(-2, 16, ValidChannels2Dot4GHz, false, labelColor); + Radio5GHzXAxes = BuildXAxes(30, 152, ValidChannels5GHz, true, labelColor); + Radio6GHzLowerXAxes = BuildXAxes(-2, 128, ValidChannels6GHz, false, labelColor); + Radio6GHzUpperXAxes = BuildXAxes(126, 236, ValidChannels6GHz, false, labelColor); + + Radio2Dot4GHzYAxes = BuildYAxes(labelColor, separatorColor); + Radio5GHzYAxes = BuildYAxes(labelColor, separatorColor); + Radio6GHzLowerYAxes = BuildYAxes(labelColor, separatorColor); + Radio6GHzUpperYAxes = BuildYAxes(labelColor, separatorColor); + + Radio2Dot4GHzSections = BuildSections(); + Radio5GHzSections = BuildSections(); + Radio6GHzLowerSections = BuildSections(); + Radio6GHzUpperSections = BuildSections(); + } - var reverseMilliwatts = 100 - network.AvailableNetwork.NetworkRssiInDecibelMilliwatts * -1; + /// + /// Builds an X-axis in channel-number space (which is linear with the channel frequency). + /// Only channels contained in are labeled; all other tick + /// positions stay blank. When is set, the 5 GHz gap shift + /// is reversed before the label lookup. + /// + private static Axis[] BuildXAxes(double min, double max, HashSet validChannels, bool applyFiveGHzGap, + SKColor labelColor) + { + return + [ + new Axis + { + Name = Strings.Channel, + NamePaint = new SolidColorPaint(labelColor), + NameTextSize = 11, + MinLimit = min, + MaxLimit = max, + MinStep = 1, + ForceStepToMin = true, + Labeler = value => + { + var channel = (int)Math.Round(value); + + if (applyFiveGHzGap && channel > FiveGHzGapStartChannel) + channel += FiveGHzGapShift; + + return validChannels.Contains(channel) ? channel.ToString() : string.Empty; + }, + TextSize = 10, + Padding = new Padding(0, 4, 0, 0), + LabelsPaint = new SolidColorPaint(labelColor), + // No vertical grid lines (matches the legacy chart). + SeparatorsPaint = null + } + ]; + } - // ToDo: Implement channel width (20, 40, 80, 160) - values[index - 2] = -1; - values[index - 1] = reverseMilliwatts; - values[index] = reverseMilliwatts; - values[index + 1] = reverseMilliwatts; - values[index + 2] = -1; + /// + /// Builds the Y-axis (signal strength in dBm, -100..0) shared by all three channel charts. + /// + private static Axis[] BuildYAxes(SKColor labelColor, SKColor separatorColor) + { + return + [ + new Axis + { + Name = Strings.SignalStrength, + NamePaint = new SolidColorPaint(labelColor), + NameTextSize = 11, + MinLimit = 0, + MaxLimit = 100, + MinStep = 10, + ForceStepToMin = true, + // Plotted in 0..100 space; convert back to real dBm for the label. + Labeler = value => $"{(int)Math.Round(value) - (int)SignalOffset} dBm", + TextSize = 11, + Padding = new Padding(4, 0), + LabelsPaint = new SolidColorPaint(labelColor), + SeparatorsPaint = new SolidColorPaint(separatorColor) + { + StrokeThickness = 1, + PathEffect = new DashEffect([10f, 10f]) + } + } + ]; + } - return values; + /// + /// Builds the colored signal-quality bands (Cisco/MetaGeek thresholds), identical for all + /// bands. Returns fresh instances so they are not shared between charts. + /// + private static RectangularSection[] BuildSections() + { + // Thresholds in 0..100 space (dBm + 100): -30 -> 70, -67 -> 33, -70 -> 30, -80 -> 20. + return + [ + NewSection(70, 100, "#5EA4BF"), // Excellent (>= -30 dBm) + NewSection(33, 70, "#badc58"), // Good (-67…-30 dBm) + NewSection(30, 33, "#f9ca24"), // Reliable (-70…-67 dBm) + NewSection(20, 30, "#FF970D"), // Weak (-80…-70 dBm) + NewSection(0, 20, "#A4442B") // Poor (< -80 dBm) + ]; } - private LineSeries GetSeriesCollection(WiFiNetworkInfo network) + private static RectangularSection NewSection(double yi, double yj, string color) { - var radioLabels = network.Radio switch + return new RectangularSection { - WiFiRadio.GHz2dot4 => Radio2dot4GHzLabels, - WiFiRadio.GHz5 => Radio5GHzLabels, - WiFiRadio.GHz6 => Radio6GHzLabels, - _ => [] + Yi = yi, + Yj = yj, + Fill = new SolidColorPaint(SKColor.Parse(color).WithAlpha(0x40)) }; + } - var index = Array.IndexOf(radioLabels, $"{network.Channel}"); + /// + /// Builds a line series rendering a single network as a trapezoid centered on its channel + /// center frequency. The width of the trapezoid reflects the channel bandwidth. + /// + private static LineSeries BuildNetworkSeries(WiFiNetworkInfo network, int colorIndex) + { + var color = ChartPalette[colorIndex % ChartPalette.Length]; + + double dbm = network.AvailableNetwork.NetworkRssiInDecibelMilliwatts; + var center = ChannelNumberToDisplay( + FrequencyToChannelAxis(network.ChannelCenterFrequencyInGigahertz * 1000, network.Radio), network.Radio); + + // Channel-number space uses 5 MHz per unit, so a bandwidth in MHz spans bandwidth/5 units. + var bandwidth = network.ChannelBandwidth > 0 ? network.ChannelBandwidth : 20; + var half = bandwidth / 10.0; + var inner = half * 0.7; + const double floor = -100; // baseline in real dBm; mapped to 0 in the chart's 0..100 space + + // Flat-topped trapezoid: rises from the noise floor at the channel edges to the RSSI plateau. + WiFiChannelPoint[] points = + [ + new(center - half, floor, network), + new(center - inner, dbm, network), + new(center, dbm, network), + new(center + inner, dbm, network), + new(center + half, floor, network) + ]; + + var name = network.IsHidden + ? $"{Strings.HiddenNetwork} ({network.AvailableNetwork.Bssid})" + : $"{network.AvailableNetwork.Ssid} ({network.AvailableNetwork.Bssid})"; + + return new LineSeries + { + Name = name, + Values = points, + // Plot in 0..100 space (dBm + 100) so the area fill drops to the bottom baseline. + Mapping = (point, _) => new Coordinate(point.ChannelAxis, point.Dbm + SignalOffset), + GeometrySize = 0, + LineSmoothness = 0, + Stroke = new SolidColorPaint(color) { StrokeThickness = 1.5f }, + Fill = new SolidColorPaint(color.WithAlpha(0x33)) + }; + } - return new LineSeries + /// + /// Converts a channel center frequency (MHz) to channel-number space for the chart X axis. + /// This mapping is linear with frequency (5 MHz per unit). + /// + private static double FrequencyToChannelAxis(double frequencyMHz, WiFiRadio radio) + { + return radio switch { - Title = $"{network.AvailableNetwork.Ssid} ({network.AvailableNetwork.Bssid})", - Values = GetChartValues(network, index), - PointGeometry = null, - LineSmoothness = 0 + WiFiRadio.GHz2dot4 => (frequencyMHz - 2407) / 5.0, + WiFiRadio.GHz5 => (frequencyMHz - 5000) / 5.0, + WiFiRadio.GHz6 => (frequencyMHz - 5950) / 5.0, + _ => 0 }; } - private Task Connect() + /// + /// Maps a channel number to its display position on the X axis. For 5 GHz the unused + /// 64..100 range is compressed so it does not render as dead space. + /// + private static double ChannelNumberToDisplay(double channelNumber, WiFiRadio radio) + { + if (radio == WiFiRadio.GHz5 && channelNumber > FiveGHzGapStartChannel) + return channelNumber - FiveGHzGapShift; + + return channelNumber; + } + + private void Connect() { var selectedAdapter = SelectedAdapter; var selectedNetwork = SelectedNetwork; @@ -866,7 +1128,7 @@ private Task Connect() ConfigurationManager.Current.IsChildWindowOpen = true; - return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); + Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } private async void Disconnect() diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 030faf1ecc..5794351fc0 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -8,7 +8,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" - xmlns:liveChart="clr-namespace:LiveCharts.Wpf;assembly=LiveCharts.Wpf" + xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:controls="clr-namespace:NETworkManager.Controls" xmlns:controls2="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" @@ -53,11 +53,11 @@ - - + @@ -267,7 +267,7 @@ + - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/WiFiView.xaml.cs b/Source/NETworkManager/Views/WiFiView.xaml.cs index f673e179e8..c12c886e00 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml.cs +++ b/Source/NETworkManager/Views/WiFiView.xaml.cs @@ -8,6 +8,8 @@ public partial class WiFiView { private readonly WiFiViewModel _viewModel; + private int _channelTabControlSelectedIndex = 0; + public WiFiView() { _viewModel = new WiFiViewModel(); @@ -32,4 +34,26 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) if (sender is ContextMenu menu) menu.DataContext = _viewModel; } + + /// + /// Restores the previously selected channel chart tab. The content is rebuilt when switching + /// between the outer tabs, so the selection is restored from the view model on load. + /// + private void ChannelsTabControl_Loaded(object sender, RoutedEventArgs e) + { + if (sender is TabControl tabControl) + tabControl.SelectedIndex = _channelTabControlSelectedIndex; + } + + /// + /// Persists the selected channel chart tab. Ignores selection changes that occur while the + /// control is not yet loaded (e.g. during the initial template apply) so the saved value is + /// not overwritten with the default, and ignores bubbled events from nested selectors. + /// + private void ChannelsTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is TabControl tabControl && tabControl.IsLoaded && + ReferenceEquals(e.OriginalSource, tabControl)) + _channelTabControlSelectedIndex = tabControl.SelectedIndex; + } } \ No newline at end of file From be661691337a80dcf8345d490189afec2c2bce2b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 31 May 2026 22:56:19 +0200 Subject: [PATCH 02/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Controls/LiveChartsWiFiChannelTooltip.xaml.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs index 6184ce5716..9bdfe8c03a 100644 --- a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs @@ -57,8 +57,9 @@ public void Show(IEnumerable tooltipPoints, Chart chart) continue; var ssid = network.IsHidden ? Strings.HiddenNetwork : network.AvailableNetwork.Ssid; + var widthText = network.ChannelBandwidth > 0 ? $"{network.ChannelBandwidth} MHz" : "-/-"; var detail = - $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm · {Strings.Channel} {network.Channel} · {network.ChannelBandwidth} MHz"; + $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm · {Strings.Channel} {network.Channel} · {Strings.ChannelWidth} {widthText}"; TooltipEntries.Add(new TooltipEntry(SkColorToBrush(GetSeriesColor(point)), ssid, detail)); } From cfbb2cdd0fffefd0e9b622c2a315f968ac3abc4d Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 31 May 2026 22:56:53 +0200 Subject: [PATCH 03/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Source/NETworkManager/Views/WiFiView.xaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 5794351fc0..239118a7b8 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -614,9 +614,19 @@ MinWidth="80" /> + MinWidth="100"> + + + + Date: Sun, 31 May 2026 22:57:30 +0200 Subject: [PATCH 04/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Source/NETworkManager/Views/WiFiView.xaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 239118a7b8..7d04289697 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -714,8 +714,13 @@ - - + + + + + + + From dacab2f8bb27bd04583533f037f0359026d8dce6 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 31 May 2026 22:57:50 +0200 Subject: [PATCH 05/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Source/NETworkManager/Views/WiFiView.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 7d04289697..41c971b6dc 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -777,7 +777,7 @@ - Date: Sun, 31 May 2026 22:57:59 +0200 Subject: [PATCH 06/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Source/NETworkManager/Views/WiFiView.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 41c971b6dc..fe36cd06cb 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -796,7 +796,7 @@ - Date: Sun, 31 May 2026 22:58:09 +0200 Subject: [PATCH 07/16] Fix: Copilot feedback --- Source/NETworkManager.Models/Network/WiFi.cs | 2 -- Source/NETworkManager/Views/WiFiView.xaml.cs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager.Models/Network/WiFi.cs b/Source/NETworkManager.Models/Network/WiFi.cs index 94fe53bb61..a7665aaf2b 100644 --- a/Source/NETworkManager.Models/Network/WiFi.cs +++ b/Source/NETworkManager.Models/Network/WiFi.cs @@ -1,13 +1,11 @@ using log4net; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Windows.Devices.WiFi; using Windows.Networking.Connectivity; using Windows.Security.Credentials; -using Microsoft.CodeAnalysis.Emit; using NETworkManager.Models.Lookup; namespace NETworkManager.Models.Network; diff --git a/Source/NETworkManager/Views/WiFiView.xaml.cs b/Source/NETworkManager/Views/WiFiView.xaml.cs index c12c886e00..088a6c178a 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml.cs +++ b/Source/NETworkManager/Views/WiFiView.xaml.cs @@ -37,7 +37,7 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) /// /// Restores the previously selected channel chart tab. The content is rebuilt when switching - /// between the outer tabs, so the selection is restored from the view model on load. + /// between the outer tabs, so the selection is restored from the view when the control loads. /// private void ChannelsTabControl_Loaded(object sender, RoutedEventArgs e) { @@ -52,7 +52,7 @@ private void ChannelsTabControl_Loaded(object sender, RoutedEventArgs e) /// private void ChannelsTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (sender is TabControl tabControl && tabControl.IsLoaded && + if (sender is TabControl { IsLoaded: true } tabControl && ReferenceEquals(e.OriginalSource, tabControl)) _channelTabControlSelectedIndex = tabControl.SelectedIndex; } From 9398203d970425a1175766904d05dc6ff0364b13 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 31 May 2026 22:58:16 +0200 Subject: [PATCH 08/16] Docs: #3462 --- Website/docs/application/wifi.md | 17 +++++++++-------- Website/docs/changelog/next-release.md | 12 +++++++++++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Website/docs/application/wifi.md b/Website/docs/application/wifi.md index 9e26634677..1d6db4a779 100644 --- a/Website/docs/application/wifi.md +++ b/Website/docs/application/wifi.md @@ -34,12 +34,6 @@ Open `Windows Settings > Privacy & security > Location`, enable access for Deskt ::: -:::note - -Due to limitations of the `Windows.Devices.WiFi` API, the channel bandwidth cannot be detected. - -::: - ![WiFi](../img/wifi.png) ### Context menu @@ -59,12 +53,19 @@ Due to limitations of the `Windows.Devices.WiFi` API, the channel bandwidth cann ## Channels -On the **Channels** tab, all wireless networks of the selected wireless network adapter are displayed in a graphical view with channel and signal strength. This can be useful to identify overlapping wireless networks that do not originate from the same access point. +On the **Channels** tab, all wireless networks of the selected wireless network adapter are displayed in a graphical view with channel, channel width, and signal strength. This can be useful to identify overlapping wireless networks that do not originate from the same access point. + +The tab is split into two sub-tabs: + +- **2.4 & 5 GHz** — shows networks on the 2.4 GHz and 5 GHz bands in a single chart each. +- **6 GHz** — shows 6 GHz networks in two separate charts: lower channels (1–125) and upper channels (129–233). + +Each network is drawn as a proportional band reflecting its channel width (20, 40, 80, or 160 MHz), so overlapping networks can be identified more accurately. ![WiFi - Channel](../img/wifi--channel.png) :::note -Move the mouse over a channel to display all wireless networks occupying that channel in a tooltip. +Move the mouse over a network band to display details such as SSID, channel, channel width, and signal strength in a tooltip. ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index bc012d8056..73d9ca6827 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -36,6 +36,11 @@ Release date: **xx.xx.2025** - New **Speed Test** widget to measure download/upload speed, latency, and jitter against [`speed.cloudflare.com`](https://speed.cloudflare.com/). The test is user-initiated and shows download (Mbps), upload (Mbps), latency (ms), jitter (ms), ISP, and server location. A privacy disclaimer is shown before use. [#3440](https://github.com/BornToBeRoot/NETworkManager/pull/3440) +**WiFi** + +- Added **Channel Width** column (in MHz) to the network list. Channel bandwidth is retrieved via the native `WlanApi`, bypassing the `Windows.Devices.WiFi` API limitation. Typical values: 20, 40, 80, 160 MHz. [#3462](https://github.com/BornToBeRoot/NETworkManager/pull/3462) +- The **Channels** tab now supports 6 GHz networks and is split into two sub-tabs: **2.4 & 5 GHz** and **6 GHz**. The 6 GHz view uses separate lower (channels 1–125) and upper (channels 129–233) charts for readability. [#3462](https://github.com/BornToBeRoot/NETworkManager/pull/3462) + **PowerShell** - DPI scaling is now applied correctly when NETworkManager is moved to a monitor with a different DPI scaling factor. The embedded PowerShell (conhost) window now rescales its font automatically using the Windows Console API (`AttachConsole` + `SetCurrentConsoleFontEx`), bypassing the OS limitation that prevents `WM_DPICHANGED` from being forwarded to cross-process child windows. [#3352](https://github.com/BornToBeRoot/NETworkManager/pull/3352) @@ -61,6 +66,11 @@ Release date: **xx.xx.2025** ## Improvements +**WiFi** + +- Channel width is now visualized in the channel charts as a proportional band, making overlapping networks easier to identify. [#3462](https://github.com/BornToBeRoot/NETworkManager/pull/3462) +- Migrated the WiFi channel charts from LiveCharts to LiveCharts2. [#3462](https://github.com/BornToBeRoot/NETworkManager/pull/3462) + **IP Scanner** - MAC address resolution now uses ARP (IPv4) or NDP (IPv6) from the neighbor cache, with NetBIOS as fallback. The detail panel shows a single **MAC Address** section instead of separate ARP and NetBIOS entries. [#3403](https://github.com/BornToBeRoot/NETworkManager/pull/3403) @@ -124,7 +134,7 @@ Release date: **xx.xx.2025** ## Dependencies, Refactoring & Documentation -- Migrated from `LiveCharts` to `LiveCharts2` (`LiveChartsCore.SkiaSharpView.WPF`) for chart rendering. [#3449](https://github.com/BornToBeRoot/NETworkManager/pull/3449) [#3457](https://github.com/BornToBeRoot/NETworkManager/pull/3457) +- Migrated from `LiveCharts` to `LiveCharts2` (`LiveChartsCore.SkiaSharpView.WPF`) for chart rendering. [#3449](https://github.com/BornToBeRoot/NETworkManager/pull/3449) [#3457](https://github.com/BornToBeRoot/NETworkManager/pull/3457) [#3462](https://github.com/BornToBeRoot/NETworkManager/pull/3462) - Replace fire-and-forget `.ConfigureAwait(false)` calls with explicit discard assignments (`_ = SomeAsyncOperation()`) across command handlers, startup/load paths and profile callbacks. [#3441](https://github.com/BornToBeRoot/NETworkManager/pull/3441) - Code cleanup & refactoring - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) From ec9c194abd836c7326d4b3170fe5470c0673b2d3 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 31 May 2026 23:02:27 +0200 Subject: [PATCH 09/16] Fix: Use converter for channel width --- .../WiFiChannelBandwidthToStringConverter.cs | 18 ++++++++++++++++++ Source/NETworkManager/Views/WiFiView.xaml | 17 ++++------------- 2 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 Source/NETworkManager.Converters/WiFiChannelBandwidthToStringConverter.cs diff --git a/Source/NETworkManager.Converters/WiFiChannelBandwidthToStringConverter.cs b/Source/NETworkManager.Converters/WiFiChannelBandwidthToStringConverter.cs new file mode 100644 index 0000000000..0a6b35c934 --- /dev/null +++ b/Source/NETworkManager.Converters/WiFiChannelBandwidthToStringConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace NETworkManager.Converters; + +public sealed class WiFiChannelBandwidthToStringConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is not int bandwidth || bandwidth == 0 ? "-/-" : $"{bandwidth} MHz"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index fe36cd06cb..2e6105eb18 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -28,6 +28,7 @@ + - - - - + MinWidth="100" /> Date: Sun, 31 May 2026 23:29:36 +0200 Subject: [PATCH 10/16] Fix: Some bug fixes --- .../Resources/Strings.Designer.cs | 11 ++++++- .../Resources/Strings.resx | 3 ++ .../LiveChartsBandwidthTooltip.xaml.cs | 2 +- .../LiveChartsPingTimeTooltip.xaml.cs | 2 +- .../LiveChartsWiFiChannelTooltip.xaml.cs | 8 ++--- .../ViewModels/WiFiViewModel.cs | 33 ++++++++++++++----- .../Views/NetworkInterfaceView.xaml | 2 +- Source/NETworkManager/Views/WiFiView.xaml | 11 ++----- 8 files changed, 45 insertions(+), 27 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index c03df53bca..5379e77ebd 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -4813,7 +4813,16 @@ public static string GHz5 { return ResourceManager.GetString("GHz5", resourceCulture); } } - + + /// + /// Looks up a localized string similar to 2.4 & 5 GHz. + /// + public static string GHz2dot4And5 { + get { + return ResourceManager.GetString("GHz2dot4And5", resourceCulture); + } + } + /// /// Looks up a localized string similar to 6 GHz. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 7d10eb50e0..3539a167ac 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2283,6 +2283,9 @@ $$hostname$$ --> Hostname 5 GHz + + 2.4 & 5 GHz + Signal strength diff --git a/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs index 85abf5f6b4..d004da9521 100644 --- a/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs +++ b/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs @@ -82,7 +82,7 @@ protected virtual void OnPropertyChanged([CallerMemberName] string propertyName private static SKColor GetSeriesColor(ChartPoint point) { - if (point.Context.Series is LineSeries ls && ls.Stroke is SolidColorPaint paint) + if (point.Context.Series is LineSeries { Stroke: SolidColorPaint paint }) return paint.Color; return SKColors.Gray; } diff --git a/Source/NETworkManager/Controls/LiveChartsPingTimeTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsPingTimeTooltip.xaml.cs index 8a802b9e48..2e3db4b47e 100644 --- a/Source/NETworkManager/Controls/LiveChartsPingTimeTooltip.xaml.cs +++ b/Source/NETworkManager/Controls/LiveChartsPingTimeTooltip.xaml.cs @@ -82,7 +82,7 @@ protected virtual void OnPropertyChanged([CallerMemberName] string propertyName private static SKColor GetSeriesColor(ChartPoint point) { - if (point.Context.Series is LineSeries ls && ls.Stroke is SolidColorPaint paint) + if (point.Context.Series is LineSeries { Stroke: SolidColorPaint paint }) return paint.Color; return SKColors.Gray; } diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs index 9bdfe8c03a..bb907b2f01 100644 --- a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; -using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls.Primitives; using System.Windows.Media; @@ -59,7 +58,7 @@ public void Show(IEnumerable tooltipPoints, Chart chart) var ssid = network.IsHidden ? Strings.HiddenNetwork : network.AvailableNetwork.Ssid; var widthText = network.ChannelBandwidth > 0 ? $"{network.ChannelBandwidth} MHz" : "-/-"; var detail = - $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm · {Strings.Channel} {network.Channel} · {Strings.ChannelWidth} {widthText}"; + $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm · {Strings.Channel} {network.Channel} · {widthText}"; TooltipEntries.Add(new TooltipEntry(SkColorToBrush(GetSeriesColor(point)), ssid, detail)); } @@ -79,12 +78,9 @@ public void Hide(Chart chart) _popup.IsOpen = false; } - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - private static SKColor GetSeriesColor(ChartPoint point) { - if (point.Context.Series is LineSeries ls && ls.Stroke is SolidColorPaint paint) + if (point.Context.Series is LineSeries { Stroke: SolidColorPaint paint }) return paint.Color; return SKColors.Gray; } diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs index deb9903eb3..a275f1f9eb 100644 --- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs +++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs @@ -952,15 +952,30 @@ private static LineSeries BuildNetworkSeries(WiFiNetworkInfo n var inner = half * 0.7; const double floor = -100; // baseline in real dBm; mapped to 0 in the chart's 0..100 space - // Flat-topped trapezoid: rises from the noise floor at the channel edges to the RSSI plateau. - WiFiChannelPoint[] points = - [ - new(center - half, floor, network), - new(center - inner, dbm, network), - new(center, dbm, network), - new(center + inner, dbm, network), - new(center + half, floor, network) - ]; + // Dense points at 0.5-channel steps so that CompareOnlyX always finds this series when the + // mouse is anywhere inside the trapezoid, even when a narrower network shares the same area. + // With only 5 corner points the nearest point of a wide network can be far from the mouse + // while a narrower network's corner is closer, causing the wide network to be omitted from + // the tooltip despite the mouse being visually inside it. + const double step = 0.5; + var rampWidth = half - inner; // > 0 always (inner = half * 0.7) + var pointList = new List(); + + for (var x = center - half; x <= center + half + step * 0.01; x += step) + { + double y; + + if (x <= center - inner) + y = floor + (dbm - floor) * (x - (center - half)) / rampWidth; + else if (x >= center + inner) + y = dbm - (dbm - floor) * (x - (center + inner)) / rampWidth; + else + y = dbm; + + pointList.Add(new WiFiChannelPoint(x, y, network)); + } + + var points = pointList.ToArray(); var name = network.IsHidden ? $"{Strings.HiddenNetwork} ({network.AvailableNetwork.Bssid})" diff --git a/Source/NETworkManager/Views/NetworkInterfaceView.xaml b/Source/NETworkManager/Views/NetworkInterfaceView.xaml index a8054f4002..31e7d04cef 100644 --- a/Source/NETworkManager/Views/NetworkInterfaceView.xaml +++ b/Source/NETworkManager/Views/NetworkInterfaceView.xaml @@ -846,7 +846,7 @@ Series="{Binding Series}" XAxes="{Binding BandwidthXAxes}" YAxes="{Binding BandwidthYAxes}" - LegendPosition="Right" + LegendPosition="Bottom" LegendTextPaint="{Binding BandwidthLegendTextPaint}" LegendTextSize="12" DrawMarginFrame="{x:Null}" diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 2e6105eb18..0ea63b4fd0 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -617,7 +617,7 @@ Header="{x:Static Member=localization:Strings.ChannelWidth}" Binding="{Binding Path=(network:WiFiNetworkInfo.ChannelBandwidth), Converter={StaticResource ResourceKey=WiFiChannelBandwidthToStringConverter}}" SortMemberPath="ChannelBandwidth" - MinWidth="100" /> + MinWidth="120" /> - - - - - - - + + From bea0ff0c95da4d43c1c8664351419831d6885ab4 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:08:01 +0200 Subject: [PATCH 11/16] Feature: WiFi legends + bug fixes --- .../LiveChartsWiFiChannelTooltip.xaml | 25 ++- .../LiveChartsWiFiChannelTooltip.xaml.cs | 9 +- .../Resources/Styles/TextBlockStyles.xaml | 1 - .../ViewModels/WiFiViewModel.cs | 99 ++++++++++-- Source/NETworkManager/Views/WiFiView.xaml | 146 ++++++++++++++++-- 5 files changed, 235 insertions(+), 45 deletions(-) diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml index 30ae582560..e6238c7fb8 100644 --- a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml @@ -11,7 +11,7 @@ - + @@ -22,13 +22,22 @@ - - + + + + + + + + diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs index bb907b2f01..d46f1fe62f 100644 --- a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs @@ -56,11 +56,12 @@ public void Show(IEnumerable tooltipPoints, Chart chart) continue; var ssid = network.IsHidden ? Strings.HiddenNetwork : network.AvailableNetwork.Ssid; + var bssid = network.AvailableNetwork.Bssid; + var dbm = $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm"; var widthText = network.ChannelBandwidth > 0 ? $"{network.ChannelBandwidth} MHz" : "-/-"; - var detail = - $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm · {Strings.Channel} {network.Channel} · {widthText}"; + var channelDetail = $"{Strings.Channel} {network.Channel} · {widthText}"; - TooltipEntries.Add(new TooltipEntry(SkColorToBrush(GetSeriesColor(point)), ssid, detail)); + TooltipEntries.Add(new TooltipEntry(SkColorToBrush(GetSeriesColor(point)), ssid, bssid, dbm, channelDetail)); } if (TooltipEntries.Count == 0) @@ -88,5 +89,5 @@ private static SKColor GetSeriesColor(ChartPoint point) private static Brush SkColorToBrush(SKColor color) => new SolidColorBrush(Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue)); - public record TooltipEntry(Brush SeriesColor, string Ssid, string Detail); + public record TooltipEntry(Brush SeriesColor, string Ssid, string Bssid, string Dbm, string ChannelDetail); } diff --git a/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml b/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml index a2b2fd109d..379671f3d5 100644 --- a/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml @@ -55,7 +55,6 @@ diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs index a275f1f9eb..9c455e67af 100644 --- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs +++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs @@ -24,6 +24,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Data; +using System.Windows.Media; using System.Windows.Input; using System.Windows.Threading; using Windows.Devices.WiFi; @@ -352,7 +353,45 @@ private set public RectangularSection[] Radio6GHzLowerSections { get; private set; } public RectangularSection[] Radio6GHzUpperSections { get; private set; } - public SolidColorPaint LegendTextPaint { get; private set; } + public WiFiChannelLegendEntry[] Radio2Dot4GHzLegend + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; + + public WiFiChannelLegendEntry[] Radio5GHzLegend + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; + + public WiFiChannelLegendEntry[] Radio6GHzLowerLegend + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; + + public WiFiChannelLegendEntry[] Radio6GHzUpperLegend + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; public bool IsStatusMessageDisplayed { @@ -685,6 +724,10 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals List series5GHz = []; List series6GHzLower = []; List series6GHzUpper = []; + List legend2Dot4GHz = []; + List legend5GHz = []; + List legend6GHzLower = []; + List legend6GHzUpper = []; foreach (var network in wiFiNetworkScanInfo.WiFiNetworkInfos) { @@ -696,11 +739,15 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals switch (network.Radio) { case WiFiRadio.GHz2dot4: - series2Dot4GHz.Add(BuildNetworkSeries(network, series2Dot4GHz.Count)); + var (s24, l24) = BuildNetworkSeries(network, series2Dot4GHz.Count); + series2Dot4GHz.Add(s24); + legend2Dot4GHz.Add(l24); break; - case WiFiRadio.GHz5: - series5GHz.Add(BuildNetworkSeries(network, series5GHz.Count)); + case WiFiRadio.GHz5: + var (s5, l5) = BuildNetworkSeries(network, series5GHz.Count); + series5GHz.Add(s5); + legend5GHz.Add(l5); break; case WiFiRadio.GHz6: @@ -709,9 +756,17 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals network.ChannelCenterFrequencyInGigahertz * 1000, WiFiRadio.GHz6); if (centerChannel < SixGHzSplitChannel) - series6GHzLower.Add(BuildNetworkSeries(network, series6GHzLower.Count)); + { + var (s6L, l6L) = BuildNetworkSeries(network, series6GHzLower.Count); + series6GHzLower.Add(s6L); + legend6GHzLower.Add(l6L); + } else - series6GHzUpper.Add(BuildNetworkSeries(network, series6GHzUpper.Count)); + { + var (s6U, l6U) = BuildNetworkSeries(network, series6GHzUpper.Count); + series6GHzUpper.Add(s6U); + legend6GHzUpper.Add(l6U); + } break; } @@ -721,6 +776,10 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals Radio5GHzSeries = [.. series5GHz]; Radio6GHzLowerSeries = [.. series6GHzLower]; Radio6GHzUpperSeries = [.. series6GHzUpper]; + Radio2Dot4GHzLegend = [.. legend2Dot4GHz]; + Radio5GHzLegend = [.. legend5GHz]; + Radio6GHzLowerLegend = [.. legend6GHzLower]; + Radio6GHzUpperLegend = [.. legend6GHzUpper]; statusMessage = string.Format(Strings.LastScanAtX, wiFiNetworkScanInfo.Timestamp.ToLongTimeString()); @@ -738,6 +797,10 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals Radio5GHzSeries = []; Radio6GHzLowerSeries = []; Radio6GHzUpperSeries = []; + Radio2Dot4GHzLegend = []; + Radio5GHzLegend = []; + Radio6GHzLowerLegend = []; + Radio6GHzUpperLegend = []; } finally { @@ -810,16 +873,14 @@ private static HashSet BuildChannelSet(int first, int last, int step) /// private void InitializeCharts() { - var labelColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray5") is System.Windows.Media.SolidColorBrush gray5 + var labelColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray5") is SolidColorBrush gray5 ? new SKColor(gray5.Color.R, gray5.Color.G, gray5.Color.B, gray5.Color.A) : new SKColor(0x68, 0x68, 0x68); - var separatorColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray8") is System.Windows.Media.SolidColorBrush gray8 + var separatorColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray8") is SolidColorBrush gray8 ? new SKColor(gray8.Color.R, gray8.Color.G, gray8.Color.B, gray8.Color.A) : new SKColor(0x80, 0x80, 0x80); - LegendTextPaint = new SolidColorPaint(labelColor) { SKTypeface = SKTypeface.Default }; - // (min, max) in display space. The lower bound is extended below the first channel so the // left flank of its trapezoid reaches the baseline instead of being clipped. The 5 GHz // upper bound is reduced because the 64..100 dead zone is compressed. @@ -935,10 +996,10 @@ private static RectangularSection NewSection(double yi, double yj, string color) } /// - /// Builds a line series rendering a single network as a trapezoid centered on its channel - /// center frequency. The width of the trapezoid reflects the channel bandwidth. + /// Builds a line series and a matching legend entry for a single network, rendered as a + /// trapezoid centered on its channel center frequency. /// - private static LineSeries BuildNetworkSeries(WiFiNetworkInfo network, int colorIndex) + private static (LineSeries Series, WiFiChannelLegendEntry Legend) BuildNetworkSeries(WiFiNetworkInfo network, int colorIndex) { var color = ChartPalette[colorIndex % ChartPalette.Length]; @@ -981,7 +1042,7 @@ private static LineSeries BuildNetworkSeries(WiFiNetworkInfo n ? $"{Strings.HiddenNetwork} ({network.AvailableNetwork.Bssid})" : $"{network.AvailableNetwork.Ssid} ({network.AvailableNetwork.Bssid})"; - return new LineSeries + var series = new LineSeries { Name = name, Values = points, @@ -992,6 +1053,12 @@ private static LineSeries BuildNetworkSeries(WiFiNetworkInfo n Stroke = new SolidColorPaint(color) { StrokeThickness = 1.5f }, Fill = new SolidColorPaint(color.WithAlpha(0x33)) }; + + var wpfColor = Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue); + var legendSsid = network.IsHidden ? Strings.HiddenNetwork : network.AvailableNetwork.Ssid; + var legend = new WiFiChannelLegendEntry(new SolidColorBrush(wpfColor), legendSsid, network.AvailableNetwork.Bssid); + + return (series, legend); } /// @@ -1253,4 +1320,6 @@ private void HideConnectionStatusMessageTimer_Tick(object sender, EventArgs e) } #endregion -} \ No newline at end of file +} + +public record WiFiChannelLegendEntry(SolidColorBrush Color, string Ssid, string Bssid); \ No newline at end of file diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 0ea63b4fd0..c0bfa3d076 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -710,9 +710,11 @@ + + @@ -722,9 +724,7 @@ XAxes="{Binding Path=Radio2Dot4GHzXAxes}" YAxes="{Binding Path=Radio2Dot4GHzYAxes}" Sections="{Binding Path=Radio2Dot4GHzSections}" - LegendPosition="Bottom" - LegendTextPaint="{Binding Path=LegendTextPaint}" - LegendTextSize="12" + LegendPosition="Hidden" FindingStrategy="CompareOnlyX" DrawMarginFrame="{x:Null}" EasingFunction="{x:Null}" @@ -733,17 +733,44 @@ - + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + @@ -759,9 +815,11 @@ + + @@ -771,9 +829,7 @@ XAxes="{Binding Path=Radio6GHzLowerXAxes}" YAxes="{Binding Path=Radio6GHzLowerYAxes}" Sections="{Binding Path=Radio6GHzLowerSections}" - LegendPosition="Bottom" - LegendTextPaint="{Binding Path=LegendTextPaint}" - LegendTextSize="12" + LegendPosition="Hidden" FindingStrategy="CompareOnlyX" DrawMarginFrame="{x:Null}" EasingFunction="{x:Null}" @@ -782,17 +838,44 @@ - + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + @@ -893,7 +1005,7 @@ - +