From 173bfc40a9a2180d1a2f31dadfb46b8149dd499c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 28 Jul 2026 14:08:46 +0200 Subject: [PATCH] feat(agent): multi-language label support in netsh WiFi fallback The netsh fallback previously matched English labels only, silently dropping fields on localized OS images. The parser now matches the label variants of the languages commonly seen in enrollments (EN/DE/FR/ES/PT/IT/NL/PL/CS/TR; Scandinavian locales reuse the EN/DE spellings) and is split into a pure Parse(output) function covered by unit tests (EN/DE/ES samples, "94 %" spacing, AP-BSSID collision, unknown-language degradation - SSID itself is untranslated in all locales). Unknown labels still fall through harmlessly; the native WLAN API remains the primary path. V2.Core suite 2095/2095 green; fallback live-verified against netsh on dev hardware (identical values to the API path). Co-Authored-By: Claude Fable 5 --- .../Interop/NetshWifiFallbackTests.cs | 133 +++++++++++++++ .../Monitoring/Interop/NetshWifiFallback.cs | 161 +++++++++++++----- 2 files changed, 247 insertions(+), 47 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/NetshWifiFallbackTests.cs 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; + } } }