Skip to content

fix: BLE service-completeness check false-fails on custom vendor UUID families - #115

Merged
gdoumen merged 1 commit into
mainfrom
fix/ble-vendor-uuid-mismatch-blocks-pairing
Aug 10, 2026
Merged

fix: BLE service-completeness check false-fails on custom vendor UUID families#115
gdoumen merged 1 commit into
mainfrom
fix/ble-vendor-uuid-mismatch-blocks-pairing

Conversation

@gdoumen

@gdoumen gdoumen commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • FIXES_BACKLOG Feature/reconnect #26 (PR fix: BLE FTMS pairing fails when a self-powered device has just powered on #110) added an exact-match check between a peripheral's advertised service UUIDs and its GATT-discovered ones, disconnecting on any mismatch. This broke real-device pairing for HRM devices that advertise one member of a private 128-bit vendor UUID family and expose a different member of the same family in GATT.
  • Confirmed in two independent production log occurrences: Wahoo TICKR FIT (A0260001-... announced vs A026EE01-... discovered) and Garmin HRM Pro+ (6A4E3E10-... announced vs 6A4E2401-... discovered). Both pairs share an identical last-96-bit vendor base UUID and differ only in the first 32 bits (the vendor's "assigned number" slot) — the same base-UUID-plus-assigned-number convention the Bluetooth SIG itself uses for 16-bit UUIDs against the shared Bluetooth Base UUID.
  • Added isSameServiceFamily() (src/ble/utils.ts): exact match for standard/SIG UUIDs (still a real completeness signal — two different SIG numbers are genuinely different services), last-96-bit match for custom 128-bit UUIDs. checkAnnouncedServices() (src/ble/base/peripheral.ts) now uses it instead of exact string equality; everything else about the original Feature/reconnect #26 fix (the early gate in subscribeSelected(), serviceDiscoveryIncomplete, disconnect-and-reset on a genuine mismatch) is unchanged.
  • The original self-powered-device registration race Feature/reconnect #26 targeted (e.g. the MRK-R15-D829 rower advertising 1826 before its GATT server finishes registering it) is unaffected — that's a standard SIG UUID genuinely absent from discovery, not a same-family variant.
  • Deliberately kept at the service-UUID level rather than checking presence of specific required characteristics: some FTMS devices don't implement every optional characteristic (e.g. the Feature flag), so a characteristic-level check risked a new false-positive class.

What changed

  • src/ble/utils.ts: new isSameServiceFamily(uuid1, uuid2).
  • src/ble/base/peripheral.ts: checkAnnouncedServices() matches via isSameServiceFamily() instead of exact equality.
  • src/ble/utils.unit.test.ts (new): 8 cases covering the comparator directly (standard match/mismatch, same-family custom match incl. both real production log pairs, genuinely-different-base custom mismatch, custom-vs-standard never matches, case-insensitivity).
  • src/ble/base/peripheral.unit.test.ts: existing FIXES_BACKLOG Feature/reconnect #26 suite retained (rower/FTMS race scenarios unchanged), plus TICKR FIT and HRM Pro+ regression repros using the real production log UUIDs, plus a negative case proving two custom UUIDs with a genuinely different base still fail.

Test plan

  • npm test — 1163 passed, 35 skipped (pre-existing skips, no change), 0 failed
  • npx tsc -p tsconfig.esm.json --noEmit — clean
  • Real-device pairing test: Wahoo TICKR FIT and/or Garmin HRM Pro+ HRM connects successfully
  • Real-device regression check: MRK-R15-D829 rower (or another self-powered FTMS device) power-on-race retry still works as before

… families

FIXES_BACKLOG #26's exact-match check between a peripheral's advertised
service UUIDs and its GATT-discovered ones required byte-for-byte equality,
which broke pairing for HRM devices (Wahoo TICKR FIT, Garmin HRM Pro+) that
advertise one member of a private 128-bit UUID family and expose a different
member of the same family in GATT - both share the same last-96-bit vendor
base, only the first 32 (assigned-number) bits differ, mirroring how the
Bluetooth SIG itself defines 16-bit UUIDs against the shared Bluetooth Base
UUID.

checkAnnouncedServices() now matches via isSameServiceFamily(): exact
equality for standard/SIG UUIDs (a real completeness signal - two different
SIG numbers are genuinely different services), last-96-bit match for custom
128-bit UUIDs. The original self-powered-device registration race (e.g. the
MRK-R15-D829 rower advertising 1826 before its GATT server finishes
registering it) is unaffected, since that case is a standard SIG UUID
genuinely absent from discovery, not a same-family variant.
@sonarqubecloud

Copy link
Copy Markdown

@gdoumen
gdoumen marked this pull request as ready for review August 10, 2026 21:05
@gdoumen
gdoumen merged commit 092cf63 into main Aug 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant