Skip to content

Fix Watch detection on BlueZ 5.x (btmon decodes manufacturer data)#2

Open
Mulkitis wants to merge 1 commit into
DavidSt49:mainfrom
Mulkitis:fix/btmon-bluez5-parser
Open

Fix Watch detection on BlueZ 5.x (btmon decodes manufacturer data)#2
Mulkitis wants to merge 1 commit into
DavidSt49:mainfrom
Mulkitis:fix/btmon-bluez5-parser

Conversation

@Mulkitis

Copy link
Copy Markdown

Bug report + fix: detection never fires on BlueZ 5.x (btmon decodes manufacturer data)

Prepared for submission to DavidSt49/watch-unlock-linux. Contains only the
BLE-detection parser defect and its fix. No host names, device addresses, or
keys are included.


Issue

Title: Watch is never detected on BlueZ 5.x — btmon never emits the 4c 00 10 05 bytes the parser matches

Environment (tested):

  • Ubuntu 24.04, GNOME 46 (X11)
  • BlueZ 5.72 (btmon from bluez 5.72)
  • Python 3.11, cryptography present
  • IRK correctly configured and independently verified to resolve the Watch's
    resolvable private addresses (so this is not an IRK/config problem)

Summary:
With a valid IRK and the Watch present, on-wrist, and unlocked, the daemon sits
at Waiting for Apple Watch... and never logs [DETECT]. No unlock ever occurs.

Root cause:
main() parses btmon output expecting the raw Apple manufacturer bytes on one
line:

hx = re.search(r'4c 00 10 (0[56]) ([0-9a-f]{2}) ([0-9a-f]{2})', line.lower())

btmon on BlueZ 5.x does not print raw manufacturer hex. It decodes Apple
manufacturer-specific data across multiple lines and never emits the 4c 00 …
byte string. A real advertising report looks like:

> HCI Event: LE Meta Event ...
        Address: XX:XX:XX:XX:XX:XX (Resolvable)
        ...
        Company: Apple, Inc. (76)
          Type: Unknown (16)          # 16 == 0x10 == Nearby Info
          Data: 25987c2c53            # payload; the "05" length byte is consumed
        RSSI: -59 dBm (0xc5)
  • The company id 4c 00 is rendered as Company: Apple, Inc. (76).
  • The Nearby Info type 10 is rendered as Type: Unknown (16) (decimal).
  • The length byte (05) is consumed by btmon; only the payload appears in Data:.

Consequently the regex matches zero times. In a ~30 s / 8476-line btmon
capture on the tested system, grep -c '4c 00 10 0[56]' returned 0, while the
Watch's Nearby Info was present throughout (as Company: Apple, Inc. (76) /
Type: Unknown (16) / Data:).

Impact:
On the tested BlueZ 5.72 build the daemon is non-functional out of the box —
detection can never trigger regardless of IRK correctness or Watch proximity.
Any BlueZ that decodes manufacturer data this way is affected. (The claim is
scoped to the tested version; other builds should be confirmed against their own
btmon output.)

Secondary issue found while fixing: btmon prints RSSI as the last
field of each advertising report, i.e. after the manufacturer data. The
original single-line design would therefore have associated a detection with the
previous report's RSSI. The fix below resolves this by firing the detection on
the report's RSSI line.


Proposed fix

Replace the single-line regex with a small per-report state machine that:

  1. tracks the Company: Apple, Inc. (76)Type … (16)Data: sequence,
  2. reads the status flags from payload[1] (same byte the original used), and
  3. defers the detection until the report's RSSI: line so the correct RSSI is used.

Behaviour, flag semantics (FLAG_AUTO_UNLOCK_ON = 0x80, FLAG_WATCH_LOCKED = 0x20), and downstream calls (is_my_watch, handle_watch_detection,
check_presence_timeout) are unchanged.

Diff (unlock_daemon.py, main())

-    mac = rssi = None
-
-    try:
-        while True:
-            ready, _, _ = select.select([sys.stdin], [], [], 1.0)
-            if ready:
-                line = sys.stdin.readline()
-                if not line:
-                    break
-
-                # Extract MAC
-                m = re.search(r'Address:\s*([0-9A-Fa-f:]{17})', line)
-                if m:
-                    mac = m.group(1)
-
-                # Extract RSSI
-                r = re.search(r'RSSI:\s*(-?\d+)', line)
-                if r:
-                    rssi = int(r.group(1))
-
-                # Parse Nearby Info
-                hx = re.search(r'4c 00 10 (0[56]) ([0-9a-f]{2}) ([0-9a-f]{2})', line.lower())
-                if hx and mac and rssi is not None:
-                    status_flags = int(hx.group(3), 16)
-
-                    if is_my_watch(mac):
-                        has_au_on = bool(status_flags & FLAG_AUTO_UNLOCK_ON)
-                        has_locked = bool(status_flags & FLAG_WATCH_LOCKED)
-
-                        if has_au_on and not has_locked:
-                            status = "on-wrist/unlocked"
-                        elif has_locked:
-                            status = "on-wrist/locked"
-                        else:
-                            status = "off-wrist"
-
-                        log.debug(f"[DETECT] Watch {mac} | RSSI:{rssi} | {status} | flags:0x{status_flags:02X}")
-
-                        handle_watch_detection(rssi, status_flags)
-
-                    mac = rssi = None
-
-            # Periodic presence check
-            check_presence_timeout()
+    # btmon (BlueZ 5.x) does not print raw manufacturer hex; it decodes Apple
+    # data across separate lines and never emits the "4c 00 10 05" byte string:
+    #   Company: Apple, Inc. (76)
+    #     Type: Unknown (16)      <- 16 == 0x10 == Nearby Info
+    #     Data: <payload hex>     <- payload[1] carries the auto-unlock flags
+    # RSSI is the last field of each advertising report, so a detection is held
+    # as pending when its Data line is parsed and fired when the report's RSSI
+    # line arrives, giving the correct RSSI for that report.
+    mac = None
+    apple = False
+    nearby = False
+    pending_flags = None
+
+    try:
+        while True:
+            ready, _, _ = select.select([sys.stdin], [], [], 1.0)
+            if ready:
+                line = sys.stdin.readline()
+                if not line:
+                    break
+
+                # New advertising report: capture address, reset per-report state
+                m = re.search(r'Address:\s*([0-9A-Fa-f:]{17})', line)
+                if m:
+                    mac = m.group(1)
+                    apple = False
+                    nearby = False
+                    pending_flags = None
+
+                # Track Apple Nearby Info (Type 16) block and capture its payload
+                if 'Company: Apple, Inc. (76)' in line:
+                    apple = True
+                    nearby = False
+                elif apple and re.search(r'Type:.*\(16\)', line):
+                    nearby = True
+                else:
+                    d = re.search(r'Data:\s*([0-9a-fA-F]+)', line)
+                    if d and nearby:
+                        payload = bytes.fromhex(d.group(1))
+                        nearby = False
+                        if len(payload) >= 2:
+                            pending_flags = payload[1]
+
+                # RSSI ends the report: fire the detection with the correct RSSI
+                r = re.search(r'RSSI:\s*(-?\d+)', line)
+                if r:
+                    rssi = int(r.group(1))
+                    if pending_flags is not None and mac and is_my_watch(mac):
+                        status_flags = pending_flags
+
+                        has_au_on = bool(status_flags & FLAG_AUTO_UNLOCK_ON)
+                        has_locked = bool(status_flags & FLAG_WATCH_LOCKED)
+
+                        if has_au_on and not has_locked:
+                            status = "on-wrist/unlocked"
+                        elif has_locked:
+                            status = "on-wrist/locked"
+                        else:
+                            status = "off-wrist"
+
+                        log.debug(f"[DETECT] Watch {mac} | RSSI:{rssi} | {status} | flags:0x{status_flags:02X}")
+
+                        handle_watch_detection(rssi, status_flags)
+                    pending_flags = None
+
+            # Periodic presence check
+            check_presence_timeout()

Verification

  • Before: grep -c '4c 00 10 0[56]' over a live btmon capture → 0
    matches; daemon never leaves Waiting for Apple Watch....

  • After: replaying the same captured btmon output through the patched
    parser yields, e.g.:

    [DETECT] Watch <rpa> | RSSI:-59 | on-wrist/unlocked | flags:0x98
    

    and the existing handle_watch_detection path proceeds as designed.

  • Flag decoding is unchanged and matches observed states:

    payload[1] AUTO_UNLOCK_ON (0x80) LOCKED (0x20) state
    0x98 set clear on-wrist / unlocked
    0x38 clear set on-wrist / locked
    0x18 clear clear off-wrist

Notes for maintainer

  • The match on Type: Unknown (16) depends on btmon's decoded label; if a
    future BlueZ labels Nearby Info differently, the Type: … (16) check is the
    line to revisit. A Company: Apple, Inc. (76) guard precedes it to avoid
    matching a (16) type from another vendor block.
  • No behavioural change to IRK handling, session control, or auto-lock.

The Nearby-Info parser matched the raw manufacturer byte string
`4c 00 10 05 ...` on a single btmon line. BlueZ 5.x btmon does not print
raw manufacturer hex; it decodes Apple data across separate lines
(`Company: Apple, Inc. (76)` / `Type: Unknown (16)` / `Data: <payload>`)
and never emits that byte string, so detection never fired (0 matches in
an 8476-line capture on BlueZ 5.72).

Replace the single-line regex with a per-report state machine that tracks
Company -> Type(16) -> Data, reads the status flags from payload[1], and
fires the detection on the report's trailing RSSI line (btmon prints RSSI
last), yielding the correct RSSI. IRK handling, flag semantics, and the
session-control path are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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