From bcb8b962e79f6310057b283b3a45d084a058a86a Mon Sep 17 00:00:00 2001 From: James Mackay Date: Fri, 9 Oct 2026 07:00:11 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20the=20deprecated=20listener+reload=20com?= =?UTF-8?q?bo=20is=20gone=20=E2=80=94=20reauth=20uses=20async=5Fupdate=5Fr?= =?UTF-8?q?eload=5Fand=5Fabort(=5Fget=5Freauth=5Fentry()),=20zeroconf=20ho?= =?UTF-8?q?st=20update=20is=20update-only,=20the=20=5F=5Finit=5F=5F=20opti?= =?UTF-8?q?ons-reload=20listener=20is=20removed=20(grep-pinned);=20host-ch?= =?UTF-8?q?ange=20tests=20assert=20update-without-reload=20(t29=20G3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/nvidia_shield/__init__.py | 20 +++++ .../nvidia_shield/media_player.py | 74 +++++++------------ custom_components/nvidia_shield/services.yaml | 24 ++++++ custom_components/nvidia_shield/strings.json | 30 ++++++++ .../nvidia_shield/translations/en.json | 30 ++++++++ tests/entity_stublib.py | 30 +++++++- 6 files changed, 158 insertions(+), 50 deletions(-) create mode 100644 custom_components/nvidia_shield/services.yaml diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index 59b82ed..be2f419 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -25,6 +25,7 @@ from .const import ( CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_HOST, DOMAIN, + SERVICE_ADB_COMMAND, ) 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] +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): """Poller factory when ADB is configured AND usable today, else None. diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index b9164ab..10d0bab 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -34,6 +34,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_PAUSED, STATE_PLAYING from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError 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 @@ -62,7 +63,6 @@ from .const import ( DOMAIN, PROTOCOL_MEDIA_KEYS, PROTOCOL_REMOTE_KEYS, - SERVICE_ADB_COMMAND, TIVIMATE_PACKAGE, ) 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 ) _ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager - _adb_command_service_ensure(self.hass) # Discover power sensor siblings from the power switch if self._power_entity: @@ -280,13 +279,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): for task in self._entity_tasks: task.cancel() self._entity_tasks.clear() - # P4-2a: drop this entity's service participation (same key as add) - if ( - (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) + # P4-2a/t29 G8: drop this entity's service participation (same key + # as add). The service itself stays registered (always-on setup). # ── Intent management ── @@ -1076,53 +1070,35 @@ _ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {} _ADB_COMMAND_SCHEMA = vol.Schema( { vol.Required("command"): str, - vol.Optional("entity_id"): str, + vol.Required("entity_id"): str, } ) 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() if not command: - _LOGGER.warning("adb_command: empty command ignored") - return + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_command" + ) entity_id = call.data.get("entity_id") - pairs = [ - (eid, manager) - for eid, manager in _ADB_COMMAND_TARGETS.items() - if entity_id in (None, eid) - ] - if not pairs: - _LOGGER.warning( - "adb_command: no participating entry matches %s (is the option " - "enabled on the entry's options?)", - entity_id, + manager = _ADB_COMMAND_TARGETS.get(entity_id) + if manager is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_target", ) - return - for _eid, manager in pairs: - poller = manager._poller - if poller is None or not manager.connected: - _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, + poller = manager.adb_poller # public accessor (t21 F6) + if poller is None or not manager.connected: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="adb_unavailable" ) - - -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) + result = await poller.shell(command) + _LOGGER.debug("adb_command %r → output=%s", command, result is not None) diff --git a/custom_components/nvidia_shield/services.yaml b/custom_components/nvidia_shield/services.yaml new file mode 100644 index 0000000..c895e9c --- /dev/null +++ b/custom_components/nvidia_shield/services.yaml @@ -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 diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index 3ed3469..b54a414 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -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." + } } } diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index 3ed3469..b54a414 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -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." + } } } diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 2341ecd..0ad5310 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -122,13 +122,41 @@ def install() -> None: config_entries = types.ModuleType("homeassistant.config_entries") 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( "ConfigFlow", - (), + (_ConfigFlowStub,), { "__init_subclass__": classmethod( lambda cls, domain=None, **kwargs: None ), + "__init__": _config_flow_init, }, ) config_entries.ConfigFlow = _config_flow_base