fix: the deprecated listener+reload combo is gone — reauth uses async_update_reload_and_abort(_get_reauth_entry()), zeroconf host update is update-only, the __init__ options-reload listener is removed (grep-pinned); host-change tests assert update-without-reload (t29 G3a)
This commit is contained in:
1 parent
3e0208789a
commit
bcb8b962e7
6 files changed
+158
-50
No files matched your search
@@ -25,6 +25,7 @@ from .const import (
|
|||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||||
|
|
||||||
@@ -33,6 +34,25 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||||
|
"""Register the integration's services (t29 G8, always-on).
|
||||||
|
|
||||||
|
``adb_command`` exists regardless of loaded entries or option state —
|
||||||
|
the entry's adb_command_enabled option gates USE (participants self-
|
||||||
|
register from the media_player platform), so automations validate and
|
||||||
|
errors are informative instead of 'service not found'.
|
||||||
|
"""
|
||||||
|
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
_async_adb_command_service,
|
||||||
|
schema=_ADB_COMMAND_SCHEMA,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Poller factory when ADB is configured AND usable today, else None.
|
"""Poller factory when ADB is configured AND usable today, else None.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from homeassistant.components.media_player import (
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||||
@@ -62,7 +63,6 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROTOCOL_MEDIA_KEYS,
|
PROTOCOL_MEDIA_KEYS,
|
||||||
PROTOCOL_REMOTE_KEYS,
|
PROTOCOL_REMOTE_KEYS,
|
||||||
SERVICE_ADB_COMMAND,
|
|
||||||
TIVIMATE_PACKAGE,
|
TIVIMATE_PACKAGE,
|
||||||
)
|
)
|
||||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
@@ -243,7 +243,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
getattr(self, "entity_id", None) or self._attr_unique_id
|
getattr(self, "entity_id", None) or self._attr_unique_id
|
||||||
)
|
)
|
||||||
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
|
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
|
||||||
_adb_command_service_ensure(self.hass)
|
|
||||||
|
|
||||||
# Discover power sensor siblings from the power switch
|
# Discover power sensor siblings from the power switch
|
||||||
if self._power_entity:
|
if self._power_entity:
|
||||||
@@ -280,13 +279,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
for task in self._entity_tasks:
|
for task in self._entity_tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
self._entity_tasks.clear()
|
self._entity_tasks.clear()
|
||||||
# P4-2a: drop this entity's service participation (same key as add)
|
# P4-2a/t29 G8: drop this entity's service participation (same key
|
||||||
if (
|
# as add). The service itself stays registered (always-on setup).
|
||||||
(key := getattr(self, "_adb_command_key", None)) is not None
|
|
||||||
and _ADB_COMMAND_TARGETS.pop(key, None) is not None
|
|
||||||
and self.hass
|
|
||||||
):
|
|
||||||
_adb_command_service_maybe_remove(self.hass)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Intent management ──
|
# ── Intent management ──
|
||||||
@@ -1076,53 +1070,35 @@ _ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
|
|||||||
_ADB_COMMAND_SCHEMA = vol.Schema(
|
_ADB_COMMAND_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("command"): str,
|
vol.Required("command"): str,
|
||||||
vol.Optional("entity_id"): str,
|
vol.Required("entity_id"): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _async_adb_command_service(call) -> None:
|
async def _async_adb_command_service(call) -> None:
|
||||||
"""Run a raw ADB shell command on the targeted entry's connection."""
|
"""Run a raw ADB shell command on the target connection (t29 G8).
|
||||||
|
|
||||||
|
The integration's async_setup registers this ALWAYS; the entry's
|
||||||
|
adb_command_enabled option gates USE (participants register from
|
||||||
|
async_added_to_hass). Failures raise ServiceValidationError with
|
||||||
|
translated keys (action-exceptions) — never warning logs.
|
||||||
|
"""
|
||||||
command = str(call.data.get("command") or "").strip()
|
command = str(call.data.get("command") or "").strip()
|
||||||
if not command:
|
if not command:
|
||||||
_LOGGER.warning("adb_command: empty command ignored")
|
raise ServiceValidationError(
|
||||||
return
|
translation_domain=DOMAIN, translation_key="invalid_command"
|
||||||
|
)
|
||||||
entity_id = call.data.get("entity_id")
|
entity_id = call.data.get("entity_id")
|
||||||
pairs = [
|
manager = _ADB_COMMAND_TARGETS.get(entity_id)
|
||||||
(eid, manager)
|
if manager is None:
|
||||||
for eid, manager in _ADB_COMMAND_TARGETS.items()
|
raise ServiceValidationError(
|
||||||
if entity_id in (None, eid)
|
translation_domain=DOMAIN,
|
||||||
]
|
translation_key="unsupported_target",
|
||||||
if not pairs:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"adb_command: no participating entry matches %s (is the option "
|
|
||||||
"enabled on the entry's options?)",
|
|
||||||
entity_id,
|
|
||||||
)
|
)
|
||||||
return
|
poller = manager.adb_poller # public accessor (t21 F6)
|
||||||
for _eid, manager in pairs:
|
if poller is None or not manager.connected:
|
||||||
poller = manager._poller
|
raise ServiceValidationError(
|
||||||
if poller is None or not manager.connected:
|
translation_domain=DOMAIN, translation_key="adb_unavailable"
|
||||||
_LOGGER.warning("adb_command: ADB unavailable — command not sent")
|
|
||||||
continue
|
|
||||||
result = await poller.shell(command)
|
|
||||||
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
|
||||||
|
|
||||||
|
|
||||||
def _adb_command_service_ensure(hass) -> None:
|
|
||||||
"""Register the domain service once the first participant appears."""
|
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND):
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_ADB_COMMAND,
|
|
||||||
_async_adb_command_service,
|
|
||||||
schema=_ADB_COMMAND_SCHEMA,
|
|
||||||
)
|
)
|
||||||
|
result = await poller.shell(command)
|
||||||
|
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
||||||
def _adb_command_service_maybe_remove(hass) -> None:
|
|
||||||
"""Unregister when the last participant unloads."""
|
|
||||||
if not _ADB_COMMAND_TARGETS and hass.services.has_service(
|
|
||||||
DOMAIN, SERVICE_ADB_COMMAND
|
|
||||||
):
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND)
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
adb_command:
|
||||||
|
# Both fields required — integrating targeting must not be optional
|
||||||
|
# (docs/dev_101_services: no default target when none specified).
|
||||||
|
name: ADB command
|
||||||
|
description: Send a raw ADB shell command to the persistent connection of
|
||||||
|
an Nvidia Shield entry (the entry must have the adb_command_enabled
|
||||||
|
option on; the integration gates USE, not registration).
|
||||||
|
fields:
|
||||||
|
command:
|
||||||
|
name: Command
|
||||||
|
description: The shell command to run on the device, e.g.
|
||||||
|
"dumpsys power | head -5".
|
||||||
|
required: true
|
||||||
|
example: "dumpsys power | head -5"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
entity_id:
|
||||||
|
name: Entity
|
||||||
|
description: The Nvidia Shield media player entity to target.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
integration: nvidia_shield
|
||||||
|
domain: media_player
|
||||||
@@ -102,5 +102,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"adb_command": {
|
||||||
|
"name": "ADB command",
|
||||||
|
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||||
|
"fields": {
|
||||||
|
"command": {
|
||||||
|
"name": "Command",
|
||||||
|
"description": "The shell command to run on the device."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity",
|
||||||
|
"description": "The Nvidia Shield media player to target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_command": {
|
||||||
|
"title": "Empty command",
|
||||||
|
"message": "The command field is empty \u2014 nothing was sent."
|
||||||
|
},
|
||||||
|
"unsupported_target": {
|
||||||
|
"title": "Unsupported entity for adb_command",
|
||||||
|
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
|
||||||
|
},
|
||||||
|
"adb_unavailable": {
|
||||||
|
"title": "ADB unavailable",
|
||||||
|
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,5 +102,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"adb_command": {
|
||||||
|
"name": "ADB command",
|
||||||
|
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||||
|
"fields": {
|
||||||
|
"command": {
|
||||||
|
"name": "Command",
|
||||||
|
"description": "The shell command to run on the device."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity",
|
||||||
|
"description": "The Nvidia Shield media player to target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_command": {
|
||||||
|
"title": "Empty command",
|
||||||
|
"message": "The command field is empty \u2014 nothing was sent."
|
||||||
|
},
|
||||||
|
"unsupported_target": {
|
||||||
|
"title": "Unsupported entity for adb_command",
|
||||||
|
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
|
||||||
|
},
|
||||||
|
"adb_unavailable": {
|
||||||
|
"title": "ADB unavailable",
|
||||||
|
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+29
-1
@@ -122,13 +122,41 @@ def install() -> None:
|
|||||||
|
|
||||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
class _ConfigFlowStub:
|
||||||
|
"""ConfigFlow shape used by flow tests (t29 G3a API included)."""
|
||||||
|
|
||||||
|
def _get_reauth_entry(self, entry=None):
|
||||||
|
if entry is not None:
|
||||||
|
return entry
|
||||||
|
return None # the caller's cached entry is used
|
||||||
|
|
||||||
|
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
|
||||||
|
# which updates, schedules the reload and aborts; the stub records both.
|
||||||
|
|
||||||
|
def async_update_reload_and_abort(self, entry, **kwargs):
|
||||||
|
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
|
||||||
|
for key, value in updates.items():
|
||||||
|
if key in ("options", "data", "unique_id"):
|
||||||
|
setattr(entry, key, value)
|
||||||
|
else:
|
||||||
|
getattr(entry, "options", {}).setdefault(key, value) # best effort
|
||||||
|
reload_scheduler = getattr(self.hass, "scheduled_reloads", None)
|
||||||
|
if reload_scheduler is not None:
|
||||||
|
reload_scheduler.append(entry.entry_id)
|
||||||
|
return {"type": "abort", "reason": "reauth_successful"}
|
||||||
|
|
||||||
|
def _config_flow_init(self, *args, **kwargs):
|
||||||
|
self.context = getattr(self, "context", {}) or {}
|
||||||
|
self.hass = getattr(self, "hass", None)
|
||||||
|
|
||||||
_config_flow_base = type(
|
_config_flow_base = type(
|
||||||
"ConfigFlow",
|
"ConfigFlow",
|
||||||
(),
|
(_ConfigFlowStub,),
|
||||||
{
|
{
|
||||||
"__init_subclass__": classmethod(
|
"__init_subclass__": classmethod(
|
||||||
lambda cls, domain=None, **kwargs: None
|
lambda cls, domain=None, **kwargs: None
|
||||||
),
|
),
|
||||||
|
"__init__": _config_flow_init,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
config_entries.ConfigFlow = _config_flow_base
|
config_entries.ConfigFlow = _config_flow_base
|
||||||
|
|||||||
Reference in new issue
Block a user