前面章节:
前言:
我们整个基于蓝牙beacon的办公室定位系统主要有两部分组成:
- 1)蓝牙信号扫描器(蓝牙扫描+数据上云)
- 2)基于beacon的低功耗工牌
上一节我们讲解了如何将数据通过ESP32上传到云端,本节主要讲如何用ESP32扫描周边蓝牙设备。
1、蓝牙广播简介
蓝牙就在我们身边:电子信标引导消防员穿过建筑物; 可穿戴医疗设备将患者的生物数据发送给医生的平板电脑; 40万平方英尺仓库的设备监控等。蓝牙技术正在蓬勃发展,预计到2021年将有超过480亿的安装基数(per ABI Internet of Everything Market Tracker)。
那么蓝牙是如何工作的呢?BLE(蓝牙低功耗) 在2.4GHz的ISM频段中有40个物理信道,每个信道之间相隔2MHz。蓝牙定义了两种传输类型:数据传输和广播传输。因此,这40个频道中有3个专门用于广播,37个专门用于数据。
广播主要会涉及下面几个参数:
Advertising Parameter | Description | Range |
---|---|---|
Advertising Interval | Time between the start of two consecutive advertising events | 20ms to 10.24s |
Advertising Types | Different PDUs are sent for different types of advertising | See following |
Advertising Channels | Legacy advertising packets are sent on three channels | Different combinations of channels 37, 38 and 39. |
一般情况下,广播信道有channel 37 (2402 MHz), channel 38 (2426 MHz), and channel 39 (2480 MHz)。设备可以在其中一个、两个或三个上进行广播,下图展示了在所有三个频道上进行广播的事件:
注意,上列中是在所有通道上都发送了相同的数据(ADV_IND)。由于数据包非常小(广播数据不超过31字节),发送它所需的时间不到10毫秒。设备可以修改为仅在选定的频道上进行广播。在较少的频道上进行广播将节省电力,但是使用更多的频道将增加对等设备接收数据包的可能性。用户可以根据应用程序用例配置广播间隔。例如,如果门锁以较慢的间隔进行广播,则对等设备连接到门锁将需要更长的时间,这将对用户体验产生不利影响。
无论是beacon(传输位置、天气或其他数据)还是与主机(平板电脑或手机)建立长期连接的健身手表,所有外围设备,至少在最初都是以广播模式开始的。
Advertising允许设备去广播有意图的信息。
那么,蓝牙的广播是怎样的呢?
为了便于使用,蓝牙为广播和数据传输定义了一种单一的数据包格式。这个包由四个部分组成:前导码(1字节)、访问地址(4字节)、协议数据单元(2-257字节)和循环冗余校验(3字节);见下图:
PDU部分比较重要,因为它定义了该数据包是广播包还是数据包。在我们解析来的讨论中,将重点讨论广播PUD包。
广播PUD包包含16 bits 的头和不定长度的payload:
广播的头部包含6部分,我们主要关注Length和PUD Type两部分。Length长6bits,定义了payload的长度。Length的取值范围是6-27字节(取决于PUD Type)。
OK,现在我们知道了广播的时候会有几字节的16进制数据在payload中,但是为什么广播呢?这就要提到PUD Type了。在蓝牙低功耗中,有两个原因需要广播:
- 在设备(如智能手表和电话)之间建立双向连接。
- 或者在不与其他设备连接的情况下广播信息,例如在博物馆里一个信标发送数据,告诉你身后5英尺处有一具500年前的木乃伊尸体。
因此,无论是智能手表还是木乃伊都在争夺关注,我们开发人员则需要关注4种PDU类型:
- ADV_IND
- 广播指示:设备请求连接中心设备(不是针对特殊的指定的中心设备)
- 例如:智能手表请求连接任何中心设备
- ADV_DIRECT_IND
- 类似ADV_IND,只是是针对特殊中心设备
- 例如:智能手表请求连接特殊中心设备
- ADV_NONCONN_IND
- 不可连接的设备,广播信息到任何收听设备
- 例如:博物馆的信标定义了临近的特定展品的信息
- ADV_SCAN_IND
- 类似ADV_SCAN_IND,是响应扫描的附加可选信息
所以,当需要维持长期连接时,PDU的类型应设置为ADV_IND或ADV_DIRECT_IND;当只是广播一些信息,不需要维持长期连接时,ADV_NONCONN_IND和ADV_SCAN_IND将会被用上,beacon常用ADV_NONCONN_IND,当需要广播更多信息的时候,可以把信息放在scan回复中,选用ADV_SCAN_IND。
无论是请求长期连接还是作为beacon,这一切都始于广播。
2、蓝牙扫描简介
当BLE设备未被连接时,可以通过发送广播包来宣传它们的存在,或者扫描附近正在广播的设备。扫描设备的过程被成为设备发现。扫描有两种类型:主动扫描和被动扫描。区别在于:主动扫描器可以主动发送一个扫描请求,请求广播设备进行广播回复;而被动扫描器只能被动扫描广播信息。下图显示了扫描器在广播事件期间向广广播客户发送扫描请求的时序:
当涉及到扫描时间时,您需要熟悉一些参数。每个参数都有一个由蓝牙核心规范指定的范围。帧间时隙(T_IFS)是同一信道上两个连续数据包之间的时间间隔,由BLE规范设置为150us。
Scan Parameter | Description | Range |
---|---|---|
Scan Interval | The interval between the start of two consecutive scan windows | 10ms to 10.24s |
Scan Window | The duration in which the Link Layer scans on one channel | 10ms to 10.24s |
Scan Duration | The duration in which the device stays in the scanning state | 10ms to infinity |
下图展示了这些参数的关系:
请注意,扫描通道的顺序是固定的。设备将分别在通道37(2402MHz)、通道38(2426MHz)和通道39(2480MHz)上进行扫描,并按照扫描窗口定义的时间长度在每个扫描间隔上进行扫描。
二级广播信道上的可扫描广播包也可以引发扫描请求和扫描响应。这些被称为AUX_SCAN_REQ and AUX_SCAN_RSP。下表总结了所有与扫描相关的数据包:
Scanning PDU | Transmitting device | Payload |
---|---|---|
SCAN_REQ | Scanner | Scanner's address + advertiser's address |
SCAN_RSP | Advertiser | Advertiser's address + 0-31 bytes scan response data |
AUX_SCAN_REQ | Scanner | Scanner's address + advertiser's address |
AUX_SCAN_RSP | Advertiser | Header + 0-254 bytes data |
- You can read more about each scanning PDU in the Bluetooth core specification [2].
3、基于蓝牙广播和蓝牙扫描常见应用
蓝牙广播常见的应用有:beacon、室内定位、靠近开门、广播小数据信息等,*[3]上的总结有如下场景:
- Broadcast location-based coupons.
- Contextual advertising.
- Localized information.
- Gaming and music.
- Content on demand.
- Specific and targeted campaign.
注:蓝牙5的定位、广播将更具诱人特性。
4、ESP32简介
ESP32是一款2.4 GHz Wi-Fi和蓝牙组合芯片,采用TSMC超低功耗40纳米技术设计。它的设计是为了获得最佳的功率和射频性能,在各种应用和电源方案中显示出鲁棒性、通用性和可靠性。
ESP32 系列芯片包括:ESP32-D0WDQ6, ESP32-D0WD, ESP32-D2WD, and ESP32-S0WD。其架构图如下:
我们实验用了乐鑫官方的一个开发板:ESP32-WROOM-32。该开发板是一款功能强大的通用Wi-Fi+BT+BLE MCU模块,面向各种应用,从低功耗传感器网络到最苛刻的任务,如语音编码、音乐流和MP3解码。
该模块采用EP32-D0WDQ6芯片,该芯片是双核芯片、可独立控制,时钟频率可以从80MHz ~ 240MHz。用户还可以关闭CPU电源,并利用低功耗协处理器持续监控外围设备的变化或是否超过阈值。ESP32集成了一套丰富的外围设备,包括电容式触摸传感器、霍尔传感器、SD卡接口、以太网、高速SPI、UART、I2s和I2c。
集成了蓝牙、BLE和Wi-Fi,代表着未来:使用WIFI可通过路由器连接到互联网,而使用蓝牙则方便用户连接到手机和低功耗。ESP32芯片的休眠电流小于5uA,因此适用于电池供电和可穿戴电子设备应用。ESP32支持高达150 Mbps的数据速率和20.5 dBm的输出功率,以确保最宽的物理范围。因此,该芯片确实为电子集成、范围、功耗和连接性提供了行业领先的规格和最佳性能。
ESP32可选的操作系统是freeRTOS with LwIP + TLS 1.2 + 硬件内部加速 + 加密的OTA技术。下表是ESP32-WROOM-32的资源总览:
5、ESP32开发环境搭建
通过下面两个资料,大家可以自行搭建环境:
-
SDK介绍: 对于ESP32乐鑫官方提供了一个IDF :
-
环境搭建: 如果你想自己搭建开发环境,参见乐鑫官方资料:
不过! 作为系统洁癖和拒绝重复造*的博主,已经写了一个全自动构建环境的脚本、并把该工具在github上开源了:esp32_linux_tool [13]
注: nbtool是博主专门放自己造的或收集到的牛逼*的github组
博主造的这个*比较好用,基于 all-in-one思想 (所有相关文件在一个文件夹下;所有相关环境变量不需要额外配置):
#!/bin/bash
set -e
PROJECT_ROOT=..
TOOLS_PATH=$PROJECT_ROOT/tool
SDK_PATH=$PROJECT_ROOT/sdk
APP_PATH=$PROJECT_ROOT/app
XTENSA_ESP32_ELF_PATH=$TOOLS_PATH/xtensa-esp32-elf
ESP_IDF_PATH=$SDK_PATH/esp-idf
XTENSA_ESP32_ELF_LINK=https://dl.espressif.com/dl/xtensa-esp32-elf-linux64-1.22.0-80-g6c4433a-5.2.0.tar.gz
ESP_IDF_LINK=https://github.com/espressif/esp-idf.git
#--------------------------------------------------------------------------
function install_tool_chain(){
echo "> install tool chain ..."
echo "> web page: https://docs.espressif.com/projects/esp-idf/zh_CN/v3.1.1/get-started/linux-setup.html"
if [ ! -d $XTENSA_ESP32_ELF_PATH ]; then
wget $XTENSA_ESP32_ELF_LINK
tar -xzf xtensa-esp32-elf*.tar.gz
rm xtensa-esp32-elf*.tar.gz
fi
}
function install_esp_idf(){
echo "> install esp idf ..."
echo "> web page: https://github.com/espressif/esp-idf"
if [ ! -d $ESP_IDF_PATH ]; then
git clone $ESP_IDF_LINK
mv esp-idf $SDK_PATH/
fi
}
function create_project(){
if [ "$1" == "" ] || [ "$2" == "" ]; then
echo "input error"
elif [ -d $1 ] && [ ! -d "$APP_PATH/$2" ]; then
cp -r $1 $APP_PATH/$2
file=$APP_PATH/$2/run.sh
the_sdk_path=`cd $ESP_IDF_PATH; pwd`
the_tool_chain_path=`cd $XTENSA_ESP32_ELF_PATH/bin; pwd`
cat > $file <<EOF
#!/bin/bash
#I don't like to set environment variables in the system,
#so I put the environment variables in run.sh.
#Every time I use run.sh, the enviroment variables will be set, after use that will be unsetted.
PROJECT_ROOT=../..
TOOLS_PATH=\$PROJECT_ROOT/tool
SDK_PATH=\$PROJECT_ROOT/sdk
APP_PATH=\$PROJECT_ROOT/app
XTENSA_ESP32_ELF_PATH=\$TOOLS_PATH/xtensa-esp32-elf
ESP_IDF_PATH=\$SDK_PATH/esp-idf
the_sdk_path=\`cd \$ESP_IDF_PATH; pwd\`
the_tool_chain_path=\`cd \$XTENSA_ESP32_ELF_PATH/bin; pwd\`
export PATH="\$PATH:\$the_tool_chain_path"
export IDF_PATH="\$the_sdk_path"
if [ "\$1" == "config" ]; then
make menuconfig
elif [ "\$1" == "build" ]; then
make all
elif [ "\$1" == "flash" ]; then
make flash
elif [ "\$1" == "build-app" ]; then
make app
elif [ "\$1" == "flash-app" ]; then
make app-flash
elif [ "\$1" == "monitor" ]; then
make monitor
elif [ "\$1" == "clean" ]; then
make clean
elif [ "\$1" == "help" ]; then
echo "bash run.sh config"
echo " |- basic configuration by GUI, if we use -j4 to build and flash, we must first config then build or flash!!!"
echo "bash run.sh build"
echo " |- build all"
echo "bash run.sh flash"
echo " |- build all and flash the program"
echo "bash run.sh build-app"
echo " |- just build app, not build bootloader and partition table"
echo "bash run.sh flash-app"
echo " |- just flash app, when bootloader and partition table have not changed, no need to flash"
echo " |- more infomation:https://docs.espressif.com/projects/esp-idf/zh_CN/v3.1.1/get-started/make-project.html"
echo "bash run.sh monitor"
echo " |- monitor the program, 'Ctrl+]' to stop"
echo " |- IDF Monitor:https://docs.espressif.com/projects/esp-idf/zh_CN/v3.1.1/get-started/idf-monitor.html"
else
echo "error, try bash run.sh help"
fi
EOF
chmod +x $file
ls -all $APP_PATH/$2
fi
}
#--------------------------------------------------------------------------
function tool(){
if [ ! -d $SDK_PATH ]; then
mkdir $SDK_PATH
fi
if [ ! -d $APP_PATH ]; then
mkdir $APP_PATH
fi
install_tool_chain
install_esp_idf
}
function clean(){
echo "cleaning ...."
rm -rf $XTENSA_ESP32_ELF_PATH
rm -rf $ESP_IDF_PATH
rm -rf $SDK_PATH
}
if [ "$1" == "clean" ]; then
clean
elif [ "$1" == "tool" ]; then
tool
elif [ "$1" == "create" ]; then
create_project $2 $3
elif [ "$1" == "help" ]; then
echo "bash run.sh tool"
echo " |- create the build enviroment, including sdk and tool chain"
echo "bash run.sh clean"
echo " |- clean all the sdk and tools, thats download form web-page when 'bash run.sh tool'"
echo "bash run.sh create path_of_example_in_sdk new_name_project"
echo " |- copy the example in the sdk to app directory, and rename it new_name_project"
else
echo "error, try bash run.sh help"
fi
上面的run.sh脚本就是完成开发环境构建、工程创建、编译、烧写、跟踪LOG等复杂功能,大家可以慢慢理解。下面先谈谈如何用该开源项目:
#克隆项目到本地
> git clone git@github.com:nbtool/esp32_linux_tool.git
#构建esp32开发环境
> cd ./esp32_linux_tool/tool
> ./run.sh help
> ./run.sh tool
#从SDK的example中复制一个DEMO到APP层(例如:hello_world)
> bash run.sh create ../sdk/esp-idf/examples/get-started/hello_world hello_world
> cd ../app/hello_world
> ./run.sh help
#烧写固件
> ./run.sh flash
#查看LOG
> ./run.sh monitor
#清空工程
> ./run.sh clean
6、基于ESP32的蓝牙扫描实现
由于ESP32的IDF中已经有蓝牙扫描的DEMO,因此我们用下面命令直接从DEMO创建工程:
bash run.sh create ../sdk/esp-idf/examples/bluetooth/bt_discovery bt_discovery
之后将 ./app/bt_discovery/main/bt_discovery.c 修改为:
#include <stdint.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "esp_system.h"
#include "esp_log.h"
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_bt_device.h"
#include "esp_gap_bt_api.h"
#define GAP_TAG "GAP"
typedef enum {
APP_GAP_STATE_IDLE = 0,
APP_GAP_STATE_DEVICE_DISCOVERING,
APP_GAP_STATE_DEVICE_DISCOVER_COMPLETE,
} app_gap_state_t;
typedef struct {
bool dev_found;
uint8_t bdname_len;
uint8_t eir_len;
uint8_t rssi;
uint32_t cod;
uint8_t eir[ESP_BT_GAP_EIR_DATA_LEN];
uint8_t bdname[ESP_BT_GAP_MAX_BDNAME_LEN + 1];
esp_bd_addr_t bda;
app_gap_state_t state;
} app_gap_cb_t;
static app_gap_cb_t m_dev_info;
static char *bda2str(esp_bd_addr_t bda, char *str, size_t size)
{
if (bda == NULL || str == NULL || size < 18) {
return NULL;
}
uint8_t *p = bda;
sprintf(str, "%02x:%02x:%02x:%02x:%02x:%02x",
p[0], p[1], p[2], p[3], p[4], p[5]);
return str;
}
static void update_device_info(esp_bt_gap_cb_param_t *param)
{
char bda_str[18];
uint32_t cod = 0;
int32_t rssi = -129; /* invalid value */
esp_bt_gap_dev_prop_t *p;
ESP_LOGI(GAP_TAG, "Device found: %s", bda2str(param->disc_res.bda, bda_str, 18));
for (int i = 0; i < param->disc_res.num_prop; i++) {
p = param->disc_res.prop + i;
switch (p->type) {
case ESP_BT_GAP_DEV_PROP_COD:
cod = *(uint32_t *)(p->val);
ESP_LOGI(GAP_TAG, "--Class of Device: 0x%x", cod);
break;
case ESP_BT_GAP_DEV_PROP_RSSI:
rssi = *(int8_t *)(p->val);
ESP_LOGI(GAP_TAG, "--RSSI: %d", rssi);
break;
case ESP_BT_GAP_DEV_PROP_BDNAME:
default:
break;
}
}
/* search for device with MAJOR service class as "rendering" in COD */
app_gap_cb_t *p_dev = &m_dev_info;
if (p_dev->dev_found && 0 != memcmp(param->disc_res.bda, p_dev->bda, ESP_BD_ADDR_LEN)) {
return;
}
if (!esp_bt_gap_is_valid_cod(cod) ||
!(esp_bt_gap_get_cod_major_dev(cod) == ESP_BT_COD_MAJOR_DEV_PHONE)) {
return;
}
memcpy(p_dev->bda, param->disc_res.bda, ESP_BD_ADDR_LEN);
p_dev->dev_found = true;
for (int i = 0; i < param->disc_res.num_prop; i++) {
p = param->disc_res.prop + i;
switch (p->type) {
case ESP_BT_GAP_DEV_PROP_COD:
p_dev->cod = *(uint32_t *)(p->val);
break;
case ESP_BT_GAP_DEV_PROP_RSSI:
p_dev->rssi = *(int8_t *)(p->val);
break;
case ESP_BT_GAP_DEV_PROP_BDNAME: {
uint8_t len = (p->len > ESP_BT_GAP_MAX_BDNAME_LEN) ? ESP_BT_GAP_MAX_BDNAME_LEN :
(uint8_t)p->len;
memcpy(p_dev->bdname, (uint8_t *)(p->val), len);
p_dev->bdname[len] = '\0';
p_dev->bdname_len = len;
break;
}
case ESP_BT_GAP_DEV_PROP_EIR: {
memcpy(p_dev->eir, (uint8_t *)(p->val), p->len);
p_dev->eir_len = p->len;
break;
}
default:
break;
}
}
}
void bt_app_gap_init(void)
{
app_gap_cb_t *p_dev = &m_dev_info;
memset(p_dev, 0, sizeof(app_gap_cb_t));
}
void bt_app_gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param)
{
app_gap_cb_t *p_dev = &m_dev_info;
switch (event) {
case ESP_BT_GAP_DISC_RES_EVT: {
update_device_info(param);
break;
}
case ESP_BT_GAP_DISC_STATE_CHANGED_EVT: {
ESP_LOGE(GAP_TAG, "%d", p_dev->state);
if(p_dev->state == APP_GAP_STATE_IDLE){
ESP_LOGE(GAP_TAG, "discovery start ...");
p_dev->state = APP_GAP_STATE_DEVICE_DISCOVERING;
}else if(p_dev->state == APP_GAP_STATE_DEVICE_DISCOVERING){
ESP_LOGE(GAP_TAG, "discovery timeout ...");
p_dev->state = APP_GAP_STATE_DEVICE_DISCOVER_COMPLETE;
esp_bt_gap_start_discovery(ESP_BT_INQ_MODE_GENERAL_INQUIRY, 10, 0);
}else{
ESP_LOGE(GAP_TAG, "discovery again ...");
p_dev->state = APP_GAP_STATE_IDLE;
}
break;
}
case ESP_BT_GAP_RMT_SRVCS_EVT: {
break;
}
case ESP_BT_GAP_RMT_SRVC_REC_EVT:
default: {
break;
}
}
return;
}
void bt_app_gap_start_up(void)
{
char *dev_name = "ESP_GAP_INQRUIY";
esp_bt_dev_set_device_name(dev_name);
/* set discoverable and connectable mode, wait to be connected */
esp_bt_gap_set_scan_mode(ESP_BT_SCAN_MODE_CONNECTABLE_DISCOVERABLE);
/* register GAP callback function */
esp_bt_gap_register_callback(bt_app_gap_cb);
/* inititialize device information and status */
app_gap_cb_t *p_dev = &m_dev_info;
memset(p_dev, 0, sizeof(app_gap_cb_t));
/* start to discover nearby Bluetooth devices */
p_dev->state = APP_GAP_STATE_IDLE;
esp_bt_gap_start_discovery(ESP_BT_INQ_MODE_GENERAL_INQUIRY, 10, 0);
}
void app_main()
{
/* Initialize NVS — it is used to store PHY calibration data */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK( ret );
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_BLE));
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
if ((ret = esp_bt_controller_init(&bt_cfg)) != ESP_OK) {
ESP_LOGE(GAP_TAG, "%s initialize controller failed: %s\n", __func__, esp_err_to_name(ret));
return;
}
if ((ret = esp_bt_controller_enable(ESP_BT_MODE_CLASSIC_BT)) != ESP_OK) {
ESP_LOGE(GAP_TAG, "%s enable controller failed: %s\n", __func__, esp_err_to_name(ret));
return;
}
if ((ret = esp_bluedroid_init()) != ESP_OK) {
ESP_LOGE(GAP_TAG, "%s initialize bluedroid failed: %s\n", __func__, esp_err_to_name(ret));
return;
}
if ((ret = esp_bluedroid_enable()) != ESP_OK) {
ESP_LOGE(GAP_TAG, "%s enable bluedroid failed: %s\n", __func__, esp_err_to_name(ret));
return;
}
bt_app_gap_start_up();
}
逐层调用关系:
函数 | 符号 | 执行任务 |
---|---|---|
app_main | -> | 各种初始化,最后调用 bt_app_gap_start_up |
bt_app_gap_start_up | -o | 初始化蓝牙并启动搜索,超时10S,回调事件会被 bt_app_gap_cb 捕捉 |
bt_app_gap_cb | o-> | 开始搜索/搜索超时/再次搜索+搜索到设备事件,超时会再次启动10S搜索,搜到设备会调用update_device_info 打印 |
update_device_info | -o | 将搜索结果打印下来 |
注:-> 会继续调用其他函数;-o 停止调用其他函数;o-> 回调函数;
7、效果展示
注: 周期性扫描,10S超时后继续扫描,扫到之后打印MAC和RSSI
- : 完~
- 大家觉得不错,可以点推荐给更多人~
- 最近一段时间准备将这个系列写完,做一套可演示的系统(笑)~
LINKS
[1]. BLOG - 自制蓝牙工牌办公室定位系统 (一)
[2]. SIG - Bluetooth core specification
[3]. WiKi - Bluetooth advertising
[4]. SIG - Bluetooth Low Energy - It starts with Advertising
[5]. TI - Bluetooth Low Energy Scanning and Advertising
[6]. TI - Bluetooth Low Energy Scanning and Advertising
[7]. Android - Bluetooth Low Energy Advertising
[8]. BLOG - Advertising(解説)
[9]. PDF - ESP32 datasheet
[10]. PDF - ESP32-WROOM-32 datasheet
[11]. ESP32-IDF GITHUB地址
[12]. ESP-IDF Program Guide
[13]. esp32_linux_tool GITHUB地址
@beautifulzzzz
以蓝牙技术为基础的的末梢无线网络系统架构及创新型应用探索!
领域:智能硬件、物联网、自动化、前沿软硬件
博客:https://www.cnblogs.com/zjutlitao/
园友交流群|微信交流群:414948975|园友交流群