动手解决 Windows 10 恼人的蓝牙自动连接问题

Windows 的蓝牙子系统自古以来就让人一言难尽,在 Windows 里属于最难用的那一类,难用到了一定境界的功能。长期地使用 Windows 让我不禁感叹,Windows 的 PM 可以没脑子,但用户不能没有。不仅如此,Windows 的开发者可以写不好代码,但是用户必须具备定位问题、查找文档、编写代码等解决问题的能力。

本来我是非常不愿意拿这种细枝末节的事情水一篇文章,不过考虑到可能有很多人在这种问题上浪费时间,我就还是把我的解决方案分享出来了。

本文讲述如何解决 Windows 在现代待机 (aka. Modern Standby, Connected Standby, S0ix) 的 PC 上随意抢夺蓝牙设备的问题。

起因

今年6月,我购入了一台 S3 睡眠的笔记本电脑。当时我遇到一件让人非常烦恼的事,那就是每当我将笔记本电脑的电源打开,或者将其从睡眠状态唤醒时,它都会去自动尝试连接我所有的蓝牙设备。如果此时我正在用无线耳机连接手机或者其它 PC 听音乐,那么 Windows 会自动连接耳机,从手机上抢走这个设备。Windows 可以抢走设备,是因为我在耳机上启用了“Seamless connection” (aka. 无缝连接)。这个功能对于需要在多个设备上使用耳机的用户来说非常友好,不需要先从前一个设备上断开连接,即可在后一个设备上直接连接使用。

无缝连接

在手机上,无缝连接的行为是,在任意一个已配对的手机上可以手动连接耳机,也就是可以手动用一个设备从另一个设备上抢走蓝牙耳机的连接。当耳机开启时,它会自动连接到上次连接的设备。实际上,在 Windows 设备上,它的行为也是类似的。唯一的不同就是上面所述的问题,Windows 在重启、唤醒、或者手动将蓝牙从关闭状态切换为开启状态后会自动尝试去连接这个蓝牙设备。

对于我来说,这其实本来并不是一个非常致命的问题,所以我也并没有去想着解决这个问题。只是我会更加小心地打开我的笔记本电脑,在打开之前确保我没有用耳机做什么重要的事情(例如电话会议等)。

但是后来这个事情发生了一些变化。出于一些其它原因,我换了一台 S0ix 现代待机的笔记本电脑。当时我以为在现代待机的笔记本电脑上 Windows 并不会有唤醒后抢夺蓝牙设备的问题,因为现代待机更类似手机待机,睡眠状态可以保持蓝牙处于连接状态。后来发现 Windows 确实不会在唤醒时抢夺蓝牙设备,只是问题变得更严重了:Windows 会在睡眠时的任意时间尝试连接蓝牙设备,这意味着只要笔记本电脑处于睡眠状态,并且耳机在笔记本电脑的蓝牙信号范围内,耳机就有可能被抢走。于是这就从一个无关紧要的问题变成了一个必须解决的问题。

解决方法

事实上简单 Google 一下可以发现很多人都提出了类似的问题,并且有人给出的解决方案是取消配对。但这个对于每天需要经常切换设备的使用场景,显然是不现实的。

不过解决这个问题的思路其实很非常简单粗暴,我们只需要在系统进入现代待机时设法将蓝牙自动关闭就行了,也就是模拟普通笔记本待机的行为。本身笔记本电脑待机时蓝牙就没有什么太大的用处,而真正需要使用蓝牙时,使用 Win-A 快捷键打开 Action Center 再轻轻点一下蓝牙开关就会使得 Windows 自动连接蓝牙设备,比安卓设备切换蓝牙还要方便。

但是单纯地关闭蓝牙会导致一些问题,例如现代待机设备的一个大优势是可以待机听音乐。如果我们粗暴地无条件将蓝牙关闭,会导致这一功能无法使用,甚至变成合上笔记本电脑之后由蓝牙耳机变成扬声器外放。因此我们需要先做一些判断,仅当没有音乐正在播放时才关闭蓝牙。

简单观察可以发现,Windows 进出现代待机时,会留下一个 event log,内容如下:

那么我们可以简单地拼凑一些从各种地方抄来的常见的 PowerShell 脚本,低成本地完成这个工作,并且使用 Windows 的 Task Scheduler (taskschd.msc) 监听现代待机的事件,以自动触发运行脚本。

将以下内容保存为 C:\Set-BluetoothStatus.ps1 (gist)

Param (
    [string]$BluetoothStatus
)

Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;

public class MediaHelper
{
    public static bool IsPlayingSound()
    {
        IMMDeviceEnumerator enumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
        IMMDevice speakers = enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia);
        IAudioMeterInformation meter = (IAudioMeterInformation)speakers.Activate(typeof(IAudioMeterInformation).GUID, 0, IntPtr.Zero);
        float value = meter.GetPeakValue();
        return value > 1E-08;
    }

    [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
    private class MMDeviceEnumerator
    {
    }

    private enum EDataFlow
    {
        eRender,
        eCapture,
        eAll,
    }

    private enum ERole
    {
        eConsole,
        eMultimedia,
        eCommunications,
    }

    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
    private interface IMMDeviceEnumerator
    {
        void NotNeeded();
        IMMDevice GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role);
        // the rest is not defined/needed
    }

    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("D666063F-1587-4E43-81F1-B948E807363F")]
    private interface IMMDevice
    {
        [return: MarshalAs(UnmanagedType.IUnknown)]
        object Activate([MarshalAs(UnmanagedType.LPStruct)] Guid iid, int dwClsCtx, IntPtr pActivationParams);
        // the rest is not defined/needed
    }

    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064")]
    private interface IAudioMeterInformation
    {
        float GetPeakValue();
        // the rest is not defined/needed
    }
}
'@

if ([MediaHelper]::IsPlayingSound()) { return; }

If ((Get-Service bthserv).Status -eq 'Stopped') { Start-Service bthserv }
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | ? { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
Function Await($WinRtTask, $ResultType) {
    $asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
    $netTask = $asTask.Invoke($null, @($WinRtTask))
    $netTask.Wait(-1) | Out-Null
    $netTask.Result
}

[Windows.Devices.Radios.Radio,Windows.System.Devices,ContentType=WindowsRuntime] | Out-Null
[Windows.Devices.Radios.RadioAccessStatus,Windows.System.Devices,ContentType=WindowsRuntime] | Out-Null
Await ([Windows.Devices.Radios.Radio]::RequestAccessAsync()) ([Windows.Devices.Radios.RadioAccessStatus]) | Out-Null
$radios = Await ([Windows.Devices.Radios.Radio]::GetRadiosAsync()) ([System.Collections.Generic.IReadOnlyList[Windows.Devices.Radios.Radio]])
$bluetooth = $radios | ? { $_.Kind -eq 'Bluetooth' }
[Windows.Devices.Radios.RadioState,Windows.System.Devices,ContentType=WindowsRuntime] | Out-Null
Await ($bluetooth.SetStateAsync($BluetoothStatus)) ([Windows.Devices.Radios.RadioAccessStatus]) | Out-Null

前往 Task Scheduler 新建一个 Basic Task,名称任意,选择 event trigger

Log 选择“System”,Source 选择“Kernel-Power”,Event ID 填写 506

Action 选择“Start a program”

Program/script 填写powershell.exe,Add arguments 填写-File C:\Set-BluetoothStatus.ps1 -BluetoothStatus off

完成 Task 创建后,打开 Task 属性,选择 Run only when user is logged on,并勾选最高权限

取消勾选 Conditions 页面的所有 checkbox

编辑完成后保存即可。

发表评论

电子邮件地址不会被公开。 必填项已用*标注