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..51b2f05 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"], @@ -84,8 +91,14 @@ def _find_char_path(uuid, 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: + # 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, @@ -95,22 +108,94 @@ 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 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", @@ -120,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 @@ -260,10 +349,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 +439,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..1f39e81 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,44 +320,66 @@ 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() + # 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 ATVV voice remote found " + "(is the remote connected?)", + xbmc.LOGWARNING, + ) + return False + + # 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, + ) - # Find and enable notifications on status char (mic button) - status_path = _find_char_path(VOICE_STATUS_UUID) - if status_path is None: + # Enable notifications on status (mic button) and audio chars. + # 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: no status characteristic found", + "Voice keyboard BLE: failed to enable notifications " + "(status={}, audio={})".format(status_ready, audio_ready), 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) + + # ATVV handshake — some firmwares require GET_CAPS before they + # 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, + "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( @@ -397,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 @@ -419,9 +458,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 +478,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.