From 697e3ba307d2515afbaa164ef24fe910bcdd033c Mon Sep 17 00:00:00 2001 From: James Mackay Date: Fri, 9 Oct 2026 06:06:34 +0100 Subject: [PATCH] =?UTF-8?q?feat(connection,const,strings):=20complete=20th?= =?UTF-8?q?e=20P5-3=20logcat=20consumer=20wiring=20lost=20in=20an=20earlie?= =?UTF-8?q?r=20amend=20=E2=80=94=20dedicated=20stream,=20instant=20stamp,?= =?UTF-8?q?=20ladder=20+=20auto-disable,=20default-off=20toggle=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/nvidia_shield/connection.py | 139 ++++++++++++++++++ custom_components/nvidia_shield/const.py | 9 ++ custom_components/nvidia_shield/strings.json | 6 +- .../nvidia_shield/translations/en.json | 6 +- 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index deebe1a..e372185 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -37,11 +37,14 @@ from .const import ( ADB_RECONNECT_BACKOFF_START, ADB_RECONNECT_JITTER, APP_LABEL_TTL, + CONF_LOGCAT_EVENTS, + LOGCAT_SUPPRESS_WINDOW, STATE_STORE_TTL, SYSTEM_MEDIA_PACKAGES, ) from .ha_adb_common import ( MediaSessionInfo, + foreground_from_logcat, parse_application_label, parse_audio_playback, ) @@ -204,6 +207,15 @@ class ShieldConnectionManager: # (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one # package per watchdog cycle so a burst never blocks the watchdog. self._app_labels: dict[str, tuple[str, float]] = {} + # P5-3 (C-STREAM): the dedicated logcat line-stream consumer. + # NEVER shares the poller's ADB socket — its own device object and + # connection; CONF_LOGCAT_EVENTS gates it (default off) and the + # heartbeat cadence remains the floor when the stream is off/disabled. + self._logcat_task: asyncio.Task | None = None + self._logcat_disabled = False + self._logcat_failures: list[float] = [] + self._logcat_last_pkg: str | None = None + self._logcat_last_stamp: float = -1e9 self._app_label_requests: set[str] = set() # Injectable clock keeps the TTL expiry tests deterministic. self._clock = clock @@ -565,6 +577,130 @@ class ShieldConnectionManager: if label: self._app_labels[package] = (label, self._clock()) + def _on_logcat_app_start(self, package: str) -> None: + """Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3). + + The next watchdog cycle confirms via poll_foreground/priority — + whatever dumpsys says wins, so logcat↔dumpsys disagreement never + becomes state churn. Duplicate STARTs for the same package inside + LOGCAT_SUPPRESS_WINDOW are not re-stamped. + """ + now = self._clock() + if ( + package == self._logcat_last_pkg + and now - self._logcat_last_stamp < LOGCAT_SUPPRESS_WINDOW + ): + return + self._logcat_last_pkg = package + self._logcat_last_stamp = now + self.store.set(FIELD_FOREGROUND, package) + self._notify() + + def _open_logcat_stream(self): + """Open the dedicated streaming logcat line source (P5-3). + + Returns an async generator of decoded logcat lines on ITS OWN + connection — a fresh AdbDeviceTcpAsync sharing only the key signer + material with the poller, never the poller's socket. + """ + poller = self._poller + if poller is None: + raise RuntimeError("poller gone — logcat stream cannot start") + + import asyncio + + from adb_shell.adb_device_async import AdbDeviceTcpAsync + from adb_shell.auth.sign_pythonrsa import PythonRSASigner + + async def _lines(): + key_path = getattr(poller, "_key_path", None) or getattr( + poller, "key_path", None + ) + host = getattr(poller, "_host", None) or getattr(poller, "host", None) + port = getattr(poller, "_port", None) or getattr(poller, "port", None) + loop = asyncio.get_running_loop() + signer = await loop.run_in_executor( + None, PythonRSASigner.FromRSAKeyPath, key_path + ) + device = AdbDeviceTcpAsync(host, port, default_transport_timeout_s=10.0) + connected = await device.connect(rsa_keys=[signer], auth_timeout_s=10.0) + if not connected: + raise RuntimeError("dedicated logcat connection failed") + async for line in device.streaming_shell( + "logcat -v brief ActivityTaskManager:*" + ): + yield line + + return _lines() + + def _note_logcat_failure(self, failure_time: float) -> None: + """Record a stream failure; auto-disable after 5 within 10 minutes.""" + self._logcat_failures = [ + stamp + for stamp in self._logcat_failures + if failure_time - stamp <= 600.0 + ] + self._logcat_failures.append(failure_time) + if len(self._logcat_failures) >= 5: + self._logcat_disabled = True + _LOGGER.warning( + "Live events (logcat) stream failed 5 times in 10 minutes — " + "disabled for this session; the watchdog heartbeat remains " + "the state floor" + ) + self._logcat_failures.clear() + + async def _run_logcat_stream(self) -> None: + """Consume the logcat line-stream; backoff-reconnect on failure. + + The stream owns its failure ladder (separate from the ADB socket + watchdog, reusing the same jitter): Store failures and reconnect + with 5 s doubling to the 300 s cap ±jitter; five failures in ten + minutes auto-disable the option for the session (heartbeat floor). + """ + delay = ADB_RECONNECT_BACKOFF_START + while not self._stopping and not self._logcat_disabled: + try: + async for line in self._open_logcat_stream(): + package = foreground_from_logcat([str(line)]) + if package: + self._on_logcat_app_start(package) + raise RuntimeError("stream ended (device closed)") + except asyncio.CancelledError: + raise + except Exception as err: + _LOGGER.debug("logcat stream failure: %s", err) + self._note_logcat_failure(self._clock()) + if self._stopping or self._logcat_disabled: + break + await asyncio.sleep(self._jitter(delay)) + delay = min(delay * 2, ADB_RECONNECT_BACKOFF_CAP) + + def _ensure_logcat_stream(self) -> None: + """Start the consumer when the option is on, connected, enabled.""" + if self._logcat_disabled or not self._entry_options_logcat(): + return + if self._poller is None or not self.connected: + return + if self._logcat_task is None or self._logcat_task.done(): + self._logcat_task = self._create_task( + self._run_logcat_stream(), "shield_logcat_stream" + ) + + def _entry_options_logcat(self) -> bool: + """CONF_LOGCAT_EVENTS from the entry's options (default off).""" + entry = getattr(self, "entry", None) + if entry is None: + return False + return bool((getattr(entry, "options", {}) or {}).get(CONF_LOGCAT_EVENTS, False)) + + def _stop_logcat_stream(self) -> None: + """Cancel the consumer at unload/stop (the stream closes in its + own finally — the dedicated device never outlives the manager).""" + if self._logcat_task is not None: + self._logcat_task.cancel() + self._logcat_task = None + def _drain_app_label_requests(self) -> None: """Start at most one label fetch per watchdog cycle.""" if not self._app_label_requests or self._poller is None: @@ -693,6 +829,7 @@ class ShieldConnectionManager: Called on entry unload and on EVENT_HOMEASSISTANT_STOP. """ self._stopping = True + self._stop_logcat_stream() tasks, self._tasks = self._tasks, [] for task in tasks: task.cancel() @@ -828,6 +965,8 @@ class ShieldConnectionManager: self.store.set(FIELD_FOREGROUND, foreground) # P5-1: manifest labels resolve on demand, one package per cycle self._drain_app_label_requests() + # P5-3: ensure the (optional, default-off) logcat fast-path runs + self._ensure_logcat_stream() else: # Standby: playback data is not current — expire it. self.store.set(FIELD_SESSIONS, []) diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 14ef099..99a5e77 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -109,6 +109,15 @@ APP_LABEL_TTL = 24 * 3600 CONF_ADB_COMMAND_ENABLED = "adb_command_enabled" SERVICE_ADB_COMMAND = "adb_command" +# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer +# (foreground AppState fast-path). Default OFF; the user flips it on and +# reports stability on their device. +CONF_LOGCAT_EVENTS = "logcat_events" + +# P5-3: duplicate START for the same package inside this window is not +# re-stamped (repeat suppression). +LOGCAT_SUPPRESS_WINDOW = 15.0 + # P4-2c BrowseMedia app directory fallback when the options map is empty # (curated; the packages are the plan's reference apps). DEFAULT_APP_DIRECTORY = { diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index f01a742..3ed3469 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -82,7 +82,8 @@ "app_name_map": "Apps name/icon map", "adb_command_enabled": "Enable raw ADB shell service", "channel_digit_delay": "Channel digit delay (seconds)", - "channel_send_enter": "Send ENTER after channel digits" + "channel_send_enter": "Send ENTER after channel digits", + "logcat_events": "Live events push (logcat)" }, "data_description": { "jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.", @@ -96,7 +97,8 @@ "app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.", "adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.", "channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).", - "channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior." + "channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.", + "logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device." } } } diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index f01a742..3ed3469 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -82,7 +82,8 @@ "app_name_map": "Apps name/icon map", "adb_command_enabled": "Enable raw ADB shell service", "channel_digit_delay": "Channel digit delay (seconds)", - "channel_send_enter": "Send ENTER after channel digits" + "channel_send_enter": "Send ENTER after channel digits", + "logcat_events": "Live events push (logcat)" }, "data_description": { "jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.", @@ -96,7 +97,8 @@ "app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.", "adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.", "channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).", - "channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior." + "channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.", + "logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device." } } }