diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/NetshWifiFallbackTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/NetshWifiFallbackTests.cs new file mode 100644 index 00000000..26255db1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/NetshWifiFallbackTests.cs @@ -0,0 +1,133 @@ +#nullable enable +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop +{ + /// + /// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow + /// the OS UI language — the parser must extract SSID/signal/radio/channel from the label + /// variants of the languages commonly seen in enrollments, and degrade gracefully (fields + /// absent, never throw) on unrecognized ones. + /// + public sealed class NetshWifiFallbackTests + { + [Fact] + public void Parse_english_output_extracts_all_fields() + { + const string output = @" +There is 1 interface on the system: + + Name : Wi-Fi + Description : Intel(R) Wi-Fi 6 AX201 160MHz + GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6 + State : connected + SSID : contoso-corp + AP BSSID : 3c:37:12:53:c8:44 + Band : 5 GHz + Channel : 116 + Radio type : 802.11ax + Signal : 94% +"; + var r = NetshWifiFallback.Parse(output); + + Assert.NotNull(r); + Assert.Equal("contoso-corp", r!.Ssid); + Assert.Equal(94, r.SignalPercent); + Assert.Equal("802.11ax", r.RadioType); + Assert.Equal(116, r.Channel); + } + + [Fact] + public void Parse_german_output_extracts_all_fields() + { + const string output = @" +Es ist 1 Schnittstelle auf dem System vorhanden: + + Name : WLAN + Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz + Status : Verbunden + SSID : fabrikam-wlan + Kanal : 44 + Funktyp : 802.11ac + Signal : 87% +"; + var r = NetshWifiFallback.Parse(output); + + Assert.NotNull(r); + Assert.Equal("fabrikam-wlan", r!.Ssid); + Assert.Equal(87, r.SignalPercent); + Assert.Equal("802.11ac", r.RadioType); + Assert.Equal(44, r.Channel); + } + + [Fact] + public void Parse_spanish_output_extracts_signal_and_channel() + { + const string output = @" + Nombre : Wi-Fi + SSID : contoso-es + Canal : 6 + Tipo de radio : 802.11n + Señal : 72% +"; + var r = NetshWifiFallback.Parse(output); + + Assert.NotNull(r); + Assert.Equal("contoso-es", r!.Ssid); + Assert.Equal(72, r.SignalPercent); + Assert.Equal("802.11n", r.RadioType); + Assert.Equal(6, r.Channel); + } + + [Fact] + public void Parse_signal_with_space_before_percent_is_parsed() + { + var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n"); + + Assert.NotNull(r); + Assert.Equal(81, r!.SignalPercent); + } + + [Fact] + public void Parse_ap_bssid_does_not_overwrite_ssid() + { + const string output = @" + SSID : contoso-corp + AP BSSID : aa:bb:cc:dd:ee:ff +"; + var r = NetshWifiFallback.Parse(output); + + Assert.Equal("contoso-corp", r!.Ssid); + } + + [Fact] + public void Parse_unknown_language_without_ssid_returns_null() + { + // No recognizable labels at all -> nothing to report. + var r = NetshWifiFallback.Parse(" Yhteystila : yhdistetty\r\n Taajuus : 5 GHz\r\n"); + + Assert.Null(r); + } + + [Fact] + public void Parse_unknown_language_still_yields_ssid() + { + // SSID is untranslated in all locales — partial result beats none. + var r = NetshWifiFallback.Parse(" SSID : contoso-fi\r\n Signaali : 90%\r\n"); + + Assert.NotNull(r); + Assert.Equal("contoso-fi", r!.Ssid); + Assert.Null(r.SignalPercent); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")] + public void Parse_empty_or_error_output_returns_null(string? output) + { + Assert.Null(NetshWifiFallback.Parse(output)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs index e094db1c..cda58f19 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs @@ -10,18 +10,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop /// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM /// context on builds where inbox netsh still reports the connection — until that is fully /// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net. - /// Caveats of this path (the reason it is only a fallback): labels are localized — on - /// non-English images "Radio type"/"Channel" (sometimes "Signal") do not match — and it - /// spawns a child process. + /// + /// netsh labels follow the OS UI language, so the parser matches the label variants of + /// the languages commonly seen in enrollments (EN/DE/FR/ES/PT/IT/NL/PL/CS/TR + the + /// Scandinavian ones that reuse EN/DE spellings). Unknown-language labels fall through + /// harmlessly — the affected field is simply absent, same as before. "SSID" itself is + /// untranslated in all known locales. + /// /// internal static class NetshWifiFallback { private const int NetshTimeoutMs = 5000; + // Label variants per field, by OS UI language. Matched case-insensitively against the + // full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide. + private static readonly string[] SignalKeys = + { + "Signal", // en, de, fr, sv, da, nb + "Señal", // es + "Sinal", // pt + "Segnale", // it + "Signaal", // nl + "Sygnał", // pl + "Signál", // cs + "Sinyal", // tr + }; + + private static readonly string[] RadioTypeKeys = + { + "Radio type", // en + "Funktyp", // de + "Type de radio", // fr + "Tipo de radio", // es + "Tipo de rádio", // pt + "Tipo di radio", // it + "Radiotype", // nl + "Typ radia", // pl + "Radyo türü", // tr + }; + + private static readonly string[] ChannelKeys = + { + "Channel", // en + "Kanal", // de, sv, da, nb, tr + "Canal", // fr, es, pt + "Canale", // it + "Kanaal", // nl + "Kanał", // pl + "Kanál", // cs + }; + /// - /// Parses netsh wlan show interfaces. Returns a - /// with the fields that could be parsed, or null when nothing was found (no WLAN, netsh - /// failure, timeout, or fully localized labels). Never throws. + /// Runs netsh wlan show interfaces and parses its output. Returns a + /// with the fields that could be parsed, or null when + /// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized + /// labels). Never throws. /// internal static WifiConnectionInfo TryRead() { @@ -48,52 +91,76 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop } } - if (string.IsNullOrEmpty(output)) - return null; - - string ssid = null, radioType = null; - int? signal = null, channel = null; - - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - var trimmed = line.Trim(); - var colonIndex = trimmed.IndexOf(':'); - if (colonIndex < 0) continue; - - var key = trimmed.Substring(0, colonIndex).Trim(); - var value = trimmed.Substring(colonIndex + 1).Trim(); - - if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase)) - ssid = value; - else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase)) - { - if (int.TryParse(value.TrimEnd('%'), out var sig)) - signal = sig; - } - else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase)) - radioType = value; - else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase)) - { - if (int.TryParse(value, out var ch)) - channel = ch; - } - } - - if (ssid == null && signal == null && radioType == null && channel == null) - return null; - - return new WifiConnectionInfo - { - Ssid = ssid, - SignalPercent = signal, - RadioType = radioType, - Channel = channel, - }; + return Parse(output); } catch { return null; } } + + /// + /// Pure parser over the netsh output text. Internal for direct unit-testing via + /// InternalsVisibleTo (TryRead itself spawns a process and stays untested). + /// + internal static WifiConnectionInfo Parse(string output) + { + if (string.IsNullOrEmpty(output)) + return null; + + string ssid = null, radioType = null; + int? signal = null, channel = null; + + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = line.Trim(); + var colonIndex = trimmed.IndexOf(':'); + if (colonIndex < 0) continue; + + var key = trimmed.Substring(0, colonIndex).Trim(); + var value = trimmed.Substring(colonIndex + 1).Trim(); + + if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase)) + { + ssid = value; + } + else if (MatchesAny(key, SignalKeys)) + { + // Value formats seen: "94%", "94% " (trailing space), "94 %". + if (int.TryParse(value.TrimEnd('%', ' '), out var sig)) + signal = sig; + } + else if (MatchesAny(key, RadioTypeKeys)) + { + radioType = value; + } + else if (MatchesAny(key, ChannelKeys)) + { + if (int.TryParse(value, out var ch)) + channel = ch; + } + } + + if (ssid == null && signal == null && radioType == null && channel == null) + return null; + + return new WifiConnectionInfo + { + Ssid = ssid, + SignalPercent = signal, + RadioType = radioType, + Channel = channel, + }; + } + + private static bool MatchesAny(string key, string[] candidates) + { + foreach (var candidate in candidates) + { + if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } } }