Initial release v0.1.0
Home Assistant custom integration for NVIDIA Shield TV that composes the Android TV Remote, Jellyfin, smart plug, and receiver entities into a single unified media player with ADB-based now-playing from any app. Part of the Media Zone ecosystem. Entities: - media_player: power (with smart plug fallback), transport, input, volume routed to receiver, rich stream info overlay when Jellyfin is active - sensor: DAG attributes and stream info - button: Pair ADB Config: - Two-step flow: basic setup + optional ADB pairing on the Shield - Configurable ADB port (default 5555) - Pick an existing Jellyfin media_player entity — stream info is read from the HA Jellyfin integration's own DataUpdateCoordinator, so no server URL / API key / extra config needed here - Privacy mode (adb_store_app_info=False) suppresses non-Jellyfin media details, showing only the app name Ecosystem: - Exposes media_dag_* attributes for ha-media-zone orchestration - Exposes media_dag_running_apps and async_bring_to_foreground for the Media Zone card's app switcher Shared ADB code lives in ltomes/ha-adb-common and is vendored at custom_components/nvidia_shield/ha_adb_common/ for HACS installs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
0ad51879ae
24 files changed
+2538
No files matched your search
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Claude Code internal notes
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Tooling caches
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 NVIDIA Shield Integration Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# NVIDIA Shield
|
||||||
|
|
||||||
|
[![hacs][hacs-badge]][hacs-url] [](LICENSE)
|
||||||
|
|
||||||
|
Enhanced NVIDIA Shield TV integration for Home Assistant. ADB-based media session tracking, Jellyfin rich metadata, power monitoring, and receiver coordination.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "NVIDIA Shield Integration"
|
||||||
|
ADB["ADB Media Session"] -->|now playing| Entity["media_player.nvidia_shield"]
|
||||||
|
JF["Jellyfin"] -->|rich metadata| Entity
|
||||||
|
ATV["Android TV Remote"] -->|power / commands| Entity
|
||||||
|
PWR["Smart Plug"] -->|power state| Entity
|
||||||
|
end
|
||||||
|
|
||||||
|
Entity -->|"media_dag_*"| MZ["Media Zone"]
|
||||||
|
|
||||||
|
style Entity fill:#76b900,color:#fff
|
||||||
|
style MZ fill:#1a73e8,color:#fff
|
||||||
|
style ADB fill:#444,color:#fff
|
||||||
|
style JF fill:#00a4dc,color:#fff
|
||||||
|
style ATV fill:#444,color:#fff
|
||||||
|
style PWR fill:#444,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **ADB Media Session** -- Now-playing from any app (PipePipe, Spotify, VLC, Plex, etc.), not just Jellyfin
|
||||||
|
- **Jellyfin Rich Metadata** -- Title, series, season/episode, duration, position, stream details (resolution, codec, HDR, audio format)
|
||||||
|
- **Power Monitoring** -- Smart plug for reliable on/off detection with auto-discovered sibling sensors (watts, voltage, current)
|
||||||
|
- **Receiver Coordination** -- Links to a receiver entity and input name
|
||||||
|
- **Intent Tracking** -- Commands reflect in HA state immediately, self-heal against actual device state
|
||||||
|
- **Privacy Mode** -- Show only app name without media details for non-Jellyfin apps
|
||||||
|
- **Controlled ADB Pairing** -- Dedicated pairing step in config flow + re-pair button entity
|
||||||
|
- **Media DAG Attributes** -- Full signal chain data for [Media Zone](https://github.com/ltomes/ha-media-zone) orchestration
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### HACS (recommended)
|
||||||
|
|
||||||
|
1. Open HACS > Integrations > three-dot menu > Custom repositories
|
||||||
|
2. Add `https://github.com/ltomes/ha-nvidia-shield` as an Integration
|
||||||
|
3. Search for "NVIDIA Shield" and install
|
||||||
|
4. Restart Home Assistant
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/` directory and restart.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||||
|
2. Select the Android TV Remote entity for your Shield
|
||||||
|
3. Enable ADB if you want now-playing from all apps
|
||||||
|
4. If ADB is enabled, the next step will pair -- approve the prompt on the Shield
|
||||||
|
5. Optionally configure Jellyfin, receiver, power switch
|
||||||
|
|
||||||
|
### ADB Pairing
|
||||||
|
|
||||||
|
ADB is paired during setup via a dedicated config flow step. If you need to re-pair later (e.g., after factory reset), use the **"Pair ADB"** button on the device page in HA.
|
||||||
|
|
||||||
|
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
- **Jellyfin Media Player** -- Auto-detected or manually selected
|
||||||
|
- **Receiver + Input** -- For Media Zone coordination
|
||||||
|
- **Power Switch** -- Smart plug entity
|
||||||
|
- **ADB Host/Port** -- Override auto-detection if needed
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
| Entity | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
||||||
|
| Current App | `sensor` | Currently running app |
|
||||||
|
| Now Playing | `sensor` | Formatted now-playing string |
|
||||||
|
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Shared ADB helpers live in [ha-adb-common](https://github.com/ltomes/ha-adb-common) and are also used by [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv). For HACS installs, a copy is **vendored** at `custom_components/nvidia_shield/ha_adb_common/` so end users need nothing extra. We plan to publish `ha-adb-common` to PyPI so it can be declared as a normal `manifest.json` requirement; until then, the vendored copy is the source consumed at runtime.
|
||||||
|
|
||||||
|
To hack on the shared code locally, clone [ha-adb-common](https://github.com/ltomes/ha-adb-common) as a sibling directory and run `uv sync --extra dev` — the `[tool.uv.sources]` section in `pyproject.toml` wires it up editable.
|
||||||
|
|
||||||
|
## Part of the Media Zone Ecosystem
|
||||||
|
|
||||||
|
This integration works standalone, but pairs with [ha-media-zone](https://github.com/ltomes/ha-media-zone) to create a unified zone controller:
|
||||||
|
|
||||||
|
- [ha-media-zone](https://github.com/ltomes/ha-media-zone) -- Orchestrates receiver + display + sources into one entity
|
||||||
|
- [ha-emotiva-mc1](https://github.com/ltomes/ha-emotiva-mc1) -- RS232 control for Emotiva MC1 receiver
|
||||||
|
- [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv) -- Hisense Google TV control via ADB + AirPlay
|
||||||
|
|
||||||
|
[hacs-badge]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg
|
||||||
|
[hacs-url]: https://github.com/hacs/integration
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""The Nvidia Shield integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Nvidia Shield from a config entry."""
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = entry
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Handle options update."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""ADB media session reader for NVIDIA Shield.
|
||||||
|
|
||||||
|
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific
|
||||||
|
`poll()` method that calls wake check + media session in sequence.
|
||||||
|
|
||||||
|
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
|
||||||
|
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DOMAIN = "nvidia_shield"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_adb_key(hass_config_dir: str) -> str:
|
||||||
|
"""Ensure an ADB key pair exists for the nvidia_shield integration."""
|
||||||
|
return _ensure_adb_key(hass_config_dir, DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
class AdbMediaSessionPoller(AdbClientBase):
|
||||||
|
"""ADB client for NVIDIA Shield — extends base with wake-state tracking."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int, key_path: str):
|
||||||
|
super().__init__(host, port, key_path)
|
||||||
|
|
||||||
|
async def poll(self, foreground_package: str | None = None) -> MediaSessionInfo | None:
|
||||||
|
"""Query the current media session. Returns parsed info or None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
foreground_package: The package name of the foreground app, used to
|
||||||
|
prefer its media session over stale sessions from other apps.
|
||||||
|
"""
|
||||||
|
return await self.poll_media_sessions(foreground_package)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Button entities for NVIDIA Shield."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||||
|
from .const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
if entry.options.get(CONF_ADB_ENABLED, False):
|
||||||
|
async_add_entities([NvidiaShieldPairAdbButton(hass, entry)])
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||||
|
"""Button to initiate or re-pair the ADB connection."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = "Pair ADB"
|
||||||
|
_attr_icon = "mdi:link-variant"
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self._entry = entry
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_pair_adb"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
|
name=self._entry.title,
|
||||||
|
manufacturer="NVIDIA",
|
||||||
|
model="Shield TV",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_host(self) -> str | None:
|
||||||
|
"""Resolve ADB host from config or Android TV entity."""
|
||||||
|
host = self._entry.options.get(CONF_ADB_HOST)
|
||||||
|
if host:
|
||||||
|
return host
|
||||||
|
|
||||||
|
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
if not atv_entity:
|
||||||
|
return None
|
||||||
|
|
||||||
|
registry = er.async_get(self.hass)
|
||||||
|
entity_entry = registry.async_get(atv_entity)
|
||||||
|
if not entity_entry or not entity_entry.config_entry_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||||
|
if config_entry:
|
||||||
|
return config_entry.data.get("host")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||||
|
host = self._resolve_host()
|
||||||
|
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
_LOGGER.error("Could not determine ADB host. Set it in integration options.")
|
||||||
|
return
|
||||||
|
|
||||||
|
key_path = await self.hass.async_add_executor_job(
|
||||||
|
ensure_adb_key, self.hass.config.config_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port)
|
||||||
|
|
||||||
|
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||||
|
if await poller.connect():
|
||||||
|
_LOGGER.info("ADB pairing successful with %s", host)
|
||||||
|
await poller.disconnect()
|
||||||
|
# Reload to start polling
|
||||||
|
await self.hass.config_entries.async_reload(self._entry.entry_id)
|
||||||
|
else:
|
||||||
|
_LOGGER.error(
|
||||||
|
"ADB pairing failed with %s. Make sure Network debugging is enabled "
|
||||||
|
"and you approved the prompt on the Shield.",
|
||||||
|
host,
|
||||||
|
)
|
||||||
|
await poller.disconnect()
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""Config flow for Nvidia Shield integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers import entity_registry as er, selector
|
||||||
|
|
||||||
|
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||||
|
from .const import (
|
||||||
|
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_JELLYFIN_ENTITY,
|
||||||
|
CONF_POWER_ENTITY,
|
||||||
|
CONF_RECEIVER_ENTITY,
|
||||||
|
CONF_RECEIVER_INPUT,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
DEFAULT_NAME,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
|
||||||
|
"""Read available inputs from a receiver's media_dag_input_map attribute."""
|
||||||
|
if not receiver_entity_id:
|
||||||
|
return []
|
||||||
|
state = hass.states.get(receiver_entity_id)
|
||||||
|
if not state:
|
||||||
|
return []
|
||||||
|
input_map = state.attributes.get(ATTR_MEDIA_DAG_INPUT_MAP, {})
|
||||||
|
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
||||||
|
"""Auto-detect the Jellyfin media_player entity for a device."""
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
device_name_lower = device_name.lower()
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
for entry in registry.entities.values():
|
||||||
|
if entry.domain != "media_player" or entry.platform != "jellyfin":
|
||||||
|
continue
|
||||||
|
if entry.disabled_by is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eid_lower = entry.entity_id.lower()
|
||||||
|
name_lower = (entry.original_name or "").lower()
|
||||||
|
|
||||||
|
if device_name_lower in eid_lower or device_name_lower in name_lower:
|
||||||
|
state = hass.states.get(entry.entity_id)
|
||||||
|
if state and state.state != "unavailable":
|
||||||
|
candidates.append(entry.entity_id)
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
if len(candidates) > 1:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Multiple Jellyfin entities found for %s: %s. Using first.",
|
||||||
|
device_name, candidates,
|
||||||
|
)
|
||||||
|
return candidates[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_selectors():
|
||||||
|
"""Build reusable selector instances."""
|
||||||
|
return {
|
||||||
|
"androidtv": selector.EntitySelector(
|
||||||
|
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
|
||||||
|
),
|
||||||
|
"media_player": selector.EntitySelector(
|
||||||
|
selector.EntitySelectorConfig(domain="media_player")
|
||||||
|
),
|
||||||
|
"receiver": selector.EntitySelector(
|
||||||
|
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
|
||||||
|
),
|
||||||
|
"power": selector.EntitySelector(
|
||||||
|
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Nvidia Shield."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._user_data = {}
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Step 1: Basic setup."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
androidtv_entity = user_input[CONF_ANDROIDTV_ENTITY]
|
||||||
|
|
||||||
|
await self.async_set_unique_id(androidtv_entity)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
# Auto-detect Jellyfin if not manually specified
|
||||||
|
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
||||||
|
if not jellyfin:
|
||||||
|
device_name = user_input.get("name", DEFAULT_NAME)
|
||||||
|
atv_state = self.hass.states.get(androidtv_entity)
|
||||||
|
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
||||||
|
|
||||||
|
for name in [device_name, atv_name]:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
found = _find_jellyfin_entity(self.hass, name)
|
||||||
|
if found:
|
||||||
|
jellyfin = found
|
||||||
|
_LOGGER.info("Auto-detected Jellyfin entity: %s", found)
|
||||||
|
break
|
||||||
|
|
||||||
|
self._user_data = {
|
||||||
|
"name": user_input.get("name", DEFAULT_NAME),
|
||||||
|
CONF_ANDROIDTV_ENTITY: androidtv_entity,
|
||||||
|
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
||||||
|
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
|
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
||||||
|
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
|
||||||
|
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False),
|
||||||
|
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
|
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
|
||||||
|
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
}
|
||||||
|
|
||||||
|
# If ADB is enabled, go to pairing step
|
||||||
|
if self._user_data[CONF_ADB_ENABLED]:
|
||||||
|
return await self.async_step_adb_pair()
|
||||||
|
|
||||||
|
return self._create_entry()
|
||||||
|
|
||||||
|
|
||||||
|
sel = _build_selectors()
|
||||||
|
|
||||||
|
schema_dict = {
|
||||||
|
vol.Required("name", default=DEFAULT_NAME): str,
|
||||||
|
vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
||||||
|
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||||
|
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# If there are any receivers with DAG input maps, show a text field
|
||||||
|
# for receiver input (will be a dropdown in options once receiver is set)
|
||||||
|
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||||
|
|
||||||
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||||
|
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(schema_dict),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_entry(self):
|
||||||
|
"""Create the config entry from stored user data."""
|
||||||
|
d = self._user_data
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=d["name"],
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]},
|
||||||
|
options={
|
||||||
|
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||||
|
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
|
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
|
||||||
|
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
|
||||||
|
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False),
|
||||||
|
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
|
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
||||||
|
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_adb_host(self) -> str | None:
|
||||||
|
"""Resolve ADB host from explicit config or the Android TV entity."""
|
||||||
|
host = self._user_data.get(CONF_ADB_HOST)
|
||||||
|
if host:
|
||||||
|
return host
|
||||||
|
|
||||||
|
atv_entity = self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
if not atv_entity:
|
||||||
|
return None
|
||||||
|
|
||||||
|
registry = er.async_get(self.hass)
|
||||||
|
entity_entry = registry.async_get(atv_entity)
|
||||||
|
if not entity_entry or not entity_entry.config_entry_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||||
|
if config_entry:
|
||||||
|
return config_entry.data.get("host")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_step_adb_pair(self, user_input=None):
|
||||||
|
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
host = self._resolve_adb_host()
|
||||||
|
port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
errors["base"] = "adb_no_host"
|
||||||
|
else:
|
||||||
|
key_path = await self.hass.async_add_executor_job(
|
||||||
|
ensure_adb_key, self.hass.config.config_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||||
|
connected = await poller.connect()
|
||||||
|
await poller.disconnect()
|
||||||
|
|
||||||
|
if connected:
|
||||||
|
return self._create_entry()
|
||||||
|
else:
|
||||||
|
errors["base"] = "adb_failed"
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="adb_pair",
|
||||||
|
data_schema=vol.Schema({}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry):
|
||||||
|
return NvidiaShieldOptionsFlow(config_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||||
|
"""Handle options for Nvidia Shield — single unified page."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry):
|
||||||
|
self._config_entry = config_entry
|
||||||
|
self._options = dict(config_entry.options)
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input=None):
|
||||||
|
"""Single-page options."""
|
||||||
|
if user_input is not None:
|
||||||
|
self._options.update(user_input)
|
||||||
|
return self.async_create_entry(title="", data=self._options)
|
||||||
|
|
||||||
|
sel = _build_selectors()
|
||||||
|
schema_dict = {}
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
||||||
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||||
|
if jf:
|
||||||
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf)
|
||||||
|
schema_dict[jf_key] = sel["media_player"]
|
||||||
|
|
||||||
|
# Receiver
|
||||||
|
recv = self._options.get(CONF_RECEIVER_ENTITY)
|
||||||
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
|
if recv:
|
||||||
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
||||||
|
schema_dict[recv_key] = sel["receiver"]
|
||||||
|
|
||||||
|
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||||
|
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
||||||
|
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
||||||
|
inputs = _get_receiver_inputs(self.hass, receiver)
|
||||||
|
|
||||||
|
if inputs:
|
||||||
|
input_sel = selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=inputs,
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
custom_value=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||||
|
if current_input:
|
||||||
|
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input)
|
||||||
|
schema_dict[input_key] = input_sel
|
||||||
|
else:
|
||||||
|
schema_dict[vol.Optional(
|
||||||
|
CONF_RECEIVER_INPUT, default=current_input
|
||||||
|
)] = str
|
||||||
|
|
||||||
|
# Power
|
||||||
|
pwr = self._options.get(CONF_POWER_ENTITY)
|
||||||
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||||
|
if pwr:
|
||||||
|
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
|
||||||
|
schema_dict[pwr_key] = sel["power"]
|
||||||
|
|
||||||
|
# ADB options
|
||||||
|
schema_dict[vol.Optional(
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
default=self._options.get(CONF_ADB_ENABLED, False),
|
||||||
|
)] = bool
|
||||||
|
schema_dict[vol.Optional(
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
|
)] = bool
|
||||||
|
|
||||||
|
# ADB host/port (optional overrides, shown when ADB is enabled)
|
||||||
|
adb_host = self._options.get(CONF_ADB_HOST, "")
|
||||||
|
schema_dict[vol.Optional(
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
default=adb_host,
|
||||||
|
)] = str
|
||||||
|
schema_dict[vol.Optional(
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
)] = int
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=vol.Schema(schema_dict),
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Constants for the Nvidia Shield integration."""
|
||||||
|
|
||||||
|
# Re-export shared Media DAG constants
|
||||||
|
from .ha_adb_common.const import ( # noqa: F401
|
||||||
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||||
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
DOMAIN = "nvidia_shield"
|
||||||
|
|
||||||
|
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
|
||||||
|
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
||||||
|
CONF_RECEIVER_ENTITY = "receiver_entity"
|
||||||
|
CONF_RECEIVER_INPUT = "receiver_input"
|
||||||
|
CONF_POWER_ENTITY = "power_entity"
|
||||||
|
CONF_ADB_ENABLED = "adb_enabled"
|
||||||
|
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
||||||
|
CONF_ADB_HOST = "adb_host"
|
||||||
|
CONF_ADB_PORT = "adb_port"
|
||||||
|
|
||||||
|
ADB_POLL_INTERVAL = 10 # seconds
|
||||||
|
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
||||||
|
DEFAULT_ADB_PORT = 5555
|
||||||
|
|
||||||
|
DEFAULT_NAME = "Nvidia Shield"
|
||||||
|
|
||||||
|
# Emotiva DAG attribute we read to get available inputs
|
||||||
|
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||||
|
|
||||||
|
# Known Jellyfin app package IDs
|
||||||
|
JELLYFIN_APP_IDS = [
|
||||||
|
"org.jellyfin.androidtv",
|
||||||
|
"org.jellyfin.mobile",
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Shared ADB utilities for Home Assistant custom integrations."""
|
||||||
|
|
||||||
|
from .adb_client import AdbClientBase
|
||||||
|
from .const import (
|
||||||
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||||
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
|
)
|
||||||
|
from .intent import PlaybackIntent
|
||||||
|
from .keys import ensure_adb_key
|
||||||
|
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||||
|
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
|
||||||
|
"ATTR_MEDIA_DAG_NOW_PLAYING",
|
||||||
|
"ATTR_MEDIA_DAG_RECEIVER_ENTITY",
|
||||||
|
"ATTR_MEDIA_DAG_RECEIVER_INPUT",
|
||||||
|
"ATTR_MEDIA_DAG_ROLE",
|
||||||
|
"ATTR_MEDIA_DAG_RUNNING_APPS",
|
||||||
|
"AdbClientBase",
|
||||||
|
"MediaSessionInfo",
|
||||||
|
"PlaybackIntent",
|
||||||
|
"ensure_adb_key",
|
||||||
|
"parse_all_media_sessions",
|
||||||
|
"parse_media_session",
|
||||||
|
"running_apps_from_sessions",
|
||||||
|
"running_apps_from_tasks",
|
||||||
|
]
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Base ADB client with shared connection management and commands.
|
||||||
|
|
||||||
|
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Standard Android media key codes
|
||||||
|
MEDIA_KEYCODES = {
|
||||||
|
"play": 126,
|
||||||
|
"pause": 127,
|
||||||
|
"play-pause": 85,
|
||||||
|
"stop": 86,
|
||||||
|
"next": 87,
|
||||||
|
"previous": 88,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEVICE_KEYCODES = {
|
||||||
|
"power": 26,
|
||||||
|
"sleep": 223,
|
||||||
|
"wakeup": 224,
|
||||||
|
"volume_up": 24,
|
||||||
|
"volume_down": 25,
|
||||||
|
"mute": 164,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
||||||
|
|
||||||
|
|
||||||
|
class AdbClientBase:
|
||||||
|
"""Base ADB client with connection management and common commands."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int, key_path: str):
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._key_path = key_path
|
||||||
|
self._device = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
# Media session state
|
||||||
|
self._last_info: MediaSessionInfo | None = None
|
||||||
|
self._all_sessions: list[MediaSessionInfo] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_info(self) -> MediaSessionInfo | None:
|
||||||
|
return self._last_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_sessions(self) -> list[MediaSessionInfo]:
|
||||||
|
return self._all_sessions
|
||||||
|
|
||||||
|
def clear_info(self) -> None:
|
||||||
|
"""Clear the cached media session info."""
|
||||||
|
self._last_info = None
|
||||||
|
self._all_sessions = []
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Connect to the device via ADB."""
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from adb_shell.adb_device_async import AdbDeviceTcpAsync
|
||||||
|
from adb_shell.auth.sign_pythonrsa import PythonRSASigner
|
||||||
|
|
||||||
|
# FromRSAKeyPath does blocking file I/O — run it in the default
|
||||||
|
# executor so we don't stall Home Assistant's event loop.
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
signer = await loop.run_in_executor(
|
||||||
|
None, PythonRSASigner.FromRSAKeyPath, self._key_path
|
||||||
|
)
|
||||||
|
self._device = AdbDeviceTcpAsync(self._host, self._port)
|
||||||
|
await self._device.connect(rsa_keys=[signer], auth_timeout_s=10)
|
||||||
|
self._connected = True
|
||||||
|
_LOGGER.info("ADB connected to %s:%d", self._host, self._port)
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("ADB connection failed: %s", err)
|
||||||
|
self._connected = False
|
||||||
|
self._device = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from the device."""
|
||||||
|
if self._device:
|
||||||
|
try:
|
||||||
|
await self._device.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._device = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def shell(self, cmd: str) -> str | None:
|
||||||
|
"""Run a shell command, reconnecting if needed."""
|
||||||
|
if not self._connected or not self._device:
|
||||||
|
if not await self.connect():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await self._device.shell(cmd)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("ADB shell failed: %s", err)
|
||||||
|
self._connected = False
|
||||||
|
self._device = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- Common commands --
|
||||||
|
|
||||||
|
async def send_keyevent(self, command: str) -> bool:
|
||||||
|
"""Send a key event via ADB input keyevent."""
|
||||||
|
keycode = ALL_KEYCODES.get(command)
|
||||||
|
if keycode is None:
|
||||||
|
_LOGGER.warning("Unknown keyevent command: %s", command)
|
||||||
|
return False
|
||||||
|
result = await self.shell(f"input keyevent {keycode}")
|
||||||
|
if result is not None:
|
||||||
|
_LOGGER.debug("ADB keyevent %d (%s)", keycode, command)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
async def send_media_command(self, command: str) -> bool:
|
||||||
|
"""Send a media command via ADB input keyevent."""
|
||||||
|
keycode = MEDIA_KEYCODES.get(command)
|
||||||
|
if keycode is None:
|
||||||
|
_LOGGER.warning("Unknown media command: %s", command)
|
||||||
|
return False
|
||||||
|
result = await self.shell(f"input keyevent {keycode}")
|
||||||
|
if result is not None:
|
||||||
|
_LOGGER.debug("ADB keyevent %d (%s)", keycode, command)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def bring_to_foreground(self, package: str) -> bool:
|
||||||
|
"""Bring an app to the foreground using monkey command."""
|
||||||
|
result = await self.shell(
|
||||||
|
f"monkey -p {package} -c android.intent.category.LAUNCHER 1 2>/dev/null"
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
_LOGGER.debug("Brought %s to foreground via monkey", package)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
async def is_awake(self) -> bool | None:
|
||||||
|
"""Check if the device screen is on / awake via ADB."""
|
||||||
|
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
||||||
|
if result:
|
||||||
|
return "Awake" in result
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- Media session polling --
|
||||||
|
|
||||||
|
async def poll_media_sessions(
|
||||||
|
self, foreground_package: str | None = None, head_lines: int = 200
|
||||||
|
) -> MediaSessionInfo | None:
|
||||||
|
"""Query media sessions. Stores all sessions and returns the best match."""
|
||||||
|
dump = await self.shell(
|
||||||
|
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
|
||||||
|
)
|
||||||
|
if dump is None:
|
||||||
|
return None
|
||||||
|
self._all_sessions = parse_all_media_sessions(dump)
|
||||||
|
self._last_info = parse_media_session(dump, foreground_package)
|
||||||
|
return self._last_info
|
||||||
|
|
||||||
|
# -- Running apps (recent visible tasks) --
|
||||||
|
|
||||||
|
async def poll_running_apps(self) -> list[dict]:
|
||||||
|
"""Get running apps from Android's recent tasks.
|
||||||
|
|
||||||
|
Returns a list of dicts with 'package' and 'foreground' keys,
|
||||||
|
ordered by recency (most recent first). Only includes visible
|
||||||
|
user apps, filtering out system UI, home, and invisible tasks.
|
||||||
|
"""
|
||||||
|
result = await self.shell(
|
||||||
|
"dumpsys activity recents 2>/dev/null "
|
||||||
|
"| grep -E 'Recent #|realActivity|visible='"
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
|
||||||
|
import re
|
||||||
|
apps = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
# System packages to exclude
|
||||||
|
_EXCLUDED = {
|
||||||
|
"com.android.systemui",
|
||||||
|
"com.android.tv.settings",
|
||||||
|
"com.nvidia.diagtools",
|
||||||
|
"android",
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in result.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Match recent task lines with visibility and package info
|
||||||
|
if "Recent #" in stripped and "visible=true" in stripped:
|
||||||
|
# Extract package from A=uid:package pattern
|
||||||
|
match = re.search(r"A=\d+:(\S+)", stripped)
|
||||||
|
if not match:
|
||||||
|
# Try I=package/activity pattern
|
||||||
|
match = re.search(r"I=(\S+)/", stripped)
|
||||||
|
if match:
|
||||||
|
pkg = match.group(1)
|
||||||
|
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."):
|
||||||
|
seen.add(pkg)
|
||||||
|
apps.append({"package": pkg})
|
||||||
|
|
||||||
|
return apps
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Shared Media DAG attribute keys.
|
||||||
|
|
||||||
|
These constants define the contract between source integrations (nvidia_shield,
|
||||||
|
hisense_tv, etc.) and the media_zone aggregator. All source integrations MUST
|
||||||
|
use these exact attribute keys so media_zone can read them uniformly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Role of this entity in the media DAG
|
||||||
|
ATTR_MEDIA_DAG_ROLE = "media_dag_role"
|
||||||
|
|
||||||
|
# Receiver this source is wired to (entity_id)
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY = "media_dag_receiver_entity"
|
||||||
|
|
||||||
|
# Which receiver input this source occupies
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
|
||||||
|
|
||||||
|
# Jellyfin media_player entity for rich metadata
|
||||||
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
|
||||||
|
|
||||||
|
# Now-playing dict: {state, title, media_type, series?, season?, episode?,
|
||||||
|
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?}
|
||||||
|
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing"
|
||||||
|
|
||||||
|
# List of running app dicts: [{package, state, title?, artist?}, ...]
|
||||||
|
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Playback intent tracking for optimistic UI updates.
|
||||||
|
|
||||||
|
Bridges the gap between instant commands and slow state polling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlaybackIntent:
|
||||||
|
"""A pending user intent for playback state.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
target_state: The MediaPlayerState we expect after the command.
|
||||||
|
command: The service/command name (for retries and logging).
|
||||||
|
created_at: Monotonic timestamp when the intent was created.
|
||||||
|
prior_state: The source state before the intent (for detecting external overrides).
|
||||||
|
retry_count: Number of retries attempted so far.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
|
||||||
|
command: str
|
||||||
|
created_at: float
|
||||||
|
prior_state: str | None = None
|
||||||
|
retry_count: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent":
|
||||||
|
return cls(
|
||||||
|
target_state=target_state,
|
||||||
|
command=command,
|
||||||
|
created_at=time.monotonic(),
|
||||||
|
prior_state=prior_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def age_seconds(self) -> float:
|
||||||
|
return round(time.monotonic() - self.created_at, 1)
|
||||||
|
|
||||||
|
def to_debug_dict(self) -> dict:
|
||||||
|
"""Serialize for the _intent debug attribute."""
|
||||||
|
return {
|
||||||
|
"target": self.target_state,
|
||||||
|
"command": self.command,
|
||||||
|
"retries": self.retry_count,
|
||||||
|
"age_s": self.age_seconds,
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""ADB key management — generation and migration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_adb_key(
|
||||||
|
hass_config_dir: str,
|
||||||
|
domain: str,
|
||||||
|
legacy_paths: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Ensure an ADB key pair exists. Migrate from legacy paths or generate if missing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hass_config_dir: Home Assistant config directory.
|
||||||
|
domain: Integration domain name (e.g. "nvidia_shield", "hisense_tv").
|
||||||
|
Used to namespace the key storage.
|
||||||
|
legacy_paths: Optional list of legacy key paths to check for migration,
|
||||||
|
in priority order. Avoids forcing device re-pairing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the private key file.
|
||||||
|
"""
|
||||||
|
key_dir = os.path.join(hass_config_dir, ".storage", domain)
|
||||||
|
os.makedirs(key_dir, exist_ok=True)
|
||||||
|
key_path = os.path.join(key_dir, "adbkey")
|
||||||
|
|
||||||
|
if os.path.exists(key_path):
|
||||||
|
return key_path
|
||||||
|
|
||||||
|
# Try migrating from legacy locations
|
||||||
|
all_legacy = list(legacy_paths or [])
|
||||||
|
# Always check the generic location as a last resort
|
||||||
|
generic = os.path.join(hass_config_dir, ".storage", "adbkey")
|
||||||
|
if generic not in all_legacy:
|
||||||
|
all_legacy.append(generic)
|
||||||
|
|
||||||
|
for legacy_key in all_legacy:
|
||||||
|
if os.path.exists(legacy_key):
|
||||||
|
shutil.copy2(legacy_key, key_path)
|
||||||
|
pub = legacy_key + ".pub"
|
||||||
|
if os.path.exists(pub):
|
||||||
|
shutil.copy2(pub, key_path + ".pub")
|
||||||
|
_LOGGER.info("Migrated ADB key from %s to %s", legacy_key, key_path)
|
||||||
|
return key_path
|
||||||
|
|
||||||
|
# Generate new key
|
||||||
|
from adb_shell.auth.keygen import keygen
|
||||||
|
|
||||||
|
keygen(key_path)
|
||||||
|
_LOGGER.info("Generated new ADB key at %s", key_path)
|
||||||
|
|
||||||
|
return key_path
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Android media session parsing.
|
||||||
|
|
||||||
|
Parses `dumpsys media_session` output from ADB to extract now-playing
|
||||||
|
information from any app using Android's MediaSession API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaSessionInfo:
|
||||||
|
"""Parsed media session data from Android."""
|
||||||
|
|
||||||
|
title: str | None = None
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
package: str | None = None
|
||||||
|
playing: bool = False
|
||||||
|
paused: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||||
|
"""Parse `dumpsys media_session` output for ALL active media sessions.
|
||||||
|
|
||||||
|
Returns a list of every active session that has metadata or a known
|
||||||
|
playback state, sorted: playing first, then paused, then others.
|
||||||
|
"""
|
||||||
|
if not dump:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sessions: list[MediaSessionInfo] = []
|
||||||
|
current_pkg: str | None = None
|
||||||
|
in_active = False
|
||||||
|
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
||||||
|
current_meta: MediaSessionInfo | None = None
|
||||||
|
|
||||||
|
for line in dump.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if "package=" in stripped:
|
||||||
|
pkg = stripped.split("package=", 1)
|
||||||
|
if len(pkg) > 1:
|
||||||
|
# New session block — save the previous one if valid
|
||||||
|
if in_active and current_meta:
|
||||||
|
current_meta.package = current_pkg
|
||||||
|
sessions.append(current_meta)
|
||||||
|
current_pkg = pkg[1].strip()
|
||||||
|
in_active = False
|
||||||
|
current_state = 0
|
||||||
|
current_meta = None
|
||||||
|
|
||||||
|
if "active=true" in stripped:
|
||||||
|
in_active = True
|
||||||
|
|
||||||
|
if in_active and "state=PlaybackState" in stripped:
|
||||||
|
if "state=3" in stripped:
|
||||||
|
current_state = 3
|
||||||
|
elif "state=2" in stripped:
|
||||||
|
current_state = 2
|
||||||
|
|
||||||
|
if in_active and "metadata: size=" in stripped and "description=" in stripped:
|
||||||
|
desc_start = stripped.find("description=")
|
||||||
|
if desc_start < 0:
|
||||||
|
continue
|
||||||
|
desc = stripped[desc_start + 12:]
|
||||||
|
parts = [p.strip() for p in desc.split(", ")]
|
||||||
|
|
||||||
|
info = MediaSessionInfo()
|
||||||
|
info.package = current_pkg
|
||||||
|
info.playing = current_state == 3
|
||||||
|
info.paused = current_state == 2
|
||||||
|
|
||||||
|
if len(parts) >= 1 and parts[0] not in ("null", ""):
|
||||||
|
info.title = parts[0]
|
||||||
|
if len(parts) >= 2 and parts[1] not in ("null", ""):
|
||||||
|
info.artist = parts[1]
|
||||||
|
if len(parts) >= 3 and parts[2] not in ("null", ""):
|
||||||
|
info.album = parts[2]
|
||||||
|
|
||||||
|
if info.title:
|
||||||
|
current_meta = info
|
||||||
|
|
||||||
|
# Don't forget the last session
|
||||||
|
if in_active and current_meta:
|
||||||
|
current_meta.package = current_pkg
|
||||||
|
sessions.append(current_meta)
|
||||||
|
|
||||||
|
# Sort: playing first, then paused, then others
|
||||||
|
def sort_key(s: MediaSessionInfo) -> int:
|
||||||
|
if s.playing:
|
||||||
|
return 0
|
||||||
|
if s.paused:
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
sessions.sort(key=sort_key)
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def parse_media_session(
|
||||||
|
dump: str, foreground_package: str | None = None
|
||||||
|
) -> MediaSessionInfo | None:
|
||||||
|
"""Parse `dumpsys media_session` output for the best active session.
|
||||||
|
|
||||||
|
Returns the best match from all active sessions:
|
||||||
|
1. The session whose package matches the foreground app (if provided)
|
||||||
|
2. The session that is currently playing (state=3)
|
||||||
|
3. The session that is paused (state=2)
|
||||||
|
4. Any active session with metadata
|
||||||
|
"""
|
||||||
|
sessions = parse_all_media_sessions(dump)
|
||||||
|
if not sessions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prefer the session matching the foreground app
|
||||||
|
if foreground_package:
|
||||||
|
for s in sessions:
|
||||||
|
if s.package == foreground_package:
|
||||||
|
return s
|
||||||
|
|
||||||
|
# Already sorted: playing > paused > other
|
||||||
|
return sessions[0]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Build the running apps list from recent tasks + media sessions.
|
||||||
|
|
||||||
|
Produces the media_dag_running_apps attribute value that media_zone reads.
|
||||||
|
Recent tasks (from dumpsys activity recents) provide the list of running apps.
|
||||||
|
Media sessions provide playback state for apps that have active sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .media_session import MediaSessionInfo
|
||||||
|
|
||||||
|
|
||||||
|
def running_apps_from_tasks(
|
||||||
|
recent_tasks: list[dict],
|
||||||
|
media_sessions: list[MediaSessionInfo] | None = None,
|
||||||
|
foreground_package: str | None = None,
|
||||||
|
) -> list[dict] | None:
|
||||||
|
"""Build a list of running app dicts from recent tasks + media sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recent_tasks: List of {"package": str} from ADB recent tasks.
|
||||||
|
media_sessions: Optional media sessions for playback state enrichment.
|
||||||
|
foreground_package: The current foreground app package.
|
||||||
|
|
||||||
|
Returns None if there are fewer than 2 apps (no point showing a switcher
|
||||||
|
with only one option).
|
||||||
|
"""
|
||||||
|
if not recent_tasks or len(recent_tasks) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Index media sessions by package for quick lookup
|
||||||
|
session_by_pkg: dict[str, MediaSessionInfo] = {}
|
||||||
|
if media_sessions:
|
||||||
|
for s in media_sessions:
|
||||||
|
if s.package:
|
||||||
|
session_by_pkg[s.package] = s
|
||||||
|
|
||||||
|
apps = []
|
||||||
|
for task in recent_tasks:
|
||||||
|
pkg = task["package"]
|
||||||
|
app: dict = {"package": pkg}
|
||||||
|
|
||||||
|
# Mark foreground app
|
||||||
|
if pkg == foreground_package:
|
||||||
|
app["foreground"] = True
|
||||||
|
|
||||||
|
# Enrich with media session data if available
|
||||||
|
session = session_by_pkg.get(pkg)
|
||||||
|
if session:
|
||||||
|
if session.playing:
|
||||||
|
app["state"] = "playing"
|
||||||
|
elif session.paused:
|
||||||
|
app["state"] = "paused"
|
||||||
|
else:
|
||||||
|
app["state"] = "idle"
|
||||||
|
if session.title:
|
||||||
|
app["title"] = session.title
|
||||||
|
if session.artist:
|
||||||
|
app["artist"] = session.artist
|
||||||
|
else:
|
||||||
|
app["state"] = "running"
|
||||||
|
|
||||||
|
apps.append(app)
|
||||||
|
|
||||||
|
return apps if len(apps) >= 2 else None
|
||||||
|
|
||||||
|
|
||||||
|
# Keep backward compat for any code still using this
|
||||||
|
def running_apps_from_sessions(sessions: list[MediaSessionInfo]) -> list[dict] | None:
|
||||||
|
"""Build running apps from media sessions only (legacy)."""
|
||||||
|
if not sessions:
|
||||||
|
return None
|
||||||
|
tasks = [{"package": s.package} for s in sessions if s.package]
|
||||||
|
return running_apps_from_tasks(tasks, sessions)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Jellyfin session stream-info reader.
|
||||||
|
|
||||||
|
Piggybacks on HA's built-in Jellyfin integration: its coordinator polls
|
||||||
|
/Sessions every 10s and exposes the parsed result on
|
||||||
|
``config_entry.runtime_data.data``. We reach across to that cached dict
|
||||||
|
by walking from the user-selected Jellyfin media_player entity back to
|
||||||
|
its owning Jellyfin config entry. Zero additional config, zero extra
|
||||||
|
HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_streams(item: dict) -> dict | None:
|
||||||
|
"""Extract friendly video/audio labels from a Jellyfin NowPlayingItem."""
|
||||||
|
streams = item.get("MediaStreams", [])
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
for stream in streams:
|
||||||
|
stype = stream.get("Type", "")
|
||||||
|
|
||||||
|
if stype == "Video" and "video" not in result:
|
||||||
|
width = stream.get("Width", 0)
|
||||||
|
height = stream.get("Height", 0)
|
||||||
|
codec = (stream.get("Codec") or "").upper()
|
||||||
|
display = stream.get("DisplayTitle", "")
|
||||||
|
|
||||||
|
if height >= 2160:
|
||||||
|
res = "4K"
|
||||||
|
elif height >= 1080:
|
||||||
|
res = "1080p"
|
||||||
|
elif height >= 720:
|
||||||
|
res = "720p"
|
||||||
|
elif height > 0:
|
||||||
|
res = f"{height}p"
|
||||||
|
else:
|
||||||
|
res = None
|
||||||
|
|
||||||
|
result["video"] = {
|
||||||
|
"resolution": res,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"codec": codec,
|
||||||
|
"display": display,
|
||||||
|
"hdr": "HDR" in display or "DV" in display or "Dolby Vision" in display,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif stype == "Audio" and "audio" not in result:
|
||||||
|
codec = (stream.get("Codec") or "").upper()
|
||||||
|
layout = stream.get("ChannelLayout", "")
|
||||||
|
display = stream.get("DisplayTitle", "")
|
||||||
|
|
||||||
|
if "atmos" in display.lower():
|
||||||
|
audio_label = "Atmos"
|
||||||
|
elif "truehd" in codec.lower():
|
||||||
|
audio_label = "TrueHD"
|
||||||
|
elif "dts" in codec.lower():
|
||||||
|
audio_label = "DTS-HD" if "hd" in display.lower() else "DTS"
|
||||||
|
elif "eac3" in codec.lower() or "e-ac-3" in codec.lower():
|
||||||
|
audio_label = "DD+"
|
||||||
|
elif "ac3" in codec.lower():
|
||||||
|
audio_label = "DD"
|
||||||
|
elif "aac" in codec.lower():
|
||||||
|
audio_label = "AAC"
|
||||||
|
elif "flac" in codec.lower():
|
||||||
|
audio_label = "FLAC"
|
||||||
|
else:
|
||||||
|
audio_label = codec
|
||||||
|
|
||||||
|
result["audio"] = {
|
||||||
|
"codec": codec,
|
||||||
|
"channels": layout,
|
||||||
|
"label": audio_label,
|
||||||
|
"display": display,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jellyfin_coordinator(hass: HomeAssistant, jellyfin_entity_id: str) -> Any:
|
||||||
|
"""Resolve the Jellyfin integration's data-update coordinator for the
|
||||||
|
entity the user picked. Returns the coordinator, or None if the
|
||||||
|
entity is not registered / its config entry has no runtime_data yet."""
|
||||||
|
if not jellyfin_entity_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
entity_entry = registry.async_get(jellyfin_entity_id)
|
||||||
|
if not entity_entry or not entity_entry.config_entry_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||||
|
if not config_entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return getattr(config_entry, "runtime_data", None)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_session_media_info(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
jellyfin_entity_id: str,
|
||||||
|
device_name: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Find stream info for the session whose DeviceName matches ``device_name``.
|
||||||
|
|
||||||
|
Reads from the already-authenticated Jellyfin coordinator — no new
|
||||||
|
HTTP calls, no credentials in this integration's config.
|
||||||
|
"""
|
||||||
|
coordinator = _get_jellyfin_coordinator(hass, jellyfin_entity_id)
|
||||||
|
if coordinator is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sessions = getattr(coordinator, "data", None) or {}
|
||||||
|
|
||||||
|
device_lower = device_name.lower()
|
||||||
|
for sess in sessions.values():
|
||||||
|
sess_device = (sess.get("DeviceName") or "").lower()
|
||||||
|
if device_lower not in sess_device:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = sess.get("NowPlayingItem")
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return _parse_streams(item)
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"domain": "nvidia_shield",
|
||||||
|
"name": "NVIDIA Shield",
|
||||||
|
"codeowners": ["@ltomes"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"documentation": "https://github.com/ltomes/ha-nvidia-shield",
|
||||||
|
"issue_tracker": "https://github.com/ltomes/ha-nvidia-shield/issues",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"requirements": ["adb-shell>=0.4.0"],
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
"""Media player entity for Nvidia Shield.
|
||||||
|
|
||||||
|
State architecture:
|
||||||
|
- Power/availability: androidtv_remote (local push, fast)
|
||||||
|
- Playback state: Jellyfin (polling, slow 10-30s)
|
||||||
|
- Metadata: Jellyfin attributes
|
||||||
|
- ALL commands: androidtv_remote (Jellyfin is read-only)
|
||||||
|
|
||||||
|
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
||||||
|
1. User presses pause → intent recorded → UI shows paused immediately
|
||||||
|
2. Command sent to androidtv_remote → Shield pauses on screen
|
||||||
|
3. 10-30s later Jellyfin confirms paused → intent cleared
|
||||||
|
4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import (
|
||||||
|
MediaPlayerDeviceClass,
|
||||||
|
MediaPlayerEntity,
|
||||||
|
MediaPlayerEntityFeature,
|
||||||
|
MediaPlayerState,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ADB_POLL_INTERVAL,
|
||||||
|
ADB_RETRY_INTERVAL,
|
||||||
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||||
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_JELLYFIN_ENTITY,
|
||||||
|
CONF_POWER_ENTITY,
|
||||||
|
CONF_RECEIVER_ENTITY,
|
||||||
|
CONF_RECEIVER_INPUT,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||||
|
from .jellyfin import fetch_session_media_info
|
||||||
|
from .power import discover_power_siblings
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INTENT_TIMEOUT = 10 # seconds before self-heal fires
|
||||||
|
MAX_RETRIES = 1
|
||||||
|
|
||||||
|
# Map service names to the MediaPlayerState we expect after the command
|
||||||
|
SERVICE_TO_STATE = {
|
||||||
|
"media_pause": MediaPlayerState.PAUSED,
|
||||||
|
"media_play": MediaPlayerState.PLAYING,
|
||||||
|
"media_stop": MediaPlayerState.IDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
_BASE_FEATURES = (
|
||||||
|
MediaPlayerEntityFeature.TURN_ON
|
||||||
|
| MediaPlayerEntityFeature.TURN_OFF
|
||||||
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||||
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||||
|
)
|
||||||
|
|
||||||
|
_PLAYBACK_FEATURES = (
|
||||||
|
MediaPlayerEntityFeature.PLAY
|
||||||
|
| MediaPlayerEntityFeature.PAUSE
|
||||||
|
| MediaPlayerEntityFeature.STOP
|
||||||
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||||
|
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||||
|
|
||||||
|
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self._entry = entry
|
||||||
|
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
||||||
|
self._unsub_listeners: list = []
|
||||||
|
|
||||||
|
# Intent tracking
|
||||||
|
self._intent: PlaybackIntent | None = None
|
||||||
|
self._intent_timer_unsub: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
|
# Power monitoring — discovered from power switch entity
|
||||||
|
self._power_sensors: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Jellyfin media stream info (resolution, codec, etc.)
|
||||||
|
self._media_info: dict | None = None
|
||||||
|
|
||||||
|
# ADB media session, wake state, and running apps
|
||||||
|
self._adb_poller: AdbMediaSessionPoller | None = None
|
||||||
|
self._adb_poll_unsub: CALLBACK_TYPE | None = None
|
||||||
|
self._adb_awake: bool | None = None
|
||||||
|
self._running_apps: list[dict] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _jellyfin_entity(self) -> str | None:
|
||||||
|
return self._entry.options.get(CONF_JELLYFIN_ENTITY) or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _receiver_entity(self) -> str | None:
|
||||||
|
return self._entry.options.get(CONF_RECEIVER_ENTITY) or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _receiver_input(self) -> str | None:
|
||||||
|
return self._entry.options.get(CONF_RECEIVER_INPUT) or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
|
name=self._entry.title,
|
||||||
|
manufacturer="NVIDIA",
|
||||||
|
model="Shield TV",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _power_entity(self) -> str | None:
|
||||||
|
return self._entry.options.get(CONF_POWER_ENTITY) or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _adb_enabled(self) -> bool:
|
||||||
|
return self._entry.options.get(CONF_ADB_ENABLED, False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _adb_store_info(self) -> bool:
|
||||||
|
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
# Discover power sensor siblings from the power switch
|
||||||
|
if self._power_entity:
|
||||||
|
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
||||||
|
|
||||||
|
# Set up ADB media session poller
|
||||||
|
if self._adb_enabled:
|
||||||
|
await self._setup_adb()
|
||||||
|
|
||||||
|
entities = [self._androidtv_entity]
|
||||||
|
if self._jellyfin_entity:
|
||||||
|
entities.append(self._jellyfin_entity)
|
||||||
|
if self._power_entity:
|
||||||
|
entities.append(self._power_entity)
|
||||||
|
self._unsub_listeners.append(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass, entities, self._on_source_change
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
for unsub in self._unsub_listeners:
|
||||||
|
unsub()
|
||||||
|
self._unsub_listeners.clear()
|
||||||
|
self._clear_intent()
|
||||||
|
if self._adb_poll_unsub:
|
||||||
|
self._adb_poll_unsub()
|
||||||
|
self._adb_poll_unsub = None
|
||||||
|
if self._adb_poller:
|
||||||
|
await self._adb_poller.disconnect()
|
||||||
|
|
||||||
|
# ── Intent management ──
|
||||||
|
|
||||||
|
def _set_intent(self, service: str, target: MediaPlayerState) -> None:
|
||||||
|
"""Record a new playback intent. Replaces any existing intent."""
|
||||||
|
# Cancel existing timer
|
||||||
|
if self._intent_timer_unsub:
|
||||||
|
self._intent_timer_unsub()
|
||||||
|
|
||||||
|
# Capture current Jellyfin state before the intent
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
prior = jf.state if jf else None
|
||||||
|
|
||||||
|
self._intent = PlaybackIntent.create(
|
||||||
|
target_state=target.value,
|
||||||
|
command=service,
|
||||||
|
prior_state=prior,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schedule self-heal timeout
|
||||||
|
self._intent_timer_unsub = async_call_later(
|
||||||
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug("Intent set: %s → %s (prior JF: %s)", service, target, prior)
|
||||||
|
|
||||||
|
def _clear_intent(self) -> None:
|
||||||
|
"""Clear the pending intent and its timer."""
|
||||||
|
if self._intent_timer_unsub:
|
||||||
|
self._intent_timer_unsub()
|
||||||
|
self._intent_timer_unsub = None
|
||||||
|
self._intent = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _on_intent_timeout(self, _now) -> None:
|
||||||
|
"""Self-heal: intent wasn't confirmed by Jellyfin in time."""
|
||||||
|
intent = self._intent
|
||||||
|
if intent is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
jf_state = jf.state if jf else None
|
||||||
|
|
||||||
|
# Check if Jellyfin caught up during the timeout window
|
||||||
|
if jf_state == intent.target_state:
|
||||||
|
_LOGGER.debug("Intent %s confirmed at timeout check", intent.command)
|
||||||
|
self._clear_intent()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Retry once
|
||||||
|
if intent.retry_count < MAX_RETRIES:
|
||||||
|
intent.retry_count += 1
|
||||||
|
intent.created_at = time.monotonic()
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
|
||||||
|
intent.command, jf_state, intent.target_state, intent.retry_count,
|
||||||
|
)
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self._cmd(intent.command)
|
||||||
|
)
|
||||||
|
self._intent_timer_unsub = async_call_later(
|
||||||
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Exhausted retries — surrender to Jellyfin truth
|
||||||
|
_LOGGER.error(
|
||||||
|
"Intent %s failed after %d retries (JF=%s), falling back to truth",
|
||||||
|
intent.command, MAX_RETRIES, jf_state,
|
||||||
|
)
|
||||||
|
self._clear_intent()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
# ── State change listener ──
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _on_source_change(self, event: Event) -> None:
|
||||||
|
"""React to state changes in androidtv or Jellyfin."""
|
||||||
|
entity_id = event.data.get("entity_id")
|
||||||
|
new_state_obj = event.data.get("new_state")
|
||||||
|
|
||||||
|
# Power off clears any intent — device is off, intent is moot
|
||||||
|
if entity_id in (self._androidtv_entity, self._power_entity) and new_state_obj:
|
||||||
|
if not self._is_power_on():
|
||||||
|
if self._intent:
|
||||||
|
_LOGGER.debug("Intent cleared: device powered off")
|
||||||
|
self._clear_intent()
|
||||||
|
|
||||||
|
# Jellyfin state change — check for intent confirmation and fetch media info
|
||||||
|
if entity_id == self._jellyfin_entity and new_state_obj:
|
||||||
|
new_jf = new_state_obj.state
|
||||||
|
|
||||||
|
# Intent confirmation
|
||||||
|
if self._intent:
|
||||||
|
intent = self._intent
|
||||||
|
if new_jf == intent.target_state:
|
||||||
|
_LOGGER.debug("Intent %s confirmed by Jellyfin", intent.command)
|
||||||
|
self._clear_intent()
|
||||||
|
elif new_jf not in (
|
||||||
|
intent.prior_state, "unavailable", "unknown", None
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Intent %s overridden by external action (JF=%s)",
|
||||||
|
intent.command, new_jf,
|
||||||
|
)
|
||||||
|
self._clear_intent()
|
||||||
|
|
||||||
|
# Fetch media stream info when playing
|
||||||
|
if new_jf in (STATE_PLAYING, STATE_PAUSED):
|
||||||
|
entity_picture = new_state_obj.attributes.get("entity_picture", "")
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self._fetch_media_info(entity_picture)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._media_info = None
|
||||||
|
|
||||||
|
|
||||||
|
# Always re-evaluate state
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
# ── Helpers ──
|
||||||
|
|
||||||
|
def _read(self, entity_id: str | None):
|
||||||
|
if not entity_id:
|
||||||
|
return None
|
||||||
|
return self.hass.states.get(entity_id)
|
||||||
|
|
||||||
|
def _discover_adb_host(self) -> str | None:
|
||||||
|
"""Auto-detect ADB host from the androidtv_remote config entry for our entity."""
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
registry = er.async_get(self.hass)
|
||||||
|
entity_entry = registry.async_get(self._androidtv_entity)
|
||||||
|
if not entity_entry or not entity_entry.config_entry_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||||
|
if config_entry:
|
||||||
|
return config_entry.data.get("host")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _setup_adb(self) -> None:
|
||||||
|
"""Initialize ADB connection and start polling."""
|
||||||
|
try:
|
||||||
|
# Only auto-connect if ADB was previously paired (key exists)
|
||||||
|
import os
|
||||||
|
key_dir = os.path.join(self.hass.config.config_dir, ".storage", DOMAIN)
|
||||||
|
key_file = os.path.join(key_dir, "adbkey")
|
||||||
|
if not await self.hass.async_add_executor_job(os.path.exists, key_file):
|
||||||
|
_LOGGER.info(
|
||||||
|
"ADB key not found — skipping auto-connect. "
|
||||||
|
"Use the Pair ADB button or reconfigure the integration."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get device IP: explicit config > auto-detect from androidtv_remote
|
||||||
|
host = self._entry.options.get(CONF_ADB_HOST)
|
||||||
|
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
host = self._discover_adb_host()
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
_LOGGER.warning("Could not determine ADB host. Set it in integration options.")
|
||||||
|
return
|
||||||
|
|
||||||
|
key_path = key_file
|
||||||
|
self._adb_poller = AdbMediaSessionPoller(host, port, key_path)
|
||||||
|
|
||||||
|
if await self._adb_poller.connect():
|
||||||
|
# Start periodic polling
|
||||||
|
self._adb_poll_unsub = async_call_later(
|
||||||
|
self.hass, ADB_POLL_INTERVAL, self._adb_poll_callback
|
||||||
|
)
|
||||||
|
_LOGGER.info("ADB media session polling started for %s", host)
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ADB connection to %s failed. If this is the first connection, "
|
||||||
|
"approve the ADB debugging prompt on the device. "
|
||||||
|
"Retrying in %d seconds.",
|
||||||
|
host, ADB_RETRY_INTERVAL,
|
||||||
|
)
|
||||||
|
self._adb_poll_unsub = async_call_later(
|
||||||
|
self.hass, ADB_RETRY_INTERVAL, self._adb_poll_callback
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Failed to set up ADB")
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _adb_poll_callback(self, _now) -> None:
|
||||||
|
"""Schedule ADB poll as a task."""
|
||||||
|
self.hass.async_create_task(self._adb_poll())
|
||||||
|
|
||||||
|
async def _adb_poll(self) -> None:
|
||||||
|
"""Poll ADB for wake state and media session, then schedule next poll."""
|
||||||
|
try:
|
||||||
|
if not self._adb_poller:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Always check wake state
|
||||||
|
prev_awake = self._adb_awake
|
||||||
|
self._adb_awake = await self._adb_poller.is_awake()
|
||||||
|
|
||||||
|
# Only poll media session when awake and Jellyfin is NOT active
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
||||||
|
|
||||||
|
if self._adb_awake:
|
||||||
|
# Always poll ADB when awake — pass the foreground app
|
||||||
|
# so the parser prefers its session over stale ones
|
||||||
|
await self._adb_poller.poll(foreground_package=self.app_id)
|
||||||
|
self._running_apps = await self._adb_poller.poll_running_apps()
|
||||||
|
elif self._adb_poller.last_info:
|
||||||
|
# Clear media info when asleep
|
||||||
|
self._adb_poller.clear_info()
|
||||||
|
self._running_apps = []
|
||||||
|
|
||||||
|
# Always update state (wake state may have changed)
|
||||||
|
if self._adb_awake != prev_awake or self._adb_poller.last_info:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("ADB poll error", exc_info=True)
|
||||||
|
finally:
|
||||||
|
# Use slow retry if ADB is disconnected, normal interval if connected
|
||||||
|
if self._adb_enabled:
|
||||||
|
interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL
|
||||||
|
self._adb_poll_unsub = async_call_later(
|
||||||
|
self.hass, interval, self._adb_poll_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fetch_media_info(self, entity_picture: str) -> None:
|
||||||
|
"""Read stream info from the HA Jellyfin integration's coordinator."""
|
||||||
|
info = await fetch_session_media_info(
|
||||||
|
self.hass, self._jellyfin_entity, self._entry.title
|
||||||
|
)
|
||||||
|
if info != self._media_info:
|
||||||
|
self._media_info = info
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def _jf_active(self) -> bool:
|
||||||
|
"""Is Jellyfin reporting active playback?"""
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
|
||||||
|
return True
|
||||||
|
# If we have an active intent for play/pause, media is conceptually active
|
||||||
|
if self._intent and self._intent.target_state in (
|
||||||
|
STATE_PLAYING, STATE_PAUSED
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── State ──
|
||||||
|
|
||||||
|
def _is_power_on(self) -> bool | None:
|
||||||
|
"""Determine if the Shield is powered on.
|
||||||
|
|
||||||
|
Priority: ADB wake state > power entity plug off > unknown.
|
||||||
|
ADB is the authority — it knows if the device is awake or asleep.
|
||||||
|
The plug being "on" just means the outlet has power (standby).
|
||||||
|
"""
|
||||||
|
# 1. ADB wake state — most accurate
|
||||||
|
if self._adb_awake is not None:
|
||||||
|
return self._adb_awake
|
||||||
|
|
||||||
|
# 2. Power entity — only trust "off" (plug pulled = definitely off)
|
||||||
|
if self._power_entity:
|
||||||
|
power_state = self._read(self._power_entity)
|
||||||
|
if power_state and power_state.state == "off":
|
||||||
|
return False
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> MediaPlayerState:
|
||||||
|
# Check if plug is off first — truly off
|
||||||
|
if self._power_entity:
|
||||||
|
power_state = self._read(self._power_entity)
|
||||||
|
if power_state and power_state.state == "off":
|
||||||
|
return MediaPlayerState.OFF
|
||||||
|
|
||||||
|
power = self._is_power_on()
|
||||||
|
|
||||||
|
if power is False:
|
||||||
|
return MediaPlayerState.IDLE # ADB says asleep — standby (yellow dot)
|
||||||
|
|
||||||
|
if power is None:
|
||||||
|
return MediaPlayerState.IDLE
|
||||||
|
|
||||||
|
# Tier 1: Active intent overrides Jellyfin
|
||||||
|
if self._intent is not None:
|
||||||
|
return MediaPlayerState(self._intent.target_state)
|
||||||
|
|
||||||
|
# Tier 2: Jellyfin is authoritative
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
if jf:
|
||||||
|
if jf.state == STATE_PLAYING:
|
||||||
|
return MediaPlayerState.PLAYING
|
||||||
|
if jf.state == STATE_PAUSED:
|
||||||
|
return MediaPlayerState.PAUSED
|
||||||
|
|
||||||
|
# Tier 3: ADB media session (non-Jellyfin apps)
|
||||||
|
if self._adb_poller and self._adb_poller.last_info:
|
||||||
|
adb_info = self._adb_poller.last_info
|
||||||
|
if adb_info.playing:
|
||||||
|
return MediaPlayerState.PLAYING
|
||||||
|
if adb_info.paused:
|
||||||
|
return MediaPlayerState.PAUSED
|
||||||
|
|
||||||
|
# Tier 4: On but no media
|
||||||
|
return MediaPlayerState.ON
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
|
if self._jf_active():
|
||||||
|
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
|
# ADB media session also supports playback controls
|
||||||
|
if self._adb_poller and self._adb_poller.last_info:
|
||||||
|
adb_info = self._adb_poller.last_info
|
||||||
|
if adb_info.playing or adb_info.paused:
|
||||||
|
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
|
return _BASE_FEATURES
|
||||||
|
|
||||||
|
# ── Metadata from Jellyfin ──
|
||||||
|
|
||||||
|
def _jf_attr(self, key: str):
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
return jf.attributes.get(key) if jf else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_id(self):
|
||||||
|
atv = self._read(self._androidtv_entity)
|
||||||
|
return atv.attributes.get("app_id") if atv else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_name(self):
|
||||||
|
atv = self._read(self._androidtv_entity)
|
||||||
|
return atv.attributes.get("app_name") if atv else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_type(self):
|
||||||
|
return self._jf_attr("media_content_type") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self):
|
||||||
|
return self._jf_attr("media_title") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_series_title(self):
|
||||||
|
return self._jf_attr("media_series_title") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_season(self):
|
||||||
|
return self._jf_attr("media_season") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_episode(self):
|
||||||
|
return self._jf_attr("media_episode") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_duration(self):
|
||||||
|
return self._jf_attr("media_duration") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position(self):
|
||||||
|
return self._jf_attr("media_position") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position_updated_at(self):
|
||||||
|
return self._jf_attr("media_position_updated_at") if self._jf_active() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_image_url(self):
|
||||||
|
return self._jf_attr("entity_picture") if self._jf_active() else None
|
||||||
|
|
||||||
|
def _get_now_playing(self) -> dict | None:
|
||||||
|
if not self._jf_active():
|
||||||
|
return None
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
if not jf:
|
||||||
|
return None
|
||||||
|
attrs = jf.attributes
|
||||||
|
np = {"state": self.state.value, "title": attrs.get("media_title"),
|
||||||
|
"media_type": attrs.get("media_content_type")}
|
||||||
|
for src, dst in [("media_series_title", "series"), ("media_season", "season"),
|
||||||
|
("media_episode", "episode"), ("media_duration", "duration"),
|
||||||
|
("media_position", "position")]:
|
||||||
|
v = attrs.get(src)
|
||||||
|
if v is not None:
|
||||||
|
np[dst] = v
|
||||||
|
if attrs.get("entity_picture"):
|
||||||
|
np["thumbnail"] = attrs["entity_picture"]
|
||||||
|
if self._media_info:
|
||||||
|
np["media_info"] = self._media_info
|
||||||
|
return np
|
||||||
|
|
||||||
|
def _get_adb_now_playing(self) -> dict | None:
|
||||||
|
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
|
||||||
|
if not self._adb_poller or not self._adb_poller.last_info:
|
||||||
|
return None
|
||||||
|
if not self._adb_store_info:
|
||||||
|
# Privacy mode: show that something is playing but not what
|
||||||
|
info = self._adb_poller.last_info
|
||||||
|
if info.playing or info.paused:
|
||||||
|
return {
|
||||||
|
"state": "playing" if info.playing else "paused",
|
||||||
|
"title": self.app_name or self.app_id or "Playing",
|
||||||
|
"source": "media_session",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = self._adb_poller.last_info
|
||||||
|
if not info.title:
|
||||||
|
return None
|
||||||
|
np = {
|
||||||
|
"state": "playing" if info.playing else ("paused" if info.paused else "idle"),
|
||||||
|
"title": info.title,
|
||||||
|
"source": "media_session",
|
||||||
|
}
|
||||||
|
if info.artist:
|
||||||
|
np["artist"] = info.artist
|
||||||
|
if info.album:
|
||||||
|
np["album"] = info.album
|
||||||
|
return np
|
||||||
|
|
||||||
|
def _get_power_data(self) -> dict | None:
|
||||||
|
"""Read current values from discovered power sensors."""
|
||||||
|
if not self._power_sensors:
|
||||||
|
return None
|
||||||
|
data = {}
|
||||||
|
for key, entity_id in self._power_sensors.items():
|
||||||
|
if key == "switch":
|
||||||
|
continue
|
||||||
|
state = self._read(entity_id)
|
||||||
|
if state and state.state not in ("unavailable", "unknown"):
|
||||||
|
try:
|
||||||
|
data[key] = float(state.state)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
data[key] = state.state
|
||||||
|
return data if data else None
|
||||||
|
|
||||||
|
def _get_running_apps(self) -> list[dict] | None:
|
||||||
|
"""Build a list of running apps from ADB recent tasks + media sessions."""
|
||||||
|
if not self._adb_poller or not self._running_apps:
|
||||||
|
return None
|
||||||
|
if not self._adb_store_info:
|
||||||
|
return None
|
||||||
|
return running_apps_from_tasks(
|
||||||
|
self._running_apps,
|
||||||
|
self._adb_poller.all_sessions,
|
||||||
|
foreground_package=self.app_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
# Now playing: prefer Jellyfin, fall back to ADB media session
|
||||||
|
now_playing = self._get_now_playing()
|
||||||
|
if not now_playing:
|
||||||
|
now_playing = self._get_adb_now_playing()
|
||||||
|
|
||||||
|
attrs = {"app_id": self.app_id, "app_name": self.app_name,
|
||||||
|
ATTR_MEDIA_DAG_ROLE: "source",
|
||||||
|
ATTR_MEDIA_DAG_NOW_PLAYING: now_playing}
|
||||||
|
if self._receiver_entity:
|
||||||
|
attrs[ATTR_MEDIA_DAG_RECEIVER_ENTITY] = self._receiver_entity
|
||||||
|
if self._receiver_input:
|
||||||
|
attrs[ATTR_MEDIA_DAG_RECEIVER_INPUT] = self._receiver_input
|
||||||
|
if self._jellyfin_entity:
|
||||||
|
attrs[ATTR_MEDIA_DAG_JELLYFIN_ENTITY] = self._jellyfin_entity
|
||||||
|
|
||||||
|
# Running apps from ADB media sessions
|
||||||
|
running_apps = self._get_running_apps()
|
||||||
|
if running_apps:
|
||||||
|
attrs[ATTR_MEDIA_DAG_RUNNING_APPS] = running_apps
|
||||||
|
|
||||||
|
# Power monitoring
|
||||||
|
if self._power_entity:
|
||||||
|
attrs["power_switch"] = self._power_entity
|
||||||
|
power_data = self._get_power_data()
|
||||||
|
if power_data:
|
||||||
|
attrs["power_monitoring"] = power_data
|
||||||
|
|
||||||
|
# Intent state for debugging
|
||||||
|
if self._intent:
|
||||||
|
attrs["_intent"] = self._intent.to_debug_dict()
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
# ── Commands — ALL go to androidtv_remote ──
|
||||||
|
|
||||||
|
async def _cmd(self, service: str, **extra) -> None:
|
||||||
|
data = {"entity_id": self._androidtv_entity, **extra}
|
||||||
|
await self.hass.services.async_call("media_player", service, data)
|
||||||
|
|
||||||
|
async def async_turn_on(self):
|
||||||
|
await self._cmd("turn_on")
|
||||||
|
|
||||||
|
async def async_turn_off(self):
|
||||||
|
await self._cmd("turn_off")
|
||||||
|
|
||||||
|
async def async_volume_up(self):
|
||||||
|
await self._cmd("volume_up")
|
||||||
|
|
||||||
|
async def async_volume_down(self):
|
||||||
|
await self._cmd("volume_down")
|
||||||
|
|
||||||
|
async def async_mute_volume(self, mute):
|
||||||
|
await self._cmd("volume_mute", is_volume_muted=mute)
|
||||||
|
|
||||||
|
async def _media_cmd(self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None) -> None:
|
||||||
|
"""Send a media command. Uses ADB dispatch for non-Jellyfin apps when available."""
|
||||||
|
if intent_state:
|
||||||
|
self._set_intent(ha_service, intent_state)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
# Use ADB media dispatch when ADB is active and Jellyfin isn't playing
|
||||||
|
jf = self._read(self._jellyfin_entity)
|
||||||
|
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
||||||
|
|
||||||
|
if self._adb_poller and self._adb_poller.connected and not jf_active:
|
||||||
|
success = await self._adb_poller.send_media_command(adb_command)
|
||||||
|
if success:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fall back to Android TV Remote
|
||||||
|
await self._cmd(ha_service)
|
||||||
|
|
||||||
|
async def async_media_play(self):
|
||||||
|
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
||||||
|
|
||||||
|
async def async_media_pause(self):
|
||||||
|
await self._media_cmd("media_pause", "pause", MediaPlayerState.PAUSED)
|
||||||
|
|
||||||
|
async def async_media_stop(self):
|
||||||
|
await self._media_cmd("media_stop", "stop", MediaPlayerState.IDLE)
|
||||||
|
|
||||||
|
async def async_media_next_track(self):
|
||||||
|
await self._media_cmd("media_next_track", "next")
|
||||||
|
|
||||||
|
async def async_media_previous_track(self):
|
||||||
|
await self._media_cmd("media_previous_track", "previous")
|
||||||
|
|
||||||
|
async def async_bring_to_foreground(self, package: str) -> bool:
|
||||||
|
"""Bring a specific app to the foreground via ADB."""
|
||||||
|
if not self._adb_poller:
|
||||||
|
_LOGGER.warning("ADB not available for bring_to_foreground")
|
||||||
|
return False
|
||||||
|
return await self._adb_poller.bring_to_foreground(package)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Power monitoring helpers — discover sibling sensors from a power switch entity."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Map unit_of_measurement to a friendly key
|
||||||
|
_UNIT_TO_KEY = {
|
||||||
|
"W": "power_w",
|
||||||
|
"kWh": "energy_kwh",
|
||||||
|
"V": "voltage_v",
|
||||||
|
"A": "current_a",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_power_siblings(
|
||||||
|
hass: HomeAssistant, power_entity_id: str
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Given a power switch entity_id, find sibling sensor entities on the same device.
|
||||||
|
|
||||||
|
Returns a dict like:
|
||||||
|
{
|
||||||
|
"switch": "switch.wall_plug_switch",
|
||||||
|
"power_w": "sensor.wall_plug_switch_electric_consumption_w",
|
||||||
|
"energy_kwh": "sensor.wall_plug_switch_electric_consumption_kwh",
|
||||||
|
"voltage_v": "sensor.wall_plug_switch_electric_consumption_v",
|
||||||
|
"current_a": "sensor.wall_plug_switch_electric_consumption_a",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
result = {"switch": power_entity_id}
|
||||||
|
|
||||||
|
# Find the device_id for the power entity
|
||||||
|
power_entry = registry.async_get(power_entity_id)
|
||||||
|
if not power_entry or not power_entry.device_id:
|
||||||
|
_LOGGER.debug("Power entity %s has no device_id", power_entity_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
device_id = power_entry.device_id
|
||||||
|
|
||||||
|
# Find all sensor siblings on the same device
|
||||||
|
for entry in registry.entities.values():
|
||||||
|
if entry.device_id != device_id:
|
||||||
|
continue
|
||||||
|
if entry.domain != "sensor":
|
||||||
|
continue
|
||||||
|
if entry.disabled_by is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
unit = entry.unit_of_measurement
|
||||||
|
if unit in _UNIT_TO_KEY:
|
||||||
|
key = _UNIT_TO_KEY[unit]
|
||||||
|
if key not in result: # first match wins
|
||||||
|
result[key] = entry.entity_id
|
||||||
|
|
||||||
|
_LOGGER.debug("Power siblings for %s: %s", power_entity_id, result)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Sensor entities for Nvidia Shield."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
|
||||||
|
from homeassistant.core import HomeAssistant, callback, Event
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_JELLYFIN_ENTITY,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Nvidia Shield sensor entities."""
|
||||||
|
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
|
||||||
|
|
||||||
|
if entry.options.get(CONF_JELLYFIN_ENTITY):
|
||||||
|
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldSensorBase(SensorEntity):
|
||||||
|
"""Base class for Shield sensors."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self._entry = entry
|
||||||
|
self._unsub_listeners: list = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
|
name=self._entry.title,
|
||||||
|
manufacturer="NVIDIA",
|
||||||
|
model="Shield TV",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
for unsub in self._unsub_listeners:
|
||||||
|
unsub()
|
||||||
|
self._unsub_listeners.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||||
|
"""Current running app on the Shield."""
|
||||||
|
|
||||||
|
_attr_name = "Current App"
|
||||||
|
_attr_icon = "mdi:application"
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
super().__init__(hass, entry)
|
||||||
|
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
self._unsub_listeners.append(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass, [self._androidtv_entity], self._handle_state_change
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_state_change(self, event: Event) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
state = self.hass.states.get(self._androidtv_entity)
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
state = self.hass.states.get(self._androidtv_entity)
|
||||||
|
if not state:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"app_id": state.attributes.get("app_id"),
|
||||||
|
"app_name": state.attributes.get("app_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||||
|
"""What's currently playing via Jellyfin."""
|
||||||
|
|
||||||
|
_attr_name = "Now Playing"
|
||||||
|
_attr_icon = "mdi:play-circle"
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
super().__init__(hass, entry)
|
||||||
|
self._jellyfin_entity = entry.options[CONF_JELLYFIN_ENTITY]
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_now_playing"
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
self._unsub_listeners.append(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass, [self._jellyfin_entity], self._handle_state_change
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_state_change(self, event: Event) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
state = self.hass.states.get(self._jellyfin_entity)
|
||||||
|
if not state or state.state not in (STATE_PLAYING, STATE_PAUSED):
|
||||||
|
return "Idle"
|
||||||
|
|
||||||
|
attrs = state.attributes
|
||||||
|
title = attrs.get("media_title", "")
|
||||||
|
series = attrs.get("media_series_title")
|
||||||
|
|
||||||
|
if series:
|
||||||
|
season = attrs.get("media_season", "")
|
||||||
|
episode = attrs.get("media_episode", "")
|
||||||
|
return f"{series} S{season}E{episode} - {title}"
|
||||||
|
return title or "Playing"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
state = self.hass.states.get(self._jellyfin_entity)
|
||||||
|
if not state or state.state not in (STATE_PLAYING, STATE_PAUSED):
|
||||||
|
return {"playback_state": "idle"}
|
||||||
|
|
||||||
|
attrs = state.attributes
|
||||||
|
result = {
|
||||||
|
"playback_state": state.state,
|
||||||
|
"title": attrs.get("media_title"),
|
||||||
|
"media_type": attrs.get("media_content_type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if attrs.get("media_series_title"):
|
||||||
|
result["series"] = attrs["media_series_title"]
|
||||||
|
if attrs.get("media_season") is not None:
|
||||||
|
result["season"] = attrs["media_season"]
|
||||||
|
if attrs.get("media_episode") is not None:
|
||||||
|
result["episode"] = attrs["media_episode"]
|
||||||
|
if attrs.get("media_duration") is not None:
|
||||||
|
result["duration"] = attrs["media_duration"]
|
||||||
|
if attrs.get("media_position") is not None:
|
||||||
|
result["position"] = attrs["media_position"]
|
||||||
|
if attrs.get("entity_picture"):
|
||||||
|
result["thumbnail"] = attrs["entity_picture"]
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "NVIDIA Shield",
|
||||||
|
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||||
|
"data": {
|
||||||
|
"name": "Device Name",
|
||||||
|
"androidtv_entity": "Android TV Remote",
|
||||||
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver Input",
|
||||||
|
"power_entity": "Power Switch",
|
||||||
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
|
"adb_store_app_info": "Show App Media Info",
|
||||||
|
"adb_host": "ADB Host",
|
||||||
|
"adb_port": "ADB Port"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||||
|
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
||||||
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
|
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
||||||
|
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||||
|
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
||||||
|
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||||
|
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||||
|
"adb_port": "ADB port number. Default is 5555."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"adb_pair": {
|
||||||
|
"title": "Pair ADB",
|
||||||
|
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
||||||
|
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This Shield entity is already configured."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "NVIDIA Shield Settings",
|
||||||
|
"description": "Configure linked devices and monitoring.",
|
||||||
|
"data": {
|
||||||
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver Input",
|
||||||
|
"power_entity": "Power Switch",
|
||||||
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
|
"adb_store_app_info": "Show App Media Info",
|
||||||
|
"adb_host": "ADB Host",
|
||||||
|
"adb_port": "ADB Port"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
||||||
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
|
"receiver_input": "Which input on the receiver.",
|
||||||
|
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||||
|
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||||
|
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||||
|
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||||
|
"adb_port": "ADB port (default 5555)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "NVIDIA Shield",
|
||||||
|
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||||
|
"data": {
|
||||||
|
"name": "Device Name",
|
||||||
|
"androidtv_entity": "Android TV Remote",
|
||||||
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver Input",
|
||||||
|
"power_entity": "Power Switch",
|
||||||
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
|
"adb_store_app_info": "Show App Media Info",
|
||||||
|
"adb_host": "ADB Host",
|
||||||
|
"adb_port": "ADB Port"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||||
|
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
||||||
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
|
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
||||||
|
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||||
|
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
||||||
|
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||||
|
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||||
|
"adb_port": "ADB port number. Default is 5555."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"adb_pair": {
|
||||||
|
"title": "Pair ADB",
|
||||||
|
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
||||||
|
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This Shield entity is already configured."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "NVIDIA Shield Settings",
|
||||||
|
"description": "Configure linked devices and monitoring.",
|
||||||
|
"data": {
|
||||||
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver Input",
|
||||||
|
"power_entity": "Power Switch",
|
||||||
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
|
"adb_store_app_info": "Show App Media Info",
|
||||||
|
"adb_host": "ADB Host",
|
||||||
|
"adb_port": "ADB Port"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
||||||
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
|
"receiver_input": "Which input on the receiver.",
|
||||||
|
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||||
|
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||||
|
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||||
|
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||||
|
"adb_port": "ADB port (default 5555)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[project]
|
||||||
|
name = "ha-nvidia-shield"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"homeassistant",
|
||||||
|
"adb-shell>=0.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"ruff>=0.4",
|
||||||
|
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
|
||||||
|
# for HACS installs; used editable from a sibling checkout for development.
|
||||||
|
# Planned migration to a proper PyPI package.
|
||||||
|
"ha-adb-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
ha-adb-common = { path = "../ha-adb-common", editable = true }
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
Reference in new issue
Block a user