Windows 上的蓝牙编程并不方便,由于操作系统并没有提供统一的蓝牙操作接口,通常是由各个蓝牙设备商提供蓝牙栈,所以要想编程兼容这些不同的厂商蓝牙是一个问题。好在有个软件项目 32feet.NET 针对主流蓝牙协议栈提供了支持,包括 Microsfot, Widcomm, BlueSolei 等,同时还支持红外传输协议。

蓝牙虚拟串口是一个较为常见的需求,为了兼容已有使用串口设备的程序,需要将蓝牙连接转为系统上的虚拟串口,然后提供给其他程序或库使用。32feet.NET 对此也提供了支持。

32feet.NET 依赖 .NET 3.5 版本以上框架,支持 Windows 桌面版本、Wndows CE 以及 Windows Phone。

安装

如果使用 Visual Studio 2015 或者 安装有 NuGet 工具的,可以直接通过 NuGet 安装。在 NuGet 命令行中输入

1
Install-Package 32feet.NET

这样 NuGet 会自动下载安装并添加到当前 .NET 项目中。可以检查项目 References 项,如果存在 InTheHand.Net.Personal 则表明已成功加入到项目中,如果没有,可以手动添加引用,NuGet 下载存放在 SolutionName\packages\32feet.NET.x.x.x.x\ 路径下。

使用

下面主要讲解 32feet.NET 的使用,覆盖蓝牙搜索、配对、直接连接以及虚拟串口服务。

1. 检测系统蓝牙可用性

如果系统没有蓝牙设备或者蓝牙设备被禁用,那么可以通过以下函数来进行检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* 定义于类 BTHelper 中 */
public static bool IsPlatformSupportBT()
{
    BluetoothClient bc;
    try
    {
        bc = new BluetoothClient();
    }
    catch (PlatformNotSupportedException)
    {
        return false;
    }
    bc.Close();
    return true;
}

如果系统不支持蓝牙设备,那么 new BluetoothClient 会抛出 PlatformNotSupportedException 异常,通过捕获这个异常,来检测系统蓝牙设备可用性。

2. 蓝牙搜索

BluetoothClient 对象有个方法 DiscoverDevices 用于搜索蓝牙设备,该方法有多个重载版本,最终都是调用如下这个接口:

1
public BluetoothDeviceInfo[] DiscoverDevices(int maxDevices, bool authenticated, bool remembered, bool unknown, bool discoverableOnly);

第一个参数表明搜索的最大设备数,第二个参数表示是否搜索已配对设备,第三个表示是否搜索记住的设备,第四个表示是否搜索未知设备,第五个参数表示是搜索范围内可被发现的设备。

这里面重要的是第二和第五个参数,第二个代表搜索系统中已配对列表中的设备,即使它们现在并不在线。第五个参数 XP 系统上不支持,表示搜索范围内可被发现的设备。

如果我们需要获得系统中已配对列表中的蓝牙设备,可以这样调用 DiscoverDevices(255, true, false, false),这里使用了4个参数的重载版本,只将第二个参数置为 true

如果我们需要搜索周围环境中可用的蓝牙设备,可以这样调用 DiscoverDevices(255, false, false, false, true),这个调用等同于 DiscoverDevicesInRange()

需要注意的是,这个调用是同步阻塞的,在搜索没有结束之前函数不会返回。所以通常我们需要将这个调用放入工作线程中。例外的是,如果只是获取系统中已配对列表中的设备,这个调用会很快完成,不会占用当前线程太多时间。

库中同时提供了一个异步搜索方法,由类 BluetoothComponent 提供,由事件 DiscoverDevicesProgress 和 事件 DiscoverDevicesComplete 以及方法 DiscoverDevicesAsync 来实现。这个和用 BackgroundWorker 来实现 DiscoverDevices() 异步查找是一样的。

PS: 可以通过 VS 的 Object Browser 查看 InTheHand.Net.Personal 库中的函数接口说明,说明非常翔实。

3. 检测蓝牙设备是否在范围内

当需要检查一个蓝牙设备是否在有效范围内,可以通过查询一个 Fake Service ID 来实现。

如果蓝牙设备在范围内可访问,那么查询的结果是返回的服务记录为空,表示不支持此服务;如果蓝牙设备不在范围内,那么会抛出套接字异常。

以下为检测代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/* 定义于类 BTHelper 中 */
public static bool TestingIfInRange(BluetoothAddress addr)
{
    bool inRange = false;
    Guid fakeUuid = new Guid("{F13F471D-47CB-41d6-9609-BAD0690BF891}");
    BluetoothDeviceInfo device = new BluetoothDeviceInfo(addr);

    try
    {
        ServiceRecord[] records = device.GetServiceRecords(fakeUuid);
        Debug.Assert(records.Length == 0, "Why are we getting any records? len: " + records.Length);
        inRange = true;
    }
    catch (SocketException)
    {
        inRange = false;
    }
    return inRange;
}

需要注意的是,因为蓝牙设备通信需要时间,所以这个调用也需要较长时间才能完成,未完成之前进入阻塞不返回,所以这个方法也需要放入工作线程中执行。

这部分内容可以参考官方文档: Tesing if a device is in rage.

4. 蓝牙设备配对

蓝牙配对功能由类 BluetoothSecurity 的静态方法 PairRequest(BluetoothAddress device, string pin) 提供,其中第一个参数是目标设备地址,第二个参数是用于配对的 Pin 码。配对成功返回 true,失败返回 false

解除配对是 BluetoothSecurity.RemoveDevice(BluetoothAddress device)

配对操作也需要将长时间完成,所以这个方法也需要放入工作线程中执行。

5. 蓝牙直接连接

如果不需要蓝牙虚拟串口而直接读写蓝牙数据,那么可以使用直接连接,这个可以参照官方文档:General Bluetooth Data Connections.

通过直接连接获取一个可读写的 System.IO.Stream 流对象,就可以直接对蓝牙进行读写数据操作了。以下为官方样例中的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
BluetoothAddress addr = BluetoothAddress.Parse("001122334455");
Guid serviceClass;
serviceClass = BluetoothService.SerialPort;
// - or - etc
// serviceClass = MyConsts.MyServiceUuid
//
var ep = new BluetoothEndPoint(addr, serviceClass);
var cli = new BluetoothClient();
cli.Connect(ep);
Stream peerStream = cli.GetStream();
// peerStream.Write/Read ...

6. 蓝牙虚拟串口

这部分讲解如何实现将蓝牙连接转为系统上的虚拟串口并获取串口名,还可以参阅官方文档: Bluetooth Serial Ports.

首先需要说明的是,虚拟串口和直接连接数据读写不能同时使用,如果已经使用了其中一个方法进行读写,那么另外的一个方法会失败。

生成虚拟串口需要将蓝牙设备服务设置为 BluetoothService.SerialPort,官方样例代码如下:

1
2
3
4
BluetoothAddress addr = BluetoothAddress.Parse("123456789012");
BluetoothDeviceInfo device = new BluetoothDeviceInfo(addr);  // Or from discovery etc
bool state = true;
device.SetServiceState(BluetoothService.SerialPort, state, true);

SetServiceState 的第一个函数表示服务标识,第二个表示要设置的服务状态,true 为启用, false 为禁用,第三个参数表示如果设置失败是否抛出异常。

但在实际使用中,某些双模蓝牙在设置时,虽然设置成功但是依然会抛出 Win32Exception 异常,所以这里建议调用此函数的重载版本 SetServiceState(System.Guid service, bool state),而想要知道是否设置成功,下面会介绍其他方法来获得。

这个方法调用并不会告诉我们新生成的串口名,官方文档中的建议是通过设置前后的系统串口列表差异来获取新生成的串口名,而获取系统串口列表则可以调用静态方法 SerialPort.GetPortNames

但是不建议用这个方法来获取虚拟串口名,因为这个不可靠而且容易出错,下面介绍另外一个可靠方法,而且这个方法也可以同时告诉我们设置虚拟串口服务是否成功。

获取蓝牙虚拟串口名

这部分参阅官方文档:Getting Virtual COM Port Names for Remote Bluetooth Devices.

蓝牙虚拟串口在系统中都有记录,我们可以通过检索这个记录,来找到所设置的设备的虚拟串口名,同时也可以得知我们的蓝牙虚拟串口是否设置成功。

通过调用 WMI 查询,可以枚举出系统中每个串口的详细信息。这里可以通过 PowerShell 来查询:

1
C:\> Get-WmiObject -query "select DeviceID,PNPDeviceID from Win32_SerialPort"

输出样例(其中 COM66 对应的蓝牙设备地址为 00803A686519):

1
2
3
4
DeviceID     : COM66
PNPDeviceID  : BTHENUM\{00001101-0000-1000-8000-00805F9B34FB}\7&1D80ECD3&0&00803A686519_C00000003

......

可以看出蓝牙虚拟串口的 PNPDeviceID 是以 BTHENUM 开头,并且会将蓝牙地址存放其中。

这个查询也可以通过 C# 代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
using System.Management;

const string Win32_SerialPort = "Win32_SerialPort";
SelectQuery q = new SelectQuery(Win32_SerialPort);
ManagementObjectSearcher s = new ManagementObjectSearcher(q);
foreach (object cur in s.Get()) {
    ManagementObject mo = (ManagementObject)cur;
    object id = mo.GetPropertyValue("DeviceID");
    object pnpId = mo.GetPropertyValue("PNPDeviceID");
    console.WriteLine("DeviceID:    {0} ", id);
    console.WriteLine("PNPDeviceID: {0} ", pnpId);
    console.WriteLine("");
}//for

综上,我们可以定义一个函数,这个函数通过 WMI 检索所有串口设备信息,然后返回一个 Hashtable,其中存储的键为蓝牙设备地址,存储的值为串口名。

函数定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* 定义于类 BTHelper 中 */
public static Hashtable QueryBTHPorts()
{
    const string Win32_SerialPort = "Win32_SerialPort";
    SelectQuery q = new SelectQuery(Win32_SerialPort);
    ManagementObjectSearcher s = new ManagementObjectSearcher(q);

    Hashtable hashResult = new Hashtable();
    foreach (object cur in s.Get())
    {
        ManagementObject mo = (ManagementObject)cur;
        string id = mo.GetPropertyValue("DeviceID").ToString();
        string pnpId = mo.GetPropertyValue("PNPDeviceID").ToString();
        Debug.WriteLine("WMI>>DeviceID: " + id);
        Debug.WriteLine("WMI>>PNPDeviceID: " + pnpId);
        Debug.WriteLine("");

        /* 仅处理蓝牙串口 */
        if (pnpId.StartsWith("BTHENUM"))
        {
            /* 从 PNPDeviceID 中提取出蓝牙地址,策略是逆序字符串  & 后 _ 之前 */
            /* 蓝牙地址为6字节,HEX为12位字符 */
            int rBound = pnpId.LastIndexOf('_');
            int lBound = pnpId.LastIndexOf('&');
            Debug.Assert(rBound - lBound == 13, "Get BT Addr, this will nevery happened.");
            string addr = pnpId.Substring(lBound + 1, 12);
            if (!hashResult.Contains(addr))
            {
                hashResult.Add(addr, id);
            }
            else
            {
                Debug.WriteLine("Get BT Addr, addr" + addr + " has more than 1 ports");
            }
        }
    }

    return hashResult;
}

同样,WMI 检索也比较耗时,所以这个函数调用需要放入工作线程中执行。

有了这个函数,我们可以通过结果中查找我们设置的蓝牙设备地址,就可以得知对应的虚拟串口名,而如果结果中没有我们设置的蓝牙设备地址,那么就可以认定设置虚拟串口服务失败了。

样例代码

这里介绍通过配合使用 BackgroundWorker 来实现蓝牙设备的配对、生成虚拟串口的样例代码:

我们通过传入蓝牙设备地址给 worker,worker 在 DoWork 中帮我们处理所有步骤,过程中报告进度,并最终告诉我们结果。

worker 的 DoWork 事件代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/* 进行蓝牙连接操作的后台线程执行函数 */
/* 传入 蓝牙地址字符串 作为参数 */
/* 利用 ReportProgress 报告进度 */
/* 利用 DoWorkEventArgs.Result 报告状态 */
void bgworkerConnection_DoWork(object sender, DoWorkEventArgs e)
{
    BackgroundWorker worker = sender as BackgroundWorker;
    string args = e.Argument as string;
    string finalportname = "";

    do
    {
        BluetoothAddress addr = BluetoothAddress.Parse(args);
        BluetoothDeviceInfo deviceinfo = new BluetoothDeviceInfo(addr);

        /* 检测设备是否在服务范围内,有些设备能被发现但不能被配对连接(如果已经被其他终端配对连接) */
        /* 不在范围内则报告错误信息 */
        worker.ReportProgress(10, "Testing if device in range...");
        if (!BTHelper.TestingIfInRange(addr))
        {
            string msg = string.Format("Error: Device {0}({1}) is not reachable!",
                    deviceinfo.DeviceName,
                    deviceinfo.DeviceAddress.ToString());
            e.Result = msg;
            return;
        }

        worker.ReportProgress(10, "Device is in range.");

        /* 检测设备是否是已配对设备 */
        if (deviceinfo.Authenticated)
        {
            /* 已配对设备枚举系统蓝牙串口,检测是否已绑定虚拟串口 */
            worker.ReportProgress(10, "Querying Bluetooth serialport...");
            Hashtable bthTable1 = BTHelper.QueryBTHPorts();
            if (bthTable1.ContainsKey(addr.ToString()))
            {   /* 获取绑定串口 */
                finalportname = bthTable1[addr.ToString()] as string;
                /* !!! 跳出 !!! */
                break;
            }
            /* 此处 else 需考虑枚举结果中没有绑定串口的处理,如果没有绑定串口,那么重新设置串口服务 */
            /* 此处 else 部分会在下面的流程处理,见下方设置串口服务部分 */
        }
        else /* 需要进行配对 */
        {
            worker.ReportProgress(10, "Start pairing...");
            if (!BluetoothSecurity.PairRequest(addr, "1234"))
            {  /* 配对失败 */
                string msg = string.Format("Error: Can not pair to Device {0}({1})!",
                        deviceinfo.DeviceName,
                        deviceinfo.DeviceAddress.ToString());
                e.Result = msg;
                return;
            }
        }

        /* 设置串口服务 */
        /* SLC蓝牙设置串口服务时,如果 SetServiceState */
        /* 第三个参数为 true (允许异常) 那么则一定会抛出异常,即使虚拟串口创建成功 */
        /* 所以这里调用2个参数的重载版本 */
        /* 之后检查串口服务是否设置成功的方法就是 设置后检索蓝牙串口 */
        worker.ReportProgress(10, "Enable serialport service...");

        deviceinfo.SetServiceState(BluetoothService.SerialPort, true);

        /* 枚举系统蓝牙串口,检查对应蓝牙是否有串口绑定 */
        Hashtable bthTable2 = BTHelper.QueryBTHPorts();
        if (bthTable2.ContainsKey(addr.ToString()))
        {
            /* 获取绑定的串口 */
            finalportname = bthTable2[addr.ToString()] as string;
            /* !!! 跳出 !!! */
            break;
        }
        else
        {   /* 启用串口服务失败或者无法检测到绑定串口 */
            string msg = string.Format("Error: Failed to set Serialport service for Device {0}({1})!",
                    deviceinfo.DeviceName,
                    deviceinfo.DeviceAddress.ToString());
            e.Result = msg;
            return;
        }

    } while (false);

    worker.ReportProgress(10, "Open serialport...");

    Debug.WriteLine(string.Format(">>PORT NMAE: {0}", finalportname));

    /* 成功 */
    /* 将串口名放入消息中返回 */
    string okmsg = string.Format("OK: {0}", finalportname);
    e.Result = okmsg;
}

代码中通过灵活使用 string 作为 Result 来表明执行结果。

worker 的 RunCompleted 事件代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void bgworkerConnection_RunCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    string msg = e.Result as string;
    if(msg.StartsWidth("OK"))
    {   /* OK */
        MessageBox.Show(msg);
    }
    else
    {  /* Error */
        MessageBox.Show(msg);
    }
}

后续可以通过字符串处理提取出生成的串口名。

worker 的启动代码:

1
bgworkerConnection.RunWorkerAsync(addrstring);

这样子我们就获取到了绑定的虚拟串口名,但是在使用串口中要注意的是,虚拟串口打开过程中,本质上还是发起了一个蓝牙数据连接,所以打开过程会耗时较长,所以这个虚拟串口打开过程也需要放入工作线程中处理。如果虚拟串口对应的蓝牙设备没有在线或者其他原因造成不可用,那么打开时会抛出异常,需要在代码上进行处理。

一旦蓝牙设备经过绑定并且设置虚拟串口服务成功,那么对应的串口会一直存在与系统的串口列表中,但这并不代表它是可用的,请在打开这类串口时添加异常处理代码。

(完)