The user ruling: this integration REPLACES both old integrations, so nothing may depend on an androidtv/androidtv_remote entity anymore. Removed: * the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer offered, only read by the migration that drops it); * the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2) and, with it, every registry lookup for another integration's devices — the user step now sets NO unique_id at all; the protocol MAC learned during pairing is the identity; * the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it now uses the entry name the user typed or the device name from discovery; * the app-name and app-id tiers (media_player.app_name/app_id + the shared resolve_app_name chain, the Current App sensor, the sensor's state-change listener and its `_androidtv_state` helper) — the manager's store is the only source; * the "transitional external entity" command tier in every path: `_cmd` and the pan-media_player service call are gone, and so is the fallback in _media_cmd, _remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry (an intent no transport served now surrenders to the truth instead of delegating); * the entity-derived power signal (`_on_source_change`'s "device off clears the intent" check no longer watches another integration's entity; the power LADDER — protocol is_on → ADB wake → power switch — is unchanged). turn_on/turn_off are native now: they send the protocol POWER key and, like the remote entity, send nothing when the known state already matches (no superfluous toggle). An ADB-only entry (protocol skipped) has no power write path — the manager owns the socket and exposes no device-keyevent, which is what the old delegation used to cover. helpers.discover_adb_host — the one helper that derived a host from another integration's config entry — is deleted; button.py (granted to this task) reads the entry's own address fields instead of the entity, so the Pair ADB button keeps working. Present-day entries that still carry the key are unaffected (nothing reads it); the v3 migration that drops it lands with the host unification. Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean, drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6 awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's legacy service recorder became a watchdog: any HA service call from the media player now fails the routing tests instead of being the expected outcome.
206 lines
7.0 KiB
Python
206 lines
7.0 KiB
Python
"""Sensor entities for Nvidia Shield."""
|
|
|
|
import logging
|
|
|
|
from homeassistant.components.sensor import SensorEntity
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
|
from homeassistant.core import Event, HomeAssistant, callback
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.event import async_track_state_change_event
|
|
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
CONF_APP_NAME_MAP,
|
|
CONF_JELLYFIN_ENTITY,
|
|
DOMAIN,
|
|
)
|
|
from .media_player import resolve_app_name
|
|
|
|
_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
|
|
# G12 (t30): poll-driven attributes never belong in the database
|
|
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
|
|
|
|
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.
|
|
|
|
Source: the ShieldConnectionManager's StateStore — the protocol current-app
|
|
push, or the most recent visible ADB recent-task app when that has not
|
|
reported (t41: our own transports only).
|
|
"""
|
|
|
|
_attr_translation_key = "current_app"
|
|
_attr_icon = "mdi:application"
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
super().__init__(hass, entry)
|
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
|
|
|
def _manager(self) -> ShieldConnectionManager | None:
|
|
manager = getattr(self._entry, "runtime_data", None)
|
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
manager = self._manager()
|
|
if manager is not None:
|
|
# Refresh when the connection manager updates its store.
|
|
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
|
|
|
@callback
|
|
def _handle_store_update(self) -> None:
|
|
self.async_write_ha_state()
|
|
|
|
def _package(self) -> str | None:
|
|
"""The raw foreground package from the manager's store (t41).
|
|
|
|
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
|
mResumedActivity → ADB recents.
|
|
"""
|
|
manager = self._manager()
|
|
if manager is None:
|
|
return None
|
|
foreground = manager.foreground_app
|
|
if foreground:
|
|
return foreground
|
|
running = manager.running_apps
|
|
if running:
|
|
return running[0].get("package")
|
|
return None
|
|
|
|
@property
|
|
def native_value(self) -> str | None:
|
|
"""The resolved friendly name (t40), not the raw package id.
|
|
|
|
Exactly the ``media_player.app_name`` chain via the shared
|
|
``resolve_app_name``: apps map → cached manifest label → the package
|
|
text itself. A bare or unknown package shows its package text rather
|
|
than None, like the media player's app_name.
|
|
"""
|
|
manager = self._manager()
|
|
return resolve_app_name(
|
|
self._package(),
|
|
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
|
manager.app_label if manager is not None else None,
|
|
)
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict:
|
|
"""The raw package stays visible here (t40) next to the shown name."""
|
|
package = self._package()
|
|
attrs: dict = {}
|
|
if package:
|
|
attrs["app_id"] = package
|
|
name = self.native_value
|
|
if name:
|
|
attrs["app_name"] = name
|
|
return attrs
|
|
|
|
|
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
|
"""What's currently playing via Jellyfin."""
|
|
|
|
_attr_translation_key = "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
|