Skip to content

Commit 9a084d6

Browse files
committed
Add functionality to Connect and Disconnect audio Bluetooth devices
1 parent 0fdff64 commit 9a084d6

23 files changed

+400
-62
lines changed

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ insert_final_newline = true
77
charset = utf-8
88
indent_style = space
99
indent_size = 4
10+
max_line_length = 150
1011

1112
[*.{xml,wxs,csproj,yaml,props,config}]
1213
indent_size = 2

.github/workflows/continuous-integration-workflow.yaml

Lines changed: 0 additions & 20 deletions
This file was deleted.

.github/workflows/main.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
on: push
2+
3+
jobs:
4+
build:
5+
runs-on: windows-2022
6+
steps:
7+
- uses: actions/checkout@v4
8+
- run: ./build.ps1
9+
- uses: softprops/action-gh-release@v2
10+
if: startsWith(github.ref, 'refs/tags/')
11+
with:
12+
draft: true
13+
files: build/Publish/*.zip
14+
- uses: actions/upload-artifact@v4
15+
with:
16+
name: Build artifacts
17+
path: build/Publish/*.zip

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
.vs/
33
/src/FodyWeavers.xsd
44
/.idea
5+
launchSettings.json

BluetoothDevicePairing.sln

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Files", "Files", "{F4EA3749
99
.editorconfig = .editorconfig
1010
.gitattributes = .gitattributes
1111
.gitignore = .gitignore
12-
.github\workflows\build.ps1 = .github\workflows\build.ps1
13-
.github\workflows\continuous-integration-workflow.yaml = .github\workflows\continuous-integration-workflow.yaml
12+
build.ps1 = build.ps1
13+
.github\workflows\main.yaml = .github\workflows\main.yaml
1414
README.md = README.md
1515
EndProjectSection
1616
EndProject

README.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# BluetoothDevicePairing
22
Console utility to discover and pair Bluetooth and Bluetooth Low Energy devices.
3-
4-
# Note on connecting to Bluetooth devices
5-
If you pair a device that is not already paired, the utility will also connect to it (this is the default behavior of Windows Bluetooth API)<br>
6-
However, the pairing will fail if a device is paired but not connected.<br>
7-
Unfortunately, it is impossible to simulate what the "Connect" button from Windows `Bluetooth and Other devices` dialog does.
8-
More details can be found here: [How to connect to a paired audio Bluetooth device](https://stackoverflow.com/questions/62502414/how-to-connect-to-a-paired-audio-bluetooth-device-using-windows-uwp-api). Specifically, [here](https://github.com/inthehand/32feet/issues/132#issuecomment-1019786324) I have described my failed attempts to implement this functionality.<br>
3+
The utility also allows to connect and disconnect from audio Bluetooth devices.
4+
* If you pair a device that is not already paired, the utility will also connect to it (this is the default behavior of Windows Bluetooth API)<br>
5+
* If you pair to an already paired audio Bluetooth device, the utility will connect to it.
6+
* Managing Bluetooth devices by mac is much faster than by name, because it doesn't require device discovery.
7+
* When managing Bluetooth devices by name, you can use the `--discovery-time` parameter to change the time spend on device discovery.
98

109
# System requirements
1110
Windows 10 1809 (10.0.17763) or higher
@@ -47,6 +46,14 @@ BluetoothDevicePairing.exe unpair-by-name --name "MX Ergo" --type BluetoothLE
4746
```
4847
BluetoothDevicePairing.exe list-adapters
4948
```
49+
* Disconnect an audio device using its Mac address:
50+
```
51+
BluetoothDevicePairing.exe disconnect-bluetooth-audio-device-by-name --name WH-1000XM5
52+
```
53+
* Disconnect to an audio device using its name:
54+
```
55+
BluetoothDevicePairing.exe disconnect-bluetooth-audio-device-by-mac --mac 88:c9:e8:17:5e:0f
56+
```
5057

5158
# Examples of scripts
5259
The BluetoothDevicePairing utility can be used in bat and PowerShell scripts.
@@ -70,11 +77,6 @@ if %ErrorLevel% NEQ 0 (
7077
)
7178
```
7279

73-
# How it works
74-
The program uses
75-
* [Windows.Devices.Enumeration API](https://docs.microsoft.com/en-us/uwp/api/Windows.Devices.Enumeration?redirectedfrom=MSDN&view=winrt-22000) to work with Bluetooth.
76-
* [Costura Fody](https://github.com/Fody/Costura) to create a single file executable.
77-
7880
### Device pairing by name
7981
To pair a device by name, the utility starts by discovering all available devices and tries to find a device with the required name. After a device is found, its Mac address is used to request pairing. The command will fail if there are several devices with the same name.
8082

@@ -83,8 +85,9 @@ If the command fails, it returns the value `-1`. If it succeeds, it returns `0`.
8385

8486
# Build
8587
* Use `Visual Studio 2022` to open the solution file and work with the code
86-
* Run `.github/workflows/build.ps1` to build a release (to run this script, `git.exe` should be in your PATH)
88+
* Run `build.ps1` to build a release (to run this script, `git.exe` should be in your PATH)
8789

8890
# References
8991
* [Windows.Devices.Enumeration API usage examples](https://github.com/microsoft/Windows-universal-samples/tree/master/Samples/DeviceEnumerationAndPairing)
9092
* [Windows.Devices.Enumeration Namespace](https://docs.microsoft.com/en-us/uwp/api/Windows.Devices.Enumeration)
93+
* [ToothTray](https://github.com/m2jean/ToothTray/tree/master) - reference implementation of connecting to Bluetooth audio devices
Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,29 +40,24 @@ Function ForceCopy($file, $dstFolder) {
4040
Copy-Item $buildResultExecutable -Destination $publishFolder -Force
4141
}
4242

43-
Function Build($slnFile, $version) {
44-
Info "Run 'dotnet build' command: `n slnFile=$slnFile `n version='$version'"
45-
46-
$Env:DOTNET_NOLOGO = "true"
47-
$Env:DOTNET_CLI_TELEMETRY_OPTOUT = "true"
48-
dotnet build `
49-
--configuration Release `
50-
/property:DebugType=None `
51-
/property:Version=$version `
52-
$slnFile
53-
CheckReturnCodeOfPreviousCommand "'dotnet build' command failed"
54-
}
55-
5643
Set-StrictMode -Version Latest
5744
$ErrorActionPreference = "Stop"
5845

59-
$root = Resolve-Path "$PSScriptRoot/../.."
46+
$root = Resolve-Path "$PSScriptRoot"
6047
$buildRoot = "$root/build"
6148
$buildResultsFolder = "$buildRoot/Release/net472"
6249
$projectName = "BluetoothDevicePairing"
6350
$buildResultExecutable = "$buildResultsFolder/$projectName.exe"
6451
$publishFolder = "$buildRoot/Publish"
52+
$version = GetVersion
53+
54+
Info "Run 'dotnet build'. version=$version"
55+
dotnet build `
56+
--configuration Release `
57+
/property:DebugType=None `
58+
/property:Version=$version `
59+
$root/BluetoothDevicePairing.sln
60+
CheckReturnCodeOfPreviousCommand "'dotnet build' command failed"
6561

66-
Build -slnFile $root/$projectName.sln -version (GetVersion)
6762
ForceCopy $buildResultExecutable $publishFolder
68-
CreateZipArchive "$publishFolder/${projectName}.exe" "$publishFolder/${projectName}.zip"
63+
CreateZipArchive $publishFolder/BluetoothDevicePairing.exe $publishFolder/BluetoothDevicePairing.zip
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using Vanara.PInvoke;
4+
using static Vanara.PInvoke.CoreAudio;
5+
6+
namespace BluetoothDevicePairing.Bluetooth.Devices.AudioDevices;
7+
8+
internal sealed class AudioDevice
9+
{
10+
private readonly IKsControl ksControl;
11+
12+
public string Name { get; }
13+
private string Id { get; }
14+
public Guid ContainerId { get; }
15+
public bool IsConnected { get; }
16+
17+
public AudioDevice(IMMDevice device, IKsControl ksControl)
18+
{
19+
this.ksControl = ksControl;
20+
21+
Id = device.GetId();
22+
IsConnected = device.GetState() == DEVICE_STATE.DEVICE_STATE_ACTIVE;
23+
24+
var propertyStore = device.OpenPropertyStore(STGM.STGM_READ);
25+
Name = (string)propertyStore.GetValue(DeviceProperties.PKEY_Device_FriendlyName);
26+
ContainerId = (Guid)propertyStore.GetValue(Ole32.PROPERTYKEY.System.Devices.ContainerId);
27+
}
28+
29+
public void Connect()
30+
{
31+
Console.WriteLine($"Request to connect audio device '{Name}'");
32+
GetKsProperty(KSPROPERTY_BTAUDIO.KSPROPERTY_ONESHOT_RECONNECT);
33+
}
34+
35+
public void Disconnect()
36+
{
37+
Console.WriteLine($"Request to disconnect audio device '{Name}'");
38+
GetKsProperty(KSPROPERTY_BTAUDIO.KSPROPERTY_ONESHOT_DISCONNECT);
39+
}
40+
41+
private void GetKsProperty(KSPROPERTY_BTAUDIO btAudioProperty)
42+
{
43+
var ksProperty = new KsProperty(KsPropertyId.KSPROPSETID_BtAudio, btAudioProperty, KsPropertyKind.KSPROPERTY_TYPE_GET);
44+
var dwReturned = 0;
45+
ksControl.KsProperty(ksProperty, Marshal.SizeOf(ksProperty), IntPtr.Zero, 0, ref dwReturned);
46+
}
47+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using static Vanara.PInvoke.CoreAudio;
2+
using System;
3+
using Vanara.PInvoke;
4+
using System.Collections.Generic;
5+
6+
namespace BluetoothDevicePairing.Bluetooth.Devices.AudioDevices;
7+
8+
internal static class AudioDeviceEnumerator
9+
{
10+
static readonly Dictionary<Guid, List<AudioDevice>> audioDevices = [];
11+
12+
static AudioDeviceEnumerator()
13+
{
14+
var audioEndpointsEnumerator = new IMMDeviceEnumerator();
15+
foreach (var audioEndPoint in EnumerateAudioEndpoints(audioEndpointsEnumerator))
16+
{
17+
foreach (var connector in EnumerateConnectors(audioEndPoint))
18+
{
19+
var connectedToPart = connector.TryGetConnectedToPart();
20+
if (connectedToPart is null)
21+
{
22+
continue;
23+
}
24+
25+
var connectedToDeviceId = (string)connectedToPart.GetTopologyObject().GetDeviceId();
26+
if (!connectedToDeviceId.StartsWith(@"{2}.\\?\bth"))
27+
{
28+
continue;
29+
}
30+
31+
var connectedToDevice = audioEndpointsEnumerator.GetDevice(connectedToDeviceId);
32+
var ksControl = Activate<IKsControl>(connectedToDevice);
33+
AddToDevicesDictionary(new AudioDevice(audioEndPoint, ksControl));
34+
}
35+
}
36+
}
37+
38+
public static IEnumerable<AudioDevice> GetAudioDevices(Guid containerId)
39+
{
40+
if (!audioDevices.TryGetValue(containerId, out var value))
41+
{
42+
return [];
43+
}
44+
45+
return value;
46+
}
47+
48+
private static void AddToDevicesDictionary(AudioDevice audioDevice)
49+
{
50+
if (!audioDevices.ContainsKey(audioDevice.ContainerId))
51+
{
52+
audioDevices[audioDevice.ContainerId] = [];
53+
}
54+
55+
audioDevices[audioDevice.ContainerId].Add(audioDevice);
56+
}
57+
58+
private static IEnumerable<IMMDevice> EnumerateAudioEndpoints(IMMDeviceEnumerator enumerator)
59+
{
60+
var deviceCollection = enumerator.EnumAudioEndpoints(EDataFlow.eAll, DEVICE_STATE.DEVICE_STATEMASK_ALL);
61+
for (uint i = 0; i < deviceCollection.GetCount(); i++)
62+
{
63+
deviceCollection.Item(i, out var device);
64+
yield return device;
65+
}
66+
}
67+
68+
private static IEnumerable<IConnector> EnumerateConnectors(IMMDevice audioEndPoint)
69+
{
70+
var topology = Activate<IDeviceTopology>(audioEndPoint);
71+
for (uint i = 0; i < topology.GetConnectorCount(); i++)
72+
{
73+
yield return topology.GetConnector(i);
74+
}
75+
}
76+
77+
private static IPart TryGetConnectedToPart(this IConnector connector)
78+
{
79+
try
80+
{
81+
return (IPart)connector.GetConnectedTo();
82+
}
83+
catch (Exception)
84+
{
85+
return null;
86+
}
87+
}
88+
89+
private static T Activate<T>(IMMDevice device)
90+
{
91+
device.Activate(typeof(T).GUID, Ole32.CLSCTX.CLSCTX_ALL, null, out var itf);
92+
return (T)itf;
93+
}
94+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.Runtime.InteropServices;
2+
using System.Security;
3+
using System;
4+
using Vanara.PInvoke;
5+
6+
namespace BluetoothDevicePairing.Bluetooth.Devices.AudioDevices;
7+
8+
public static class DeviceProperties
9+
{
10+
// https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/functiondiscoverykeys_devpkey.h#L62
11+
public static Ole32.PROPERTYKEY PKEY_Device_FriendlyName = new(new("{a45c254e-df1c-4efd-8020-67d146a850e0}"), 14u);
12+
}
13+
14+
public enum KsPropertyKind : uint
15+
{
16+
KSPROPERTY_TYPE_GET = 0x00000001,
17+
KSPROPERTY_TYPE_SET = 0x00000002,
18+
KSPROPERTY_TYPE_TOPOLOGY = 0x10000000
19+
}
20+
21+
public enum KSPROPERTY_BTAUDIO : uint
22+
{
23+
KSPROPERTY_ONESHOT_RECONNECT = 0,
24+
KSPROPERTY_ONESHOT_DISCONNECT = 1
25+
}
26+
27+
public static class KsPropertyId
28+
{
29+
public static readonly Guid KSPROPSETID_BtAudio = new("7fa06c40-b8f6-4c7e-8556-e8c33a12e54d");
30+
}
31+
32+
[StructLayout(LayoutKind.Sequential)]
33+
public readonly struct KsProperty(Guid set, KSPROPERTY_BTAUDIO id, KsPropertyKind flags)
34+
{
35+
public Guid Set { get; } = set;
36+
public KSPROPERTY_BTAUDIO Id { get; } = id;
37+
public KsPropertyKind Flags { get; } = flags;
38+
}
39+
40+
[ComImport, SuppressUnmanagedCodeSecurity, Guid("28F54685-06FD-11D2-B27A-00A0C9223196"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
41+
public interface IKsControl
42+
{
43+
[PreserveSig]
44+
int KsProperty(
45+
[In] ref KsProperty Property,
46+
[In] int PropertyLength,
47+
[In, Out] IntPtr PropertyData,
48+
[In] int DataLength,
49+
[In, Out] ref int BytesReturned);
50+
51+
[PreserveSig]
52+
int KsMethod(
53+
[In] ref KsProperty Method,
54+
[In] int MethodLength,
55+
[In, Out] IntPtr MethodData,
56+
[In] int DataLength,
57+
[In, Out] ref int BytesReturned);
58+
59+
[PreserveSig]
60+
int KsEvent(
61+
[In, Optional] ref KsProperty Event,
62+
[In] int EventLength,
63+
[In, Out] IntPtr EventData,
64+
[In] int DataLength,
65+
[In, Out] ref int BytesReturned);
66+
}

0 commit comments

Comments
 (0)