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;
+ }
}
}