[IOT] 自制藍芽工牌辦公室定位系統 (二)—— 基於ESP32的藍芽訊號掃描系統
前面章節:
目錄:
- 1、藍芽廣播簡介
- 2、藍芽掃描簡介
- 3、基於藍芽廣播和藍芽掃描常見應用
- 4、ESP32簡介
- 5、ESP32開發環境搭建
- 6、基於ESP32的藍芽掃描實現
- 7、效果展示
前言:
我們整個基於藍芽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 [email protected]: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
[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
[10]. PDF - ESP32-WROOM-32 datasheet
[13]. esp32_linux_tool GITHUB地址@beautifulzzzz 以藍芽技術為基礎的的末梢無線網路系統架構及創新型應用探索! 領域:智慧硬體、物聯網、自動化、前沿軟硬體 部落格:https://www.cnblogs.com/zjutlitao/ 園友交流群|微信交流群:414948975|園友交流群