From 5740b9289f0cc03ea099a7895fcbf5706db10b08 Mon Sep 17 00:00:00 2001 From: xbmc4lyfe <273732874+xbmc4lyfe@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:45:18 -0500 Subject: [PATCH 1/2] Discover ATVV voice handles per remote (SHIELD support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The btmon reader matched hardcoded ATT handles (0x0042 status, 0x003f audio) that are specific to the UR02, so no other ATVV remote could be seen even though they all expose the same ab5e... service. Discover the control/audio/status characteristics per device at service start and derive their ATT value handles (BlueZ names the object by the declaration handle .../charNNNN; the value handle is declaration+1 — verified against the UR02: char0041->0x0042, char003e->0x003f, char003c->0x003d). The btmon reader now matches the discovered handles, so any device exposing the ATVV voice service works without hardcoding. Add a 'Voice remote MAC (blank = auto-detect)' setting to bind a specific remote when more than one is paired (e.g. UR02 vs SHIELD); blank auto-picks the first remote exposing the voice service. No behavior change for the UR02 (discovery returns its known handles). UNTESTED on the SHIELD — the declaration+1 handle convention is the main thing to confirm there; opening for later on-device testing. Co-Authored-By: Claude Fable 5 --- addon.xml | 2 +- changelog.txt | 7 + lib/audio_capture/ble.py | 137 ++++++++++++++---- .../resource.language.en_gb/strings.po | 4 + .../resource.language.es_es/strings.po | 4 + .../resource.language.fr_fr/strings.po | 4 + .../resource.language.he_il/strings.po | 4 + .../resource.language.it_it/strings.po | 6 +- resources/settings.xml | 13 ++ service.py | 88 ++++++----- 10 files changed, 200 insertions(+), 69 deletions(-) diff --git a/addon.xml b/addon.xml index 757465e..eaf8963 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/changelog.txt b/changelog.txt index 0a7243f..fdf9424 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,10 @@ +v3.0.1-beta.4 (2026-07-18) — UNTESTED, for SHIELD-remote testing +- Discover ATVV characteristic handles per remote instead of hardcoding + the UR02's, so other ATVV remotes (e.g. NVIDIA SHIELD) can work +- New setting: Voice remote MAC (blank = auto-detect the first remote + exposing the voice service) — use it when multiple remotes are paired +- No behavior change for the UR02 (discovery yields its known handles) + v3.0.1-beta.3 (2026-07-18) - Tap mic to start, tap again to stop (remote-native toggle); silence detection (1.5s) stops automatically if you just stop talking diff --git a/lib/audio_capture/ble.py b/lib/audio_capture/ble.py index ab46e9b..7db58ec 100644 --- a/lib/audio_capture/ble.py +++ b/lib/audio_capture/ble.py @@ -1,21 +1,23 @@ # -*- coding: utf8 -*- -"""BLE audio capture backend for Bluetrum-based voice remotes (UR-02, G20S). - -Uses btmon subprocess to capture raw HCI traffic for both mic button detection -and audio data. This bypasses Kodi's D-Bus/GLib main loop which blocks all -D-Bus signal dispatch to Python addons. - -Protocol (reverse-engineered from Ugoos UR-02): - - Voice service UUID: ab5e0001-5a21-4f05-bc7d-af01f617b664 - - Audio data char: ab5e0003 (ATT handle 0x003f) — IMA ADPCM, 8kHz mono - - Status char: ab5e0004 (ATT handle 0x0042) — mic button signals - - Control char: ab5e0002 (ATT handle 0x003d) — write ack - - Frame groups: 1 header (20B, 6B metadata + 14B audio) + 5 cont (20B) + 1 partial (8B) - -Voice activation signals on status handle 0x0042: - - Data[1]: ff — mic button pressed - - Data[1]: 00 — mic button released - - Data[4]: 040301XX — voice session ended +"""BLE audio capture backend for Android TV Voice (ATVV) remotes. + +The ab5e... service is Google's Android TV Voice Service, implemented by the +Ugoos UR-02, G20S clones and the NVIDIA SHIELD remote among others. Uses a +btmon subprocess to capture raw HCI traffic for both mic-button detection and +audio data, bypassing Kodi's D-Bus/GLib main loop which blocks D-Bus signal +dispatch to Python addons. + +Service characteristics (UUID is stable across remotes; ATT handles are NOT, +so they are discovered per device — see discover_voice_endpoints): + - Voice service: ab5e0001-5a21-4f05-bc7d-af01f617b664 + - Control char: ab5e0002 — host writes GET_CAPS / MIC_OPEN / MIC_CLOSE + - Audio data char:ab5e0003 — IMA ADPCM notifications, 8kHz mono + - Status char: ab5e0004 — mic button / session signals + +Status-char signals (host reacts to these in service.py's btmon reader): + - Data[1]: 08 — START_SEARCH (voice button pressed; host sends MIC_OPEN) + - Data[1]: ff — clone-firmware retry while waiting for MIC_OPEN + - Data[1]: 00 — AUDIO_END / button release """ import io @@ -44,9 +46,13 @@ VOICE_DATA_UUID = "ab5e0003-5a21-4f05-bc7d-af01f617b664" VOICE_STATUS_UUID = "ab5e0004-5a21-4f05-bc7d-af01f617b664" -# ATT attribute handles (from btmon HCI capture) -ATT_HANDLE_AUDIO = "003f" # voice data notifications -ATT_HANDLE_STATUS = "0042" # mic button press/release +# Default ATT value handles, from the UR02 btmon HCI capture. Used only as a +# fallback when live discovery fails; normally the handles are discovered per +# device (see discover_voice_endpoints) so other ATVV remotes — e.g. the +# NVIDIA SHIELD remote — work without hardcoding their handles. +ATT_HANDLE_AUDIO = "0x003f" # voice data notifications +ATT_HANDLE_STATUS = "0x0042" # mic button press/release +ATT_HANDLE_CONTROL = "0x003d" # MIC_OPEN / MIC_CLOSE / GET_CAPS writes BLE_SAMPLE_RATE = 8000 BLE_SAMPLE_WIDTH = 2 @@ -63,14 +69,15 @@ # --------------------------------------------------------------------------- -def _find_char_path(uuid, device_address=None): - # type: (str, Optional[str]) -> Optional[str] - """Find the D-Bus object path for a GATT characteristic by UUID. +def _scan_chars(device_address=None): + # type: (Optional[str]) -> List[tuple] + """Return [(char_path, uuid_lower), ...] for every GATT characteristic. Uses busctl subprocess calls: Kodi's bundled Python has no dbus module on CoreELEC/LibreELEC, and an in-process GLib main loop would be blocked by Kodi anyway (same reason audio capture goes through btmon). """ + found = [] # type: List[tuple] try: tree = subprocess.run( ["busctl", "tree", "org.bluez", "--list"], @@ -85,7 +92,7 @@ def _find_char_path(uuid, device_address=None): continue if device_address: addr_part = device_address.replace(":", "_").upper() - if addr_part not in path: + if addr_part not in path.upper(): continue prop = subprocess.run( ["busctl", "get-property", "org.bluez", path, @@ -95,14 +102,80 @@ def _find_char_path(uuid, device_address=None): ) # Output shape: s "ab5e0004-5a21-4f05-bc7d-af01f617b664" out = prop.stdout.decode("utf-8", "replace") - if '"' in out and out.split('"')[1].lower() == uuid.lower(): - return path + if '"' in out: + found.append((path, out.split('"')[1].lower())) except Exception as exc: if _KODI_AVAILABLE: xbmc.log( - "Voice keyboard BLE: busctl characteristic lookup failed: {}".format(exc), + "Voice keyboard BLE: busctl scan failed: {}".format(exc), xbmc.LOGWARNING, ) + return found + + +def _find_char_path(uuid, device_address=None): + # type: (str, Optional[str]) -> Optional[str] + """Find the D-Bus object path for a GATT characteristic by UUID.""" + uuid = uuid.lower() + for path, u in _scan_chars(device_address): + if u == uuid: + return path + return None + + +def _char_value_handle(char_path): + # type: (str) -> Optional[str] + """Return the ATT value handle for a characteristic path as '0xNNNN'. + + BlueZ names the object by the characteristic's declaration handle + (.../charNNNN); the value handle btmon reports for reads/writes and + notifications is the next one (declaration + 1). Verified against the + UR02: char0041 -> status notifications on 0x0042, char003e -> audio on + 0x003f, char003c -> control writes on 0x003d. This +1 convention is the + part most worth confirming on a new remote (e.g. the SHIELD). + """ + m = re.search(r"/char([0-9a-fA-F]{1,4})$", char_path) + if not m: + return None + return "0x{:04x}".format(int(m.group(1), 16) + 1) + + +def _device_of(char_path): + # type: (str) -> Optional[str] + """Return the /org/bluez/hciN/dev_XX prefix a characteristic belongs to.""" + m = re.search(r"(/org/bluez/hci\d+/dev_[0-9A-Fa-f_]+)", char_path) + return m.group(1) if m else None + + +def discover_voice_endpoints(device_address=None): + # type: (Optional[str]) -> Optional[dict] + """Discover the ATVV voice characteristics on a connected remote. + + Returns a dict of paths + value handles for the control/audio/status + characteristics of the first device exposing all three ATVV voice UUIDs, + or None if none is found. Pass device_address to bind a specific remote + when more than one is paired (e.g. UR02 vs SHIELD). Handles are returned + as '0xNNNN' strings ready to match against btmon output. + """ + by_dev = {} # type: dict + for path, uuid in _scan_chars(device_address): + dev = _device_of(path) + if dev: + by_dev.setdefault(dev, {})[uuid] = path + control = VOICE_CONTROL_UUID.lower() + audio = VOICE_DATA_UUID.lower() + status = VOICE_STATUS_UUID.lower() + for dev, umap in by_dev.items(): + if control in umap and audio in umap and status in umap: + return { + "device": dev, + "control_path": umap[control], + "control_handle": _char_value_handle(umap[control]), + "audio_path": umap[audio], + "audio_handle": _char_value_handle(umap[audio]), + "status_path": umap[status], + "status_handle": _char_value_handle(umap[status]), + } return None @@ -260,10 +333,12 @@ class BLEAudioCapture(AudioCaptureBase): IMA ADPCM to PCM, and returns 8kHz/16-bit/mono WAV. """ - def __init__(self, max_duration=MAX_DURATION_DEFAULT, device_address=None): - # type: (int, Optional[str]) -> None + def __init__(self, max_duration=MAX_DURATION_DEFAULT, device_address=None, + audio_handle=ATT_HANDLE_AUDIO): + # type: (int, Optional[str], str) -> None self._max_duration = max_duration self._device_address = device_address + self._audio_handle = audio_handle # '0xNNNN', matched in btmon output self._lock = threading.Lock() self._pcm_buffer = [] # type: List[bytes] self._framer = BLEPacketFramer() @@ -348,8 +423,8 @@ def _reader_loop(self): continue line = raw.decode("utf-8", errors="replace").rstrip() - # Match: previous line has "Handle: 0x003f", current has "Data[N]: hex" - if "Handle: 0x" + ATT_HANDLE_AUDIO in prev_line: + # Match: previous line has the audio value handle, current the data + if "Handle: " + self._audio_handle in prev_line: m = _DATA_RE.search(line) if m: hex_data = m.group(2) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index a35d0fb..30b3e76 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -102,3 +102,7 @@ msgstr "" msgctxt "#32115" msgid "Audio source" msgstr "Audio source" + +msgctxt "#32116" +msgid "Voice remote MAC (blank = auto-detect)" +msgstr "Voice remote MAC (blank = auto-detect)" diff --git a/resources/language/resource.language.es_es/strings.po b/resources/language/resource.language.es_es/strings.po index a04a716..5d21b4d 100644 --- a/resources/language/resource.language.es_es/strings.po +++ b/resources/language/resource.language.es_es/strings.po @@ -102,3 +102,7 @@ msgstr "" msgctxt "#32115" msgid "Audio source" msgstr "" + +msgctxt "#32116" +msgid "Voice remote MAC (blank = auto-detect)" +msgstr "MAC del mando de voz (vacío = automático)" diff --git a/resources/language/resource.language.fr_fr/strings.po b/resources/language/resource.language.fr_fr/strings.po index a82ca12..ac5e6c7 100644 --- a/resources/language/resource.language.fr_fr/strings.po +++ b/resources/language/resource.language.fr_fr/strings.po @@ -102,3 +102,7 @@ msgstr "" msgctxt "#32115" msgid "Audio source" msgstr "" + +msgctxt "#32116" +msgid "Voice remote MAC (blank = auto-detect)" +msgstr "MAC de la télécommande vocale (vide = auto)" diff --git a/resources/language/resource.language.he_il/strings.po b/resources/language/resource.language.he_il/strings.po index f9cb394..f128c07 100644 --- a/resources/language/resource.language.he_il/strings.po +++ b/resources/language/resource.language.he_il/strings.po @@ -105,3 +105,7 @@ msgstr "" msgctxt "#32115" msgid "Audio source" msgstr "" + +msgctxt "#32116" +msgid "Voice remote MAC (blank = auto-detect)" +msgstr "כתובת MAC של שלט קולי (ריק = אוטומטי)" diff --git a/resources/language/resource.language.it_it/strings.po b/resources/language/resource.language.it_it/strings.po index 6138cc4..6f7cce6 100644 --- a/resources/language/resource.language.it_it/strings.po +++ b/resources/language/resource.language.it_it/strings.po @@ -101,4 +101,8 @@ msgstr "" msgctxt "#32115" msgid "Audio source" -msgstr "" \ No newline at end of file +msgstr "" + +msgctxt "#32116" +msgid "Voice remote MAC (blank = auto-detect)" +msgstr "MAC telecomando vocale (vuoto = auto)" diff --git a/resources/settings.xml b/resources/settings.xml index f0cf5c6..8d0a06d 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -146,6 +146,19 @@ 32115 + + 0 + + + true + + + ble + + + 32116 + + diff --git a/service.py b/service.py index b44dc2c..808a327 100644 --- a/service.py +++ b/service.py @@ -46,6 +46,13 @@ def __init__(self): self._ble_backend = None self._stt_provider = None self._mic_button = None + self._ble_control_char_path = None + # ATT value handles matched in btmon output; discovered per remote in + # _start_ble_monitor, seeded with the UR02 defaults as a fallback. + from lib.audio_capture.ble import ATT_HANDLE_STATUS, ATT_HANDLE_AUDIO + + self._status_handle = ATT_HANDLE_STATUS + self._audio_handle = ATT_HANDLE_AUDIO def _get_state(self): with self._lock: @@ -313,45 +320,52 @@ def _start_ble_monitor(self): try: from lib.audio_capture.ble import ( BLEAudioCapture, - _find_char_path, + discover_voice_endpoints, _start_notify, - VOICE_STATUS_UUID, - VOICE_DATA_UUID, - VOICE_CONTROL_UUID, + _send_get_caps, ) - self._ble_backend = BLEAudioCapture() - self._stt_provider = get_stt_provider() - - # Find and enable notifications on status char (mic button) - status_path = _find_char_path(VOICE_STATUS_UUID) - if status_path is None: + # Optionally bind a specific remote by MAC when more than one + # ATVV remote is paired (e.g. UR02 vs SHIELD). Blank = auto-pick + # the first remote exposing the voice service. + addr = (xbmcaddon.Addon().getSetting("voice_device_address") or "").strip() + endpoints = discover_voice_endpoints(addr or None) + if endpoints is None: xbmc.log( - "Voice keyboard BLE: no status characteristic found", + "Voice keyboard BLE: no ATVV voice remote found " + "(is the remote connected?)", xbmc.LOGWARNING, ) return False - _start_notify(status_path) - - # Find and enable notifications on audio data char - audio_path = _find_char_path(VOICE_DATA_UUID) - if audio_path: - _start_notify(audio_path) - - # Find control char and pre-enable voice mode. - # Writing 0x01 at startup tells the remote the host is ready - # for voice data, so it enters voice mode immediately on button - # press instead of doing a quick press/release cycle. - control_path = _find_char_path(VOICE_CONTROL_UUID) - self._ble_control_char_path = control_path - if control_path: - from lib.audio_capture.ble import _send_get_caps - - _send_get_caps(control_path) - xbmc.log( - "Voice keyboard BLE: sent ATVV GET_CAPS handshake", - xbmc.LOGINFO, - ) + + # Handles are per-device; the btmon reader matches against these + # instead of hardcoded UR02 handles. + self._status_handle = endpoints["status_handle"] + self._audio_handle = endpoints["audio_handle"] + self._ble_control_char_path = endpoints["control_path"] + self._stt_provider = get_stt_provider() + self._ble_backend = BLEAudioCapture( + audio_handle=endpoints["audio_handle"] + ) + xbmc.log( + "Voice keyboard BLE: bound {} (status={}, audio={})".format( + endpoints["device"], + endpoints["status_handle"], + endpoints["audio_handle"], + ), + xbmc.LOGINFO, + ) + + # Enable notifications on status (mic button) and audio chars. + _start_notify(endpoints["status_path"]) + _start_notify(endpoints["audio_path"]) + + # ATVV handshake — some firmwares require GET_CAPS before they + # honor MIC_OPEN; harmless on those that don't. + _send_get_caps(endpoints["control_path"]) + xbmc.log( + "Voice keyboard BLE: sent ATVV GET_CAPS handshake", xbmc.LOGINFO + ) xbmc.log( "Voice keyboard BLE: monitoring for mic button press", xbmc.LOGINFO @@ -419,9 +433,11 @@ def _btmon_reader(self): continue line = raw.decode("utf-8", errors="replace").rstrip() + status_match = "Handle: " + self._status_handle in prev_line + # Voice button (ATVV): Data[1]: 08 = START_SEARCH; clone # firmwares (UR02) retry with ff while waiting for MIC_OPEN. - if "Handle: 0x0042" in prev_line and ( + if status_match and ( "Data[1]: ff" in line or "Data[1]: 08" in line ): if _KODI_AVAILABLE: @@ -437,14 +453,14 @@ def _btmon_reader(self): # - held >=2s: releasing the button is the stop signal # The remote's own end-of-stream 0x00 also arrives well past 2s, # so it stops the capture through the same path. - elif "Handle: 0x0042" in prev_line and "Data[1]: 00" in line: + elif status_match and "Data[1]: 00" in line: backend = self._ble_backend if backend is not None and getattr(backend, "_recording", False): if time.time() - self._last_activation_time >= 2.0: backend.signal_stream_end() - # Audio data: Handle 0x003f, Data[N]: hex - elif "Handle: 0x003f" in prev_line: + # Audio data: audio value handle, Data[N]: hex + elif "Handle: " + self._audio_handle in prev_line: m = data_re.search(line) if m: # If audio data arrives while idle, auto-trigger voice start. From ee92a9adcb021524e4083a2f450f5367940437f4 Mon Sep 17 00:00:00 2001 From: xbmc4lyfe <273732874+xbmc4lyfe@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:25:35 -0500 Subject: [PATCH 2/2] Address review: exact device match + notify failure handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit + Codex review on PR #3: - _scan_chars: match the exact /dev_/ path component instead of a substring, so a partial/ambiguous voice_device_address can't bind an unintended remote. Uppercase the whole component (incl. the 'dev_' literal) before comparing to path.upper() — a unit test caught that the naive form (lowercase 'dev_' vs path.upper()) never matches and would have broken discovery whenever a MAC was configured. - _start_notify: treat a nonzero dbus-send exit as failure, tolerating only the benign already-notifying / InProgress case. - _start_ble_monitor: return False if either status/audio notification fails to enable, so the retry loop stays alive instead of silently going deaf; GET_CAPS failure remains a non-fatal warning. - Document the known handle-only matching limitation in _btmon_reader: two identical-model remotes sharing ATT handles could cross-trip; full ACL-connection correlation is deferred until testable with two remotes. Verified offline: py_compile clean; unit tests cover exact-match (case-insensitive, partial rejection, other-device rejection) and the notify tolerance logic. Co-Authored-By: Claude Fable 5 --- lib/audio_capture/ble.py | 28 ++++++++++++++++++----- service.py | 49 ++++++++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/lib/audio_capture/ble.py b/lib/audio_capture/ble.py index 7db58ec..51b2f05 100644 --- a/lib/audio_capture/ble.py +++ b/lib/audio_capture/ble.py @@ -91,8 +91,14 @@ def _scan_chars(device_address=None): if "/char" not in path or "/desc" in path: continue if device_address: - addr_part = device_address.replace(":", "_").upper() - if addr_part not in path.upper(): + # Match the exact /dev_/ path component so a partial or + # ambiguous MAC can't bind an unintended remote. Uppercase the + # whole component (incl. the "dev_" literal) to compare against + # path.upper() — otherwise the lowercase literal never matches. + device_component = "/dev_{}/".format( + device_address.replace(":", "_") + ).upper() + if device_component not in path.upper(): continue prop = subprocess.run( ["busctl", "get-property", "org.bluez", path, @@ -181,9 +187,15 @@ def discover_voice_endpoints(device_address=None): def _start_notify(char_path): # type: (str) -> bool - """Call StartNotify on a GATT characteristic via dbus-send.""" + """Call StartNotify on a GATT characteristic via dbus-send. + + Returns True only if notifications are actually enabled. A nonzero + dbus-send exit means the call failed — except the benign case where the + characteristic is already notifying (BlueZ reports InProgress / "Already + notifying"), which is treated as success. + """ try: - subprocess.run( + proc = subprocess.run( [ "dbus-send", "--system", @@ -193,10 +205,14 @@ def _start_notify(char_path): "org.bluez.GattCharacteristic1.StartNotify", ], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, timeout=5, ) - return True + if proc.returncode == 0: + return True + err = (proc.stderr or b"").decode("utf-8", "replace").lower() + # Already-subscribed is fine — notifications are on regardless. + return "notifying" in err or "inprogress" in err except Exception: return False diff --git a/service.py b/service.py index 808a327..1f39e81 100644 --- a/service.py +++ b/service.py @@ -357,15 +357,30 @@ def _start_ble_monitor(self): ) # Enable notifications on status (mic button) and audio chars. - _start_notify(endpoints["status_path"]) - _start_notify(endpoints["audio_path"]) + # If either fails, don't claim success — returning False keeps the + # caller's retry loop alive instead of silently going deaf. + status_ready = _start_notify(endpoints["status_path"]) + audio_ready = _start_notify(endpoints["audio_path"]) + if not (status_ready and audio_ready): + xbmc.log( + "Voice keyboard BLE: failed to enable notifications " + "(status={}, audio={})".format(status_ready, audio_ready), + xbmc.LOGWARNING, + ) + return False # ATVV handshake — some firmwares require GET_CAPS before they - # honor MIC_OPEN; harmless on those that don't. - _send_get_caps(endpoints["control_path"]) - xbmc.log( - "Voice keyboard BLE: sent ATVV GET_CAPS handshake", xbmc.LOGINFO - ) + # honor MIC_OPEN; harmless on those that don't, so a failure here + # is only a warning and does not abort monitoring. + if _send_get_caps(endpoints["control_path"]): + xbmc.log( + "Voice keyboard BLE: sent ATVV GET_CAPS handshake", xbmc.LOGINFO + ) + else: + xbmc.log( + "Voice keyboard BLE: GET_CAPS handshake failed (continuing)", + xbmc.LOGWARNING, + ) xbmc.log( "Voice keyboard BLE: monitoring for mic button press", xbmc.LOGINFO @@ -411,13 +426,23 @@ def _btmon_reader_safe(self): def _btmon_reader(self): """Read btmon output, detect mic button and capture audio data. - Unified reader for the single shared btmon process. Handles: - - Handle 0x0042 + Data[1]: ff → mic button press → _on_ble_voice_start() - - Handle 0x003f + Data[N]: hex → audio data → BLE backend feed + Unified reader for the single shared btmon process. Matches the + per-remote value handles discovered in _start_ble_monitor: + - status handle + Data[1]: 08/ff → mic button press + - status handle + Data[1]: 00 → AUDIO_END / release + - audio handle + Data[N]: hex → audio data → BLE backend feed - Also auto-triggers voice start when audio data appears on 0x003f - while idle — this catches cases where the status notification (0x0042) + Also auto-triggers voice start when audio data appears on the audio + handle while idle — this catches cases where the status notification was too brief for btmon to capture. + + Known limitation: matching is by ATT value handle only. ATT handles + are unique only within one peripheral's GATT database, so two paired + remotes of the SAME model (identical handles) could cross-trip this + reader even when voice_device_address binds one of them. Remotes of + different models (e.g. UR02 vs SHIELD) use different handles and are + unaffected. Full fix needs correlating each btmon packet to its ACL + connection/device; deferred until it can be tested with two remotes. """ import re import select as _select