mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c3d29f30b | |||
| bb4accfe96 |
+97
@@ -0,0 +1,97 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the native-WLAN-API WiFi reader. The WLAN state of the test machine is
|
||||
/// unknown (CI runners/VMs have no WLAN service), so the live path only pins the
|
||||
/// fail-soft contract; the pure helpers (SSID decode, PHY-type mapping) are pinned
|
||||
/// exactly — their output feeds wifi_signal_info event payloads and must stay
|
||||
/// consistent with the strings netsh historically reported.
|
||||
/// </summary>
|
||||
public sealed class WifiInfoReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGetCurrentConnection_never_throws_and_returns_sane_values()
|
||||
{
|
||||
var result = WifiInfoReader.TryGetCurrentConnection();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
Assert.InRange(result.SignalPercent, 0, 100);
|
||||
if (result.Channel.HasValue)
|
||||
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- DecodeSsid ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_decodes_utf8_bytes()
|
||||
{
|
||||
var raw = new byte[32];
|
||||
var ssid = Encoding.UTF8.GetBytes("Contoso-Büro");
|
||||
ssid.CopyTo(raw, 0);
|
||||
|
||||
Assert.Equal("Contoso-Büro", WifiInfoReader.DecodeSsid(raw, (uint)ssid.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_returns_null_for_empty_or_missing()
|
||||
{
|
||||
Assert.Null(WifiInfoReader.DecodeSsid(null, 5));
|
||||
Assert.Null(WifiInfoReader.DecodeSsid(new byte[32], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_clamps_length_to_buffer_size()
|
||||
{
|
||||
var raw = Encoding.UTF8.GetBytes("abc");
|
||||
|
||||
// A corrupt native length beyond the buffer must not throw.
|
||||
Assert.Equal("abc", WifiInfoReader.DecodeSsid(raw, 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_uses_only_declared_length()
|
||||
{
|
||||
var raw = new byte[32];
|
||||
Encoding.UTF8.GetBytes("HomeNet-Rest-Ignored").CopyTo(raw, 0);
|
||||
|
||||
Assert.Equal("HomeNet", WifiInfoReader.DecodeSsid(raw, 7));
|
||||
}
|
||||
|
||||
// ----- PhyTypeToRadioType -------------------------------------------
|
||||
|
||||
// int parameters (cast inside): DOT11_PHY_TYPE is internal and may not appear in a
|
||||
// public test method signature (CS0051).
|
||||
[Theory]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ofdm, "802.11a")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_hrdsss, "802.11b")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_erp, "802.11g")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ht, "802.11n")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_vht, "802.11ac")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dmg, "802.11ad")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_he, "802.11ax")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_eht, "802.11be")]
|
||||
public void PhyTypeToRadioType_maps_known_generations(int phyType, string expected)
|
||||
{
|
||||
Assert.Equal(expected, WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_unknown)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_fhss)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dsss)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_irbaseband)]
|
||||
[InlineData(9999)]
|
||||
public void PhyTypeToRadioType_returns_null_for_legacy_or_unknown(int phyType)
|
||||
{
|
||||
Assert.Null(WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Current WiFi connection details as read from the native WLAN API.
|
||||
/// </summary>
|
||||
public sealed class WifiConnectionInfo
|
||||
{
|
||||
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
|
||||
public string Ssid { get; internal set; }
|
||||
|
||||
/// <summary>Signal quality 0–100 (same semantics as the netsh "Signal" percentage).</summary>
|
||||
public int SignalPercent { get; internal set; }
|
||||
|
||||
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
|
||||
public string RadioType { get; internal set; }
|
||||
|
||||
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
|
||||
public int? Channel { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft reader over <see cref="WlanNativeMethods"/>: returns the current WiFi
|
||||
/// connection or null — never throws, never blocks on a child process. Machines without
|
||||
/// the WLAN AutoConfig service (VMs, wired-only devices) simply return null.
|
||||
/// </summary>
|
||||
public static class WifiInfoReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the current WiFi connection. When <paramref name="preferredInterfaceId"/> is
|
||||
/// given (the adapter GUID from <c>NetworkInterface.Id</c>), that interface is queried
|
||||
/// first; otherwise — or when it is not connected — the first connected WLAN interface
|
||||
/// wins. Returns null when no interface has an active connection.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
|
||||
{
|
||||
var clientHandle = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle) != ERROR_SUCCESS)
|
||||
return null;
|
||||
|
||||
var interfaceList = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList) != ERROR_SUCCESS)
|
||||
return null;
|
||||
|
||||
var count = (uint)Marshal.ReadInt32(interfaceList);
|
||||
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
|
||||
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
|
||||
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
|
||||
|
||||
WifiConnectionInfo fallback = null;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
|
||||
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
|
||||
|
||||
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid);
|
||||
if (connection == null)
|
||||
continue;
|
||||
|
||||
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
|
||||
return connection;
|
||||
|
||||
if (fallback == null)
|
||||
fallback = connection;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (interfaceList != IntPtr.Zero)
|
||||
WlanFreeMemory(interfaceList);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (clientHandle != IntPtr.Zero)
|
||||
WlanCloseHandle(clientHandle, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
|
||||
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
|
||||
return null;
|
||||
|
||||
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
|
||||
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
|
||||
|
||||
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
|
||||
return null;
|
||||
|
||||
var assoc = attributes.wlanAssociationAttributes;
|
||||
return new WifiConnectionInfo
|
||||
{
|
||||
Ssid = DecodeSsid(assoc.dot11Ssid.ucSSID, assoc.dot11Ssid.uSSIDLength),
|
||||
SignalPercent = (int)Math.Min(assoc.wlanSignalQuality, 100),
|
||||
RadioType = PhyTypeToRadioType(assoc.dot11PhyType),
|
||||
Channel = QueryChannel(clientHandle, interfaceGuid),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dataPtr != IntPtr.Zero)
|
||||
WlanFreeMemory(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private static int? QueryChannel(IntPtr clientHandle, Guid interfaceGuid)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_channel_number,
|
||||
IntPtr.Zero, out var dataSize, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
|
||||
return null;
|
||||
|
||||
if (dataSize < sizeof(uint) || dataPtr == IntPtr.Zero)
|
||||
return null;
|
||||
|
||||
return Marshal.ReadInt32(dataPtr);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dataPtr != IntPtr.Zero)
|
||||
WlanFreeMemory(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a raw DOT11_SSID (UTF-8 by convention) into a string; null for empty SSIDs.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static string DecodeSsid(byte[] raw, uint length)
|
||||
{
|
||||
if (raw == null || length == 0)
|
||||
return null;
|
||||
|
||||
var byteCount = (int)Math.Min(length, (uint)raw.Length);
|
||||
try
|
||||
{
|
||||
var ssid = Encoding.UTF8.GetString(raw, 0, byteCount);
|
||||
return string.IsNullOrEmpty(ssid) ? null : ssid;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a DOT11 PHY type to the same strings netsh historically reported as
|
||||
/// "Radio type" — keeps event payloads consistent across the netsh→WLAN-API switch.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static string PhyTypeToRadioType(DOT11_PHY_TYPE phyType)
|
||||
{
|
||||
switch (phyType)
|
||||
{
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_ofdm: return "802.11a";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_hrdsss: return "802.11b";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_erp: return "802.11g";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_ht: return "802.11n";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_vht: return "802.11ac";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_dmg: return "802.11ad";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_he: return "802.11ax";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_eht: return "802.11be";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Native WLAN API interop (<c>wlanapi.dll</c>) used to read the current WiFi connection
|
||||
/// (SSID, signal quality, PHY type, channel) without spawning <c>netsh</c>.
|
||||
/// <para>
|
||||
/// The WLAN API is language-neutral — unlike parsing <c>netsh wlan show interfaces</c>
|
||||
/// output, whose labels follow the OS UI language and silently drop fields on
|
||||
/// non-English images. On machines without the WLAN AutoConfig service (VMs, servers,
|
||||
/// wired-only devices) <c>WlanOpenHandle</c>/<c>WlanEnumInterfaces</c> fail with a
|
||||
/// non-zero error code; callers treat that as "no WiFi" (see <see cref="WifiInfoReader"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class WlanNativeMethods
|
||||
{
|
||||
internal const uint ERROR_SUCCESS = 0;
|
||||
|
||||
/// <summary>Client version 2 = Vista and later (the only relevant range).</summary>
|
||||
internal const uint WLAN_CLIENT_VERSION = 2;
|
||||
|
||||
internal enum WLAN_INTF_OPCODE : uint
|
||||
{
|
||||
wlan_intf_opcode_current_connection = 7,
|
||||
wlan_intf_opcode_channel_number = 8,
|
||||
}
|
||||
|
||||
internal enum WLAN_INTERFACE_STATE
|
||||
{
|
||||
wlan_interface_state_not_ready = 0,
|
||||
wlan_interface_state_connected = 1,
|
||||
wlan_interface_state_ad_hoc_network_formed = 2,
|
||||
wlan_interface_state_disconnecting = 3,
|
||||
wlan_interface_state_disconnected = 4,
|
||||
wlan_interface_state_associating = 5,
|
||||
wlan_interface_state_discovering = 6,
|
||||
wlan_interface_state_authenticating = 7,
|
||||
}
|
||||
|
||||
internal enum DOT11_PHY_TYPE : uint
|
||||
{
|
||||
dot11_phy_type_unknown = 0,
|
||||
dot11_phy_type_fhss = 1,
|
||||
dot11_phy_type_dsss = 2,
|
||||
dot11_phy_type_irbaseband = 3,
|
||||
dot11_phy_type_ofdm = 4, // 802.11a
|
||||
dot11_phy_type_hrdsss = 5, // 802.11b
|
||||
dot11_phy_type_erp = 6, // 802.11g
|
||||
dot11_phy_type_ht = 7, // 802.11n
|
||||
dot11_phy_type_vht = 8, // 802.11ac
|
||||
dot11_phy_type_dmg = 9, // 802.11ad
|
||||
dot11_phy_type_he = 10, // 802.11ax
|
||||
dot11_phy_type_eht = 11, // 802.11be
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WLAN_INTERFACE_INFO
|
||||
{
|
||||
public Guid InterfaceGuid;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strInterfaceDescription;
|
||||
public WLAN_INTERFACE_STATE isState;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct DOT11_SSID
|
||||
{
|
||||
public uint uSSIDLength;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] ucSSID;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WLAN_ASSOCIATION_ATTRIBUTES
|
||||
{
|
||||
public DOT11_SSID dot11Ssid;
|
||||
public int dot11BssType;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
|
||||
public byte[] dot11Bssid;
|
||||
public DOT11_PHY_TYPE dot11PhyType;
|
||||
public uint uDot11PhyIndex;
|
||||
public uint wlanSignalQuality; // 0..100, same semantics as netsh "Signal"
|
||||
public uint ulRxRate;
|
||||
public uint ulTxRate;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WLAN_SECURITY_ATTRIBUTES
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool bSecurityEnabled;
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool bOneXEnabled;
|
||||
public int dot11AuthAlgorithm;
|
||||
public int dot11CipherAlgorithm;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WLAN_CONNECTION_ATTRIBUTES
|
||||
{
|
||||
public WLAN_INTERFACE_STATE isState;
|
||||
public int wlanConnectionMode;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strProfileName;
|
||||
public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes;
|
||||
public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes;
|
||||
}
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanOpenHandle(
|
||||
uint dwClientVersion,
|
||||
IntPtr pReserved,
|
||||
out uint pdwNegotiatedVersion,
|
||||
out IntPtr phClientHandle);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanCloseHandle(
|
||||
IntPtr hClientHandle,
|
||||
IntPtr pReserved);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanEnumInterfaces(
|
||||
IntPtr hClientHandle,
|
||||
IntPtr pReserved,
|
||||
out IntPtr ppInterfaceList);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanQueryInterface(
|
||||
IntPtr hClientHandle,
|
||||
[In] ref Guid pInterfaceGuid,
|
||||
WLAN_INTF_OPCODE OpCode,
|
||||
IntPtr pReserved,
|
||||
out uint pdwDataSize,
|
||||
out IntPtr ppData,
|
||||
IntPtr pWlanOpcodeValueType);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern void WlanFreeMemory(IntPtr pMemory);
|
||||
}
|
||||
}
|
||||
+25
-65
@@ -815,7 +815,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// Collects active network interface details (name, type, link speed, MAC) using
|
||||
/// System.Net.NetworkInformation — instant, no process spawn.
|
||||
/// If the active interface is WiFi, fires off a separate async task to collect
|
||||
/// WiFi signal info via netsh (fire-and-forget, never blocks).
|
||||
/// WiFi signal info via the native WLAN API (fire-and-forget, never blocks).
|
||||
/// </summary>
|
||||
private void CollectActiveNetworkInterfaceInfo()
|
||||
{
|
||||
@@ -858,7 +858,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Fire-and-forget WiFi signal collection — separate event, never blocks
|
||||
if (isWifi)
|
||||
{
|
||||
Task.Run(() => CollectWiFiSignalInfo());
|
||||
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
|
||||
Task.Run(() => CollectWiFiSignalInfo(nicGuid));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -899,78 +900,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
|
||||
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
|
||||
/// never blocks other collection. VMs without WiFi service simply get no event.
|
||||
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
|
||||
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
|
||||
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
|
||||
/// VMs without WiFi service simply get no event.
|
||||
/// </summary>
|
||||
private void CollectWiFiSignalInfo()
|
||||
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid);
|
||||
if (wifi == null)
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
["wifiSignalPercent"] = wifi.SignalPercent
|
||||
};
|
||||
if (wifi.Ssid != null)
|
||||
data["wifiSsid"] = wifi.Ssid;
|
||||
if (wifi.RadioType != null)
|
||||
data["wifiRadioType"] = wifi.RadioType;
|
||||
if (wifi.Channel.HasValue)
|
||||
data["wifiChannel"] = wifi.Channel.Value;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
var message = wifi.Ssid != null
|
||||
? $"WiFi: {wifi.Ssid}, Signal: {wifi.SignalPercent}%"
|
||||
: $"WiFi signal info, Signal: {wifi.SignalPercent}%";
|
||||
if (wifi.RadioType != null)
|
||||
message += $" ({wifi.RadioType})";
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiSsid"] = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var signal))
|
||||
data["wifiSignalPercent"] = signal;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiRadioType"] = value;
|
||||
}
|
||||
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value, out var channel))
|
||||
data["wifiChannel"] = channel;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
var message = data.ContainsKey("wifiSsid")
|
||||
? $"WiFi: {data["wifiSsid"]}"
|
||||
: "WiFi signal info";
|
||||
if (data.ContainsKey("wifiSignalPercent"))
|
||||
message += $", Signal: {data["wifiSignalPercent"]}%";
|
||||
if (data.ContainsKey("wifiRadioType"))
|
||||
message += $" ({data["wifiRadioType"]})";
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
|
||||
}
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+11
-60
@@ -8,8 +8,8 @@ using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -37,7 +37,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private const int DebounceDelayMs = 3000;
|
||||
private const int ConnectivityCheckDelayMs = 5000;
|
||||
private const int ConnectivityCheckTimeoutMs = 8000;
|
||||
private const int WifiCollectTimeoutMs = 3000;
|
||||
private const string Source = "NetworkChangeDetector";
|
||||
|
||||
/// <summary>
|
||||
@@ -374,7 +373,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Collect WiFi SSID synchronously (needed for before/after comparison)
|
||||
if (isWifi)
|
||||
{
|
||||
var wifiInfo = CollectWiFiInfo();
|
||||
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
|
||||
var wifiInfo = CollectWiFiInfo(nicGuid);
|
||||
snapshot.WifiSsid = wifiInfo.ssid;
|
||||
snapshot.WifiSignalPercent = wifiInfo.signal;
|
||||
snapshot.WifiRadioType = wifiInfo.radioType;
|
||||
@@ -390,73 +390,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
|
||||
/// Synchronous with timeout — needed before emitting the event.
|
||||
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
|
||||
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
|
||||
/// Synchronous — needed before emitting the event.
|
||||
/// </summary>
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
|
||||
{
|
||||
string ssid = null;
|
||||
int? signal = null;
|
||||
string radioType = null;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(WifiCollectTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Debug("NetworkChangeDetector: netsh timed out");
|
||||
return (null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return (null, null, 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) &&
|
||||
!key.Equals("BSSID", 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;
|
||||
}
|
||||
}
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid);
|
||||
if (wifi != null)
|
||||
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return (ssid, signal, radioType);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -35,9 +35,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>C:\Windows\System32\tzutil.exe</summary>
|
||||
public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\netsh.exe</summary>
|
||||
public static readonly string Netsh = Path.Combine(System32, "netsh.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\schtasks.exe</summary>
|
||||
public static readonly string Schtasks = Path.Combine(System32, "schtasks.exe");
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const ALL_COLUMNS: ColumnDef[] = [
|
||||
{ key: "duration", label: "Duration", defaultVisible: true, sortKey: "durationSeconds" },
|
||||
{ key: "started", label: "Started", defaultVisible: true, sortKey: "startedAt" },
|
||||
{ key: "country", label: "Country", defaultVisible: false, sortKey: "geoCountry", filterKey: "geoCountry" },
|
||||
{ key: "apiLatency", label: "API Latency", defaultVisible: false, sortKey: "avgApiLatencyMs" },
|
||||
{ key: "agentVersion", label: "Agent Version", defaultVisible: false, sortKey: "agentVersion", filterKey: "agentVersion" },
|
||||
{ key: "osName", label: "OS Name", defaultVisible: false, sortKey: "osName", filterKey: "osName" },
|
||||
{ key: "osBuild", label: "OS Build", defaultVisible: false, sortKey: "osBuild", filterKey: "osBuild" },
|
||||
@@ -640,7 +641,7 @@ export function SessionTable({
|
||||
currentSort={sortColumn}
|
||||
direction={sortDirection}
|
||||
onSort={onSort}
|
||||
className={["eventCount", "duration", "started", "country", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined}
|
||||
className={["eventCount", "duration", "started", "country", "apiLatency", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined}
|
||||
filterKey={col.filterKey}
|
||||
filterValues={col.filterKey ? uniqueValuesByField[col.filterKey] : undefined}
|
||||
activeFilter={col.filterKey ? columnFilters[col.filterKey] : undefined}
|
||||
@@ -904,6 +905,19 @@ function SessionCell({
|
||||
</td>
|
||||
);
|
||||
|
||||
case "apiLatency":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{(session.avgApiLatencyMs ?? 0) > 0 ? (
|
||||
<span title={`Agent→backend HTTP round-trip${session.apiRequestCount ? ` across ${session.apiRequestCount} requests` : ""}`}>
|
||||
{Math.round(session.avgApiLatencyMs!)} ms
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "agentVersion": {
|
||||
const fullVersion = session.agentVersion || "";
|
||||
const shortVersion = fullVersion.split("+")[0];
|
||||
|
||||
Reference in New Issue
Block a user