Garmin: sensor-menu pairing, no PHY update on that link, fast interval for phones

This commit is contained in:
pelgraine committed 2026-10-26 21:42:43 +11:00
1 parent 8417c32f3c
commit 4b9ba3a65d
3 files changed
+116 -42

No files matched your search

+97 -1
View File
@@ -11,6 +11,25 @@
#define ADVERT_RESTART_DELAY 1000 // millis
#ifdef MECK_BLE_SENSOR_ADVERT
// Experiment (2026-10-26): make the node look like a Bluetooth heart-rate
// sensor as well as a MeshCore node, so a Garmin watch's Sensors & Accessories
// search lists it and the watch firmware runs its own pairing, which can ask
// for a PIN (Connect IQ apps cannot). The Heart Rate characteristics carry the
// same MITM permissions as UART, so the watch has to pair before it can
// subscribe. No heart-rate data is ever sent.
//
// Advertising packet (31 bytes max): flags, 16-bit Heart Rate service UUID,
// appearance "Heart Rate Belt", and the device name (shortened to fit).
// Scan response: the 128-bit UART service UUID, so phone apps that filter on
// it still find the node.
#define SENSOR_SVC_UUID 0x180D // Heart Rate
#define SENSOR_CHR_HRM_UUID 0x2A37 // Heart Rate Measurement (notify)
#define SENSOR_CHR_LOC_UUID 0x2A38 // Body Sensor Location (read)
#define SENSOR_APPEARANCE 0x0341 // Heart Rate Belt
#define ADV_NAME_MAX_BYTES 18 // 31 - flags(3) - 16-bit svc(4) - appearance(4) - name header(2)
#endif
// BLE transmit power for connections, advertising and scanning: +9 dBm unless
// a build sets another esp_power_level_t level with a flag, for example
// -D MECK_BLE_TX_POWER=ESP_PWR_LVL_P15
@@ -107,7 +126,52 @@ void SerialBLEInterface::_realBegin() {
#endif
pRxCharacteristic->setCallbacks(this);
#ifdef MECK_BLE_SENSOR_ADVERT
// Heart Rate service: notify-only measurement plus body sensor location,
// with the same link requirement as the UART characteristics (encrypted
// under MECK_BLE_JUST_WORKS, authenticated/MITM otherwise).
#ifdef MECK_BLE_JUST_WORKS
const esp_gatt_perm_t sensorRead = ESP_GATT_PERM_READ_ENCRYPTED;
const esp_gatt_perm_t sensorWrite = ESP_GATT_PERM_WRITE_ENCRYPTED;
#else
const esp_gatt_perm_t sensorRead = ESP_GATT_PERM_READ_ENC_MITM;
const esp_gatt_perm_t sensorWrite = ESP_GATT_PERM_WRITE_ENC_MITM;
#endif
pSensorService = pServer->createService(BLEUUID((uint16_t)SENSOR_SVC_UUID));
BLECharacteristic* pHrm = pSensorService->createCharacteristic(BLEUUID((uint16_t)SENSOR_CHR_HRM_UUID), BLECharacteristic::PROPERTY_NOTIFY);
pHrm->setAccessPermissions(sensorRead);
BLE2902* pHrmCccd = new BLE2902();
pHrmCccd->setAccessPermissions(sensorRead | sensorWrite);
pHrm->addDescriptor(pHrmCccd);
uint8_t hrmInit[2] = { 0x00, 0x00 }; // flags byte + 8-bit value 0
pHrm->setValue(hrmInit, 2);
BLECharacteristic* pLoc = pSensorService->createCharacteristic(BLEUUID((uint16_t)SENSOR_CHR_LOC_UUID), BLECharacteristic::PROPERTY_READ);
pLoc->setAccessPermissions(sensorRead);
uint8_t locChest = 1;
pLoc->setValue(&locChest, 1);
// Advertising data: flags, Heart Rate UUID, appearance, name (shortened to
// ADV_NAME_MAX_BYTES at a UTF-8 character boundary if needed).
BLEAdvertisementData adv;
adv.setFlags(ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT);
adv.setCompleteServices(BLEUUID((uint16_t)SENSOR_SVC_UUID));
adv.setAppearance(SENSOR_APPEARANCE);
size_t nameLen = strlen(_dev_name);
if (nameLen <= ADV_NAME_MAX_BYTES) {
adv.setName(std::string(_dev_name, nameLen));
} else {
size_t cut = ADV_NAME_MAX_BYTES;
while (cut > 0 && (((uint8_t)_dev_name[cut]) & 0xC0) == 0x80) cut--; // do not split a UTF-8 sequence
adv.setShortName(std::string(_dev_name, cut));
}
BLEAdvertisementData rsp;
rsp.setCompleteServices(BLEUUID(SERVICE_UUID));
pServer->getAdvertising()->setAdvertisementData(adv);
pServer->getAdvertising()->setScanResponseData(rsp);
BLE_DEBUG_PRINTLN("sensor advert: HR service + UART in scan response, name bytes=%u", (unsigned)nameLen);
#else
pServer->getAdvertising()->addServiceUUID(SERVICE_UUID);
#endif
}
// -------- BLESecurityCallbacks methods
@@ -166,6 +230,13 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
BLE_DEBUG_PRINTLN(" - Requested fast connection interval (15-20ms)");
#endif
#ifdef MECK_BLE_NO_PHY_UPDATE
// A Garmin watch paired through its sensor menu answers the 2M PHY request
// in a way the ESP32-S3 controller does not survive (assert in
// lld_con.c during the PHY update procedure, 2026-10-26). The watch keeps
// a 23-byte MTU anyway, so neither request gains anything: skip both.
BLE_DEBUG_PRINTLN(" - PHY/DLE requests skipped (MECK_BLE_NO_PHY_UPDATE)");
#else
// Request 2M PHY for doubled air data rate (BLE 5.0, supported on ESP32-S3)
// Note: ESP-IDF misspells "preferred" as "prefered" in their API
esp_ble_gap_set_prefered_phy(_remote_bda,
@@ -175,6 +246,7 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
ESP_BLE_GAP_PHY_OPTIONS_NO_PREF);
esp_ble_gap_set_pkt_data_len(_remote_bda, 251); // Request DLE (max link-layer PDU)
BLE_DEBUG_PRINTLN(" - Requested 2M PHY and DLE (251 bytes)");
#endif
} else {
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Failure*");
_auth_ok = false;
@@ -197,7 +269,25 @@ void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t
}
void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) {
BLE_DEBUG_PRINTLN("onMtuChanged(), mtu=%d", pServer->getPeerMTU(param->mtu.conn_id));
uint16_t mtu = pServer->getPeerMTU(param->mtu.conn_id);
BLE_DEBUG_PRINTLN("onMtuChanged(), mtu=%d", mtu);
#ifdef MECK_BLE_SLOW_INTERVAL
// The relaxed interval requested at authentication suits a watch, which
// never raises its MTU above 23. A peer that negotiates a larger MTU is a
// phone about to sync contacts; at 100-200 ms the notify buffer overflows
// and contact frames are dropped (2026-10-26), so give phones the fast
// interval they have always had.
if (mtu > 23) {
esp_ble_conn_update_params_t conn_params;
memcpy(conn_params.bda, _remote_bda, 6);
conn_params.min_int = 12; // 15ms (12 x 1.25ms)
conn_params.max_int = 16; // 20ms (16 x 1.25ms)
conn_params.latency = 0; // no skipped intervals
conn_params.timeout = 400; // 4 seconds supervision timeout
esp_ble_gap_update_conn_params(&conn_params);
BLE_DEBUG_PRINTLN(" - Large-MTU peer: requested fast connection interval (15-20ms)");
}
#endif
}
void SerialBLEInterface::onDisconnect(BLEServer* pServer) {
@@ -267,6 +357,9 @@ void SerialBLEInterface::enable() {
// Start the service
pService->start();
#ifdef MECK_BLE_SENSOR_ADVERT
pSensorService->start();
#endif
// Start advertising
@@ -289,6 +382,9 @@ void SerialBLEInterface::disable() {
pServer->getAdvertising()->stop();
pServer->disconnect(last_conn_id);
pService->stop();
#ifdef MECK_BLE_SENSOR_ADVERT
pSensorService->stop();
#endif
}
oldDeviceConnected = deviceConnected = false;
adv_restart_time = 0;
+9
View File
@@ -12,6 +12,12 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
BLEServer *pServer;
BLEService *pService;
BLECharacteristic * pTxCharacteristic;
#ifdef MECK_BLE_SENSOR_ADVERT
// Experiment: a Heart Rate service alongside UART so a Garmin watch lists
// the node under Sensors & Accessories and runs its own (PIN) pairing. See
// the .cpp for the advertising layout.
BLEService *pSensorService;
#endif
bool deviceConnected;
bool oldDeviceConnected;
bool _isEnabled;
@@ -74,6 +80,9 @@ public:
SerialBLEInterface() {
pServer = NULL;
pService = NULL;
#ifdef MECK_BLE_SENSOR_ADVERT
pSensorService = NULL;
#endif
deviceConnected = false;
oldDeviceConnected = false;
adv_restart_time = 0;
+10 -41
View File
@@ -192,10 +192,15 @@ lib_deps =
bitbank2/JPEGDEC
bitbank2/PNGdec
; MAX + BLE companion, Garmin test build. Identical to meck_max_ble above
; except the five BLE flags are switched on instead of commented out:
; MAX + BLE companion, Garmin watch build. meck_max_ble plus the watch flags:
; debug logging, just-works pairing, small-MTU frame slicing, the watch
; protocol extensions, and the slow connection interval.
; protocol extensions, the slow connection interval (watch only; phones get
; the fast interval back when they raise the MTU), MECK_BLE_SENSOR_ADVERT (a
; Heart Rate service and sensor-style advertising, so the watch pairs with
; the node through its own Sensors & Accessories menu and the bond then
; serves the Meck watch app; switch the sensor entry off afterwards) and
; MECK_BLE_NO_PHY_UPDATE (the 2M PHY request crashed the controller on the
; sensor link). Verified on the fenix 7S 2026-10-26.
[env:meck_max_ble_garmin]
extends = LilyGo_TDeck_Pro_Max
build_flags =
@@ -213,6 +218,8 @@ build_flags =
-D MECK_BLE_SMALL_MTU_SPLIT=1
-D MECK_WATCH_EXT=1
-D MECK_BLE_SLOW_INTERVAL=1
-D MECK_BLE_SENSOR_ADVERT=1
-D MECK_BLE_NO_PHY_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.15.1.MAX"'
build_src_filter = ${LilyGo_TDeck_Pro_Max.build_src_filter}
+<helpers/esp32/*.cpp>
@@ -265,44 +272,6 @@ lib_deps =
bitbank2/PNGdec
; ---------------------------------------------------------------------------
; Fish build -- meck_max_ble plus the LoRaGotchi DX fish (MECK_LORAFISH,
; ui-new/LoRaFish.cpp) in LoRaGotchi's colour mode with the watch's tank
; scenery, each colour drawn as one of the home tile borders' grey patterns:
; an aquarium home page after the world clock, and LoRaGotchi's menus (stats,
; DX visits, options) under Games > LoRaGotchi. The aquarium page redraws back-to-back
; (no 800 ms floor) with a full refresh every 5 minutes while it stays on
; screen. The fish uses LoRaGotchi's own save files (/lorafish.dat,
; /lorafish.cfg, NVS "lfgdx").
; Build: pio run -e meck_max_ble_fish
; ---------------------------------------------------------------------------
[env:meck_max_ble_fish]
extends = LilyGo_TDeck_Pro_Max
build_flags =
${LilyGo_TDeck_Pro_Max.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D MECK_BLE_TX_POWER=ESP_PWR_LVL_P15
-D OFFLINE_QUEUE_SIZE=256
-D MECK_WEB_READER=1
-D HAS_4G_MODEM=1
-D MECK_OTA_UPDATE=1
-D MECK_LORAFISH
-D FIRMWARE_VERSION='"Meck v1.15.1.MAX.FISH"'
build_src_filter = ${LilyGo_TDeck_Pro_Max.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro_Max.lib_deps}
densaugeo/base64 @ ~1.4.0
bitbank2/JPEGDEC
bitbank2/PNGdec
; MAX + WiFi companion (WiFi app bridging — no BLE, higher contact limit)
; WiFi credentials loaded from SD card (/web/wifi.cfg).
; Connect via MeshCore web app, meshcore.js, or Python CLI.