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
编辑完成后保存即可。
好像任务参数一只错误,为什么?
系统提示以下错误信息,是否可帮忙看下是什么问题,谢谢!
无法将“SetStateAsync”的参数“value”(其值为“”)转换为类型“Windows.Devices.Radios.RadioState”:“无法将值“”转换为
类型“Windows.Devices.Radios.RadioState”。错误:“无法处理标识符名称 ,因为该名称与以下枚举器名称相同或非常类似: Unknow
n, On, Off, Disabled。请使用更具体的标识符名称。””
所在位置 C:\Set-BluetoothStatus.ps1:82 字符: 1
+ Await ($bluetooth.SetStateAsync($BluetoothStatus)) ([Windows.Devices. …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodException
+ FullyQualifiedErrorId : MethodArgumentConversionInvalidCastArgument
唐完了,我现在就有这个问题,Surface Pro 8,盒盖后随机挑时间跟我的手机抢蓝牙耳机
我本来也是用的合盖前关蓝牙这个方法,但是更唐的事情是Win11这个啥必控制中心开关有bug,经常性点不了,甚至设置App里蓝牙的部分也有bug,,,受不了了
关于待机播放音乐,现在真的有软件适配了Modern Standby吗?我用下来也就Apple Music,LyricEase和系统内置的媒体播放器支持待机播放,老版UWP网易云只能合盖播完第一首,Win32合盖直接死,,
微软到底是怎么用尽全力做出这么逆天的系统的
同为pro 8,真的唐完了微软,把用户的智商按在地上搓。LyricEase现在也用不了了,网易云收紧登录验证了还封我号 真的唐完了
感谢大爹指导 微软真的唐完了搓出来这么个血压高的玩意 甜度爆表