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/WiFiChannelBandwidthToStringConverter.cs similarity index 69% rename from Source/NETworkManager.Converters/WiFiDBMReverseConverter.cs rename to Source/NETworkManager.Converters/WiFiChannelBandwidthToStringConverter.cs index 7160f17813..eed8d891e1 100644 --- a/Source/NETworkManager.Converters/WiFiDBMReverseConverter.cs +++ b/Source/NETworkManager.Converters/WiFiChannelBandwidthToStringConverter.cs @@ -1,14 +1,14 @@ -using System; +using System; using System.Globalization; using System.Windows.Data; namespace NETworkManager.Converters; -public sealed class WiFiDBMReverseConverter : IValueConverter +public sealed class WiFiChannelBandwidthToStringConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return !(value is double) ? "-/-" : $"-{100 - (double)value} dBm"; + return value is not int bandwidth ? "-/-" : $"{bandwidth} MHz"; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 08463b220d..5379e77ebd 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. /// @@ -4804,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 886495042e..3539a167ac 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2274,12 +2274,18 @@ $$hostname$$ --> Hostname Channel + + Channel width + 2.4 GHz 5 GHz + + 2.4 & 5 GHz + Signal strength diff --git a/Source/NETworkManager.Models/Network/WiFi.cs b/Source/NETworkManager.Models/Network/WiFi.cs index 0066ee502a..d9475faca6 100644 --- a/Source/NETworkManager.Models/Network/WiFi.cs +++ b/Source/NETworkManager.Models/Network/WiFi.cs @@ -68,18 +68,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,54 +265,28 @@ 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 - { - // 2.4 GHz - 2.412 => 1, - 2.417 => 2, - 2.422 => 3, - 2.427 => 4, - 2.432 => 5, - 2.437 => 6, - 2.442 => 7, - 2.447 => 8, - 2.452 => 9, - 2.457 => 10, - 2.462 => 11, - 2.467 => 12, - 2.472 => 13, - 2.484 => 14, // Most countries do not allow this channel - // 5 GHz - 5.180 => 36, // UNII-1 - 5.200 => 40, // UNII-1 - 5.220 => 44, // UNII-1 - 5.240 => 48, // UNII-1 - 5.260 => 52, // UNII-2, DFS - 5.280 => 56, // UNII-2, DFS - 5.300 => 60, // UNII-2, DFS - 5.320 => 64, // UNII-2, DFS - 5.500 => 100, // UNII-2 Extended, DFS - 5.520 => 104, // UNII-2 Extended, DFS - 5.540 => 108, // UNII-2 Extended, DFS - 5.560 => 112, // UNII-2 Extended, DFS - 5.580 => 116, // UNII-2 Extended, DFS - 5.600 => 120, // UNII-2 Extended, DFS - 5.620 => 124, // UNII-2 Extended, DFS - 5.640 => 128, // UNII-2 Extended, DFS - 5.660 => 132, // UNII-2 Extended, DFS - 5.680 => 136, // UNII-2 Extended, DFS - 5.700 => 140, // UNII-2 Extended, DFS - 5.720 => 144, // UNII-2 Extended, DFS - 5.745 => 149, // UNII-3 - 5.765 => 153, // UNII-3 - 5.785 => 157, // UNII-3 - 5.805 => 161, // UNII-3 - 5.825 => 165, // UNII-3 - _ => -1 - }; + // Convert to integer MHz to avoid floating-point precision issues from the + // kilohertz / 1_000_000.0 conversion in ConvertChannelFrequencyToGigahertz. + var mhz = (int)Math.Round(gigahertz * 1000); + + // 2.4 GHz: channels 1-13 follow f = 2407 + ch×5 MHz; channel 14 is at 2484 MHz + if (mhz is >= 2412 and <= 2472) + return (mhz - 2407) / 5; + if (mhz == 2484) + return 14; + + // 5 GHz: channels follow f = 5000 + ch×5 MHz + if (mhz is >= 5180 and <= 5825) + return (mhz - 5000) / 5; + + // 6 GHz: channels 1-233 follow f = 5950 + ch×5 MHz + if (mhz is >= 5955 and <= 7115) + return (mhz - 5950) / 5; + + return -1; } /// @@ -313,7 +294,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 +304,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 +315,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..025c839c50 100644 --- a/Source/NETworkManager.Models/Network/WiFiNetworkInfo.cs +++ b/Source/NETworkManager.Models/Network/WiFiNetworkInfo.cs @@ -37,6 +37,12 @@ public WiFiNetworkInfo() /// public int Channel { get; set; } + /// + /// The channel bandwidth in MHz (e.g. 20, 40, 80, 160). Always >= 20: native value from + /// the BSS list when available, otherwise estimated from radio band and PHY kind. + /// + 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..8ac684c0e9 --- /dev/null +++ b/Source/NETworkManager.Models/Network/WlanApi.cs @@ -0,0 +1,406 @@ +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 ieSize = (int)entry.ulIeSize; // safe: guarded to <= 2324 above + + var ie = new byte[ieSize]; + Marshal.Copy(IntPtr.Add(bssListPtr, (int)ieStart), ie, 0, ieSize); + + 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/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 b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml new file mode 100644 index 0000000000..0b55b19963 --- /dev/null +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs new file mode 100644 index 0000000000..c19f58d2a2 --- /dev/null +++ b/Source/NETworkManager/Controls/LiveChartsWiFiChannelTooltip.xaml.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +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 bssid = network.AvailableNetwork.Bssid; + var dbm = $"{network.AvailableNetwork.NetworkRssiInDecibelMilliwatts} dBm"; + var widthText = $"{network.ChannelBandwidth} MHz"; + var channelDetail = $"{Strings.Channel} {network.Channel} · {widthText}"; + + TooltipEntries.Add(new TooltipEntry(SkColorToBrush(GetSeriesColor(point)), ssid, bssid, dbm, channelDetail)); + } + + 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; + } + + private static SKColor GetSeriesColor(ChartPoint point) + { + if (point.Context.Series is LineSeries { Stroke: 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 Bssid, string Dbm, string ChannelDetail); +} 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/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 254601c740..43e2ed1c5b 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; @@ -18,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; @@ -191,7 +198,7 @@ public string Search } } - public bool Show2dot4GHzNetworks + public bool Show2Dot4GHzNetworks { get; set @@ -251,7 +258,7 @@ public bool Show6GHzNetworks public ObservableCollection Networks { get; - set + init { if (value != null && value == field) return; @@ -289,28 +296,102 @@ 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 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 { @@ -398,7 +479,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 +514,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 +559,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; } @@ -546,9 +631,23 @@ private static void OpenSettingsAction() /// Fails if the access is denied. private static bool RequestAccess() { - var accessStatus = WiFiAdapter.RequestAccessAsync().GetAwaiter().GetResult(); + try + { + var accessStatus = WiFiAdapter.RequestAccessAsync().GetAwaiter().GetResult(); - return accessStatus == WiFiAccessStatus.Allowed; + return accessStatus == WiFiAccessStatus.Allowed; + } + catch (Exception ex) + { + // RequestAccessAsync() can throw instead of returning a status (e.g. on Windows ARM64 + // running the emulated x64 build, see GitHub issue #3110). Log it and treat it as denied + // so the view shows the "access not available" message with the settings button instead + // of silently failing (the exception would otherwise be swallowed by the WPF binding that + // constructs this view model). + Log.Error("Error while requesting access to the WiFi adapter.", ex); + + return false; + } } private async Task LoadAdaptersAsync(string adapterId = null) @@ -633,10 +732,17 @@ 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 = []; + List legend2Dot4GHz = []; + List legend5GHz = []; + List legend6GHzLower = []; + List legend6GHzUpper = []; + foreach (var network in wiFiNetworkScanInfo.WiFiNetworkInfos) { Log.Debug("ScanAsync - Add network: " + network.AvailableNetwork.Ssid + " with channel frequency: " + @@ -647,21 +753,48 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals switch (network.Radio) { case WiFiRadio.GHz2dot4: - Radio2dot4GHzSeries.Add(GetSeriesCollection(network)); + var (s24, l24) = BuildNetworkSeries(network, series2Dot4GHz.Count); + series2Dot4GHz.Add(s24); + legend2Dot4GHz.Add(l24); break; case WiFiRadio.GHz5: - Radio5GHzSeries.Add(GetSeriesCollection(network)); + var (s5, l5) = BuildNetworkSeries(network, series5GHz.Count); + series5GHz.Add(s5); + legend5GHz.Add(l5); 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) + { + var (s6L, l6L) = BuildNetworkSeries(network, series6GHzLower.Count); + series6GHzLower.Add(s6L); + legend6GHzLower.Add(l6L); + } + else + { + var (s6U, l6U) = BuildNetworkSeries(network, series6GHzUpper.Count); + series6GHzUpper.Add(s6U); + legend6GHzUpper.Add(l6U); + } + + break; } } + Radio2Dot4GHzSeries = [.. series2Dot4GHz]; + Radio5GHzSeries = [.. series5GHz]; + Radio6GHzLowerSeries = [.. series6GHzLower]; + Radio6GHzUpperSeries = [.. series6GHzUpper]; + Radio2Dot4GHzLegend = [.. legend2Dot4GHz]; + Radio5GHzLegend = [.. legend5GHz]; + Radio6GHzLowerLegend = [.. legend6GHzLower]; + Radio6GHzUpperLegend = [.. legend6GHzUpper]; + statusMessage = string.Format(Strings.LastScanAtX, wiFiNetworkScanInfo.Timestamp.ToLongTimeString()); } @@ -674,8 +807,14 @@ 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 = []; + Radio2Dot4GHzLegend = []; + Radio5GHzLegend = []; + Radio6GHzLowerLegend = []; + Radio6GHzUpperLegend = []; } finally { @@ -689,62 +828,282 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals } } - private ChartValues GetDefaultChartValues(WiFiRadio radio) + /// + /// 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") + ]; + + // 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; + + // 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; + + private static HashSet BuildChannelSet(int first, int last, int step) { - ChartValues values = []; + var set = new HashSet(); - var size = radio switch - { - WiFiRadio.GHz2dot4 => Radio2dot4GHzLabels.Length, - WiFiRadio.GHz5 => Radio5GHzLabels.Length, - WiFiRadio.GHz6 => Radio6GHzLabels.Length, - _ => 0 - }; + for (var channel = first; channel <= last; channel += step) + set.Add(channel); - for (var i = 0; i < size; i++) - values.Add(-1); + return set; + } - return values; + /// + /// 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 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 SolidColorBrush gray8 + ? new SKColor(gray8.Color.R, gray8.Color.G, gray8.Color.B, gray8.Color.A) + : new SKColor(0x80, 0x80, 0x80); + + // (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(); } - private ChartValues GetChartValues(WiFiNetworkInfo network, int index) + /// + /// 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) { - var values = GetDefaultChartValues(network.Radio); + 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 + } + ]; + } - var reverseMilliwatts = 100 - network.AvailableNetwork.NetworkRssiInDecibelMilliwatts * -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]) + } + } + ]; + } - // 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 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) + ]; + } - return values; + private static RectangularSection NewSection(double yi, double yj, string color) + { + return new RectangularSection + { + Yi = yi, + Yj = yj, + Fill = new SolidColorPaint(SKColor.Parse(color).WithAlpha(0x40)) + }; } - private LineSeries GetSeriesCollection(WiFiNetworkInfo network) + /// + /// 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 Series, WiFiChannelLegendEntry Legend) BuildNetworkSeries(WiFiNetworkInfo network, int colorIndex) { - var radioLabels = network.Radio switch + 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; + 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 + + // 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) { - WiFiRadio.GHz2dot4 => Radio2dot4GHzLabels, - WiFiRadio.GHz5 => Radio5GHzLabels, - WiFiRadio.GHz6 => Radio6GHzLabels, - _ => [] + 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})" + : $"{network.AvailableNetwork.Ssid} ({network.AvailableNetwork.Bssid})"; + + var series = 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)) }; - var index = Array.IndexOf(radioLabels, $"{network.Channel}"); + 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 new LineSeries + return (series, legend); + } + + /// + /// 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 + // Channel 14 is at 2484 MHz and does not follow the standard ch×5 + 2407 spacing. + WiFiRadio.GHz2dot4 => (int)Math.Round(frequencyMHz) == 2484 ? 14.0 : (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 +1225,7 @@ private Task Connect() ConfigurationManager.Current.IsChildWindowOpen = true; - return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); + _ = Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } private async void Disconnect() @@ -976,4 +1335,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/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 030faf1ecc..c0bfa3d076 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" @@ -28,6 +28,7 @@ + - - + @@ -267,7 +268,7 @@ + - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -890,7 +1005,7 @@ - +