feat(connection,const,strings): complete the P5-3 logcat consumer wiring lost in an earlier amend — dedicated stream, instant stamp, ladder + auto-disable, default-off toggle strings
This commit is contained in:
1 parent
f863496ce5
commit
697e3ba307
4 files changed
+156
-4
No files matched your search
@@ -37,11 +37,14 @@ from .const import (
|
|||||||
ADB_RECONNECT_BACKOFF_START,
|
ADB_RECONNECT_BACKOFF_START,
|
||||||
ADB_RECONNECT_JITTER,
|
ADB_RECONNECT_JITTER,
|
||||||
APP_LABEL_TTL,
|
APP_LABEL_TTL,
|
||||||
|
CONF_LOGCAT_EVENTS,
|
||||||
|
LOGCAT_SUPPRESS_WINDOW,
|
||||||
STATE_STORE_TTL,
|
STATE_STORE_TTL,
|
||||||
SYSTEM_MEDIA_PACKAGES,
|
SYSTEM_MEDIA_PACKAGES,
|
||||||
)
|
)
|
||||||
from .ha_adb_common import (
|
from .ha_adb_common import (
|
||||||
MediaSessionInfo,
|
MediaSessionInfo,
|
||||||
|
foreground_from_logcat,
|
||||||
parse_application_label,
|
parse_application_label,
|
||||||
parse_audio_playback,
|
parse_audio_playback,
|
||||||
)
|
)
|
||||||
@@ -204,6 +207,15 @@ class ShieldConnectionManager:
|
|||||||
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
|
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
|
||||||
# package per watchdog cycle so a burst never blocks the watchdog.
|
# package per watchdog cycle so a burst never blocks the watchdog.
|
||||||
self._app_labels: dict[str, tuple[str, float]] = {}
|
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()
|
self._app_label_requests: set[str] = set()
|
||||||
# Injectable clock keeps the TTL expiry tests deterministic.
|
# Injectable clock keeps the TTL expiry tests deterministic.
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
@@ -565,6 +577,130 @@ class ShieldConnectionManager:
|
|||||||
if label:
|
if label:
|
||||||
self._app_labels[package] = (label, self._clock())
|
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:
|
def _drain_app_label_requests(self) -> None:
|
||||||
"""Start at most one label fetch per watchdog cycle."""
|
"""Start at most one label fetch per watchdog cycle."""
|
||||||
if not self._app_label_requests or self._poller is None:
|
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.
|
Called on entry unload and on EVENT_HOMEASSISTANT_STOP.
|
||||||
"""
|
"""
|
||||||
self._stopping = True
|
self._stopping = True
|
||||||
|
self._stop_logcat_stream()
|
||||||
tasks, self._tasks = self._tasks, []
|
tasks, self._tasks = self._tasks, []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -828,6 +965,8 @@ class ShieldConnectionManager:
|
|||||||
self.store.set(FIELD_FOREGROUND, foreground)
|
self.store.set(FIELD_FOREGROUND, foreground)
|
||||||
# P5-1: manifest labels resolve on demand, one package per cycle
|
# P5-1: manifest labels resolve on demand, one package per cycle
|
||||||
self._drain_app_label_requests()
|
self._drain_app_label_requests()
|
||||||
|
# P5-3: ensure the (optional, default-off) logcat fast-path runs
|
||||||
|
self._ensure_logcat_stream()
|
||||||
else:
|
else:
|
||||||
# Standby: playback data is not current — expire it.
|
# Standby: playback data is not current — expire it.
|
||||||
self.store.set(FIELD_SESSIONS, [])
|
self.store.set(FIELD_SESSIONS, [])
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ APP_LABEL_TTL = 24 * 3600
|
|||||||
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||||
SERVICE_ADB_COMMAND = "adb_command"
|
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
|
# P4-2c BrowseMedia app directory fallback when the options map is empty
|
||||||
# (curated; the packages are the plan's reference apps).
|
# (curated; the packages are the plan's reference apps).
|
||||||
DEFAULT_APP_DIRECTORY = {
|
DEFAULT_APP_DIRECTORY = {
|
||||||
|
|||||||
@@ -82,7 +82,8 @@
|
|||||||
"app_name_map": "Apps name/icon map",
|
"app_name_map": "Apps name/icon map",
|
||||||
"adb_command_enabled": "Enable raw ADB shell service",
|
"adb_command_enabled": "Enable raw ADB shell service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"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": {
|
"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.",
|
"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.",
|
"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.",
|
"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_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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,8 @@
|
|||||||
"app_name_map": "Apps name/icon map",
|
"app_name_map": "Apps name/icon map",
|
||||||
"adb_command_enabled": "Enable raw ADB shell service",
|
"adb_command_enabled": "Enable raw ADB shell service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"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": {
|
"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.",
|
"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.",
|
"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.",
|
"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_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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user