diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 6e3c8b156b2acb..ca53cd728620df 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -47,12 +47,18 @@ State, callback, ) -from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotFound, + ServiceValidationError, + TemplateError, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, issue_registry as ir, + json, template, ) from homeassistant.helpers.device_registry import format_mac @@ -273,11 +279,32 @@ def async_on_service_call(self, service: HomeassistantServiceCall) -> None: elif self.entry.options.get( CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS ): - hass.async_create_task( - hass.services.async_call( - domain, service_name, service_data, blocking=True + call_id = service.call_id + if call_id and service.wants_response: + # Service call with response expected + self.entry.async_create_task( + hass, + self._handle_service_call_with_response( + domain, + service_name, + service_data, + call_id, + service.response_template, + ), + ) + elif call_id: + # Service call without response but needs success/failure notification + self.entry.async_create_task( + hass, + self._handle_service_call_with_notification( + domain, service_name, service_data, call_id + ), + ) + else: + # Fire and forget service call + self.entry.async_create_task( + hass, hass.services.async_call(domain, service_name, service_data) ) - ) else: device_info = self.entry_data.device_info assert device_info is not None @@ -303,6 +330,98 @@ def async_on_service_call(self, service: HomeassistantServiceCall) -> None: service_data, ) + async def _handle_service_call_with_response( + self, + domain: str, + service_name: str, + service_data: dict, + call_id: int, + response_template: str | None = None, + ) -> None: + """Handle service call that expects a response and send response back to ESPHome.""" + try: + # Call the service with response capture enabled + action_response = await self.hass.services.async_call( + domain=domain, + service=service_name, + service_data=service_data, + blocking=True, + return_response=True, + ) + + if response_template: + try: + # Render response template + tmpl = Template(response_template, self.hass) + response = tmpl.async_render( + variables={"response": action_response}, + strict=True, + ) + response_dict = {"response": response} + + except TemplateError as ex: + raise HomeAssistantError( + f"Error rendering response template: {ex}" + ) from ex + else: + response_dict = {"response": action_response} + + # JSON encode response data for ESPHome + response_data = json.json_bytes(response_dict) + + except ( + ServiceNotFound, + ServiceValidationError, + vol.Invalid, + HomeAssistantError, + ) as ex: + self._send_service_call_response( + call_id, success=False, error_message=str(ex), response_data=b"" + ) + + else: + # Send success response back to ESPHome + self._send_service_call_response( + call_id=call_id, + success=True, + error_message="", + response_data=response_data, + ) + + async def _handle_service_call_with_notification( + self, domain: str, service_name: str, service_data: dict, call_id: int + ) -> None: + """Handle service call that needs success/failure notification.""" + try: + await self.hass.services.async_call( + domain, service_name, service_data, blocking=True + ) + except (ServiceNotFound, ServiceValidationError, vol.Invalid) as ex: + self._send_service_call_response(call_id, False, str(ex), b"") + else: + self._send_service_call_response(call_id, True, "", b"") + + def _send_service_call_response( + self, + call_id: int, + success: bool, + error_message: str, + response_data: bytes, + ) -> None: + """Send service call response back to ESPHome device.""" + _LOGGER.debug( + "Service call response for call_id %s: success=%s, error=%s", + call_id, + success, + error_message, + ) + self.cli.send_homeassistant_action_response( + call_id, + success, + error_message, + response_data, + ) + @callback def _send_home_assistant_state( self, entity_id: str, attribute: str | None, state: State | None diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 232b8615457cbd..e47fdfe08147f0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==42.0.0", + "aioesphomeapi==42.1.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.4.0" ], diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index bf5393d191771b..daa6fc8b5357d4 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -from contextlib import suppress from dataclasses import dataclass from datetime import timedelta import logging @@ -154,10 +153,14 @@ async def _async_update_data(self) -> XboxData: def _build_presence_data(person: Person) -> PresenceData: """Build presence data from a person.""" active_app: PresenceDetail | None = None - with suppress(StopIteration): - active_app = next( - presence for presence in person.presence_details if presence.is_primary - ) + + active_app = next( + (presence for presence in person.presence_details if presence.is_primary), + None, + ) + in_game = ( + active_app is not None and active_app.is_game and active_app.state == "Active" + ) return PresenceData( xuid=person.xuid, @@ -166,7 +169,7 @@ def _build_presence_data(person: Person) -> PresenceData: online=person.presence_state == "Online", status=person.presence_text, in_party=person.multiplayer_summary.in_party > 0, - in_game=active_app is not None and active_app.is_game, + in_game=in_game, in_multiplayer=person.multiplayer_summary.in_multiplayer_session, gamer_score=person.gamer_score, gold_tenure=person.detail.tenure, diff --git a/requirements_all.txt b/requirements_all.txt index 57fc83704682f4..874e06233b9a9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -256,7 +256,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==42.0.0 +aioesphomeapi==42.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fde780d62dbd25..020072a36d4ed6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==42.0.0 +aioesphomeapi==42.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 43bf531f378b61..034993a818e027 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -25,6 +25,7 @@ ZWaveProxyRequestType, ) import pytest +import voluptuous as vol from homeassistant import config_entries from homeassistant.components.esphome.const import ( @@ -326,6 +327,342 @@ async def _mock_service(call: ServiceCall) -> None: events.clear() +async def test_esphome_device_service_call_with_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test service call with response expected.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + # Register a service that returns a response + async def _mock_service_with_response(call: ServiceCall) -> dict[str, Any]: + return {"result": "success", "value": 42} + + hass.services.async_register( + DOMAIN, + "test_with_response", + _mock_service_with_response, + supports_response=True, + ) + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call service with response expected + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test_with_response", + data={"input": "test"}, + call_id=123, + wants_response=True, + ) + ) + await hass.async_block_till_done() + + # Verify response was sent back to ESPHome + mock_client.send_homeassistant_action_response.assert_called_once() + call_id, success, error_message, response_data = ( + mock_client.send_homeassistant_action_response.call_args[0] + ) + assert call_id == 123 + assert success is True + assert error_message == "" + assert response_data == b'{"response":{"result":"success","value":42}}' + + +async def test_esphome_device_service_call_with_response_template( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test service call with response template.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + # Register a service that returns a response + async def _mock_service_with_response(call: ServiceCall) -> dict[str, Any]: + return {"temperature": 23.5, "humidity": 65} + + hass.services.async_register( + DOMAIN, "get_data", _mock_service_with_response, supports_response=True + ) + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call service with response template + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.get_data", + data={}, + call_id=456, + wants_response=True, + response_template="{{ response.temperature }}", + ) + ) + await hass.async_block_till_done() + + # Verify response was sent back with template applied + mock_client.send_homeassistant_action_response.assert_called_once() + call_id, success, error_message, response_data = ( + mock_client.send_homeassistant_action_response.call_args[0] + ) + assert call_id == 456 + assert success is True + assert error_message == "" + assert response_data == b'{"response":23.5}' + + +async def test_esphome_device_service_call_with_response_template_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test service call with invalid response template.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + # Register a service that returns a response + async def _mock_service_with_response(call: ServiceCall) -> dict[str, Any]: + return {"temperature": 23.5} + + hass.services.async_register( + DOMAIN, "get_data", _mock_service_with_response, supports_response=True + ) + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call service with invalid response template + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.get_data", + data={}, + call_id=789, + wants_response=True, + response_template="{{ response.invalid_field }}", + ) + ) + await hass.async_block_till_done() + + # Verify error response was sent back + mock_client.send_homeassistant_action_response.assert_called_once() + call_id, success, error_message, response_data = ( + mock_client.send_homeassistant_action_response.call_args[0] + ) + assert call_id == 789 + assert success is False + assert "Error rendering response template" in error_message + assert response_data == b"" + + +async def test_esphome_device_service_call_with_notification( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test service call with notification (no response expected).""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + # Register a service without response + async def _mock_service(call: ServiceCall) -> None: + pass + + hass.services.async_register(DOMAIN, "test_notify", _mock_service) + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call service with call_id but no wants_response + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test_notify", + data={"input": "test"}, + call_id=999, + wants_response=False, + ) + ) + await hass.async_block_till_done() + + # Verify success notification was sent back + mock_client.send_homeassistant_action_response.assert_called_once() + call_id, success, error_message, response_data = ( + mock_client.send_homeassistant_action_response.call_args[0] + ) + assert call_id == 999 + assert success is True + assert error_message == "" + assert response_data == b"" + + +async def test_esphome_device_service_call_with_service_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test service call when service does not exist.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call non-existent service with notification + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.nonexistent", + data={}, + call_id=111, + wants_response=False, + ) + ) + await hass.async_block_till_done() + + # Verify error notification was sent back + mock_client.send_homeassistant_action_response.assert_called_once() + call_id, success, error_message, response_data = ( + mock_client.send_homeassistant_action_response.call_args[0] + ) + assert call_id == 111 + assert success is False + assert "not found" in error_message.lower() + assert response_data == b"" + + +async def test_esphome_device_service_call_with_validation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test service call with validation error.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + # Register a service that validates input + async def _mock_service(call: ServiceCall) -> None: + raise vol.Invalid("Invalid input provided") + + hass.services.async_register(DOMAIN, "validate_test", _mock_service) + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call service with invalid data + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.validate_test", + data={"invalid": "data"}, + call_id=222, + wants_response=False, + ) + ) + await hass.async_block_till_done() + + # Verify error notification was sent back + mock_client.send_homeassistant_action_response.assert_called_once() + call_id, success, error_message, response_data = ( + mock_client.send_homeassistant_action_response.call_args[0] + ) + assert call_id == 222 + assert success is False + assert "Invalid input provided" in error_message + assert response_data == b"" + + +async def test_esphome_device_service_call_fire_and_forget( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test fire-and-forget service call (no call_id).""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} + ) + device = await mock_esphome_device( + mock_client=mock_client, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + + mock_calls: list[ServiceCall] = [] + + async def _mock_service(call: ServiceCall) -> None: + mock_calls.append(call) + + hass.services.async_register(DOMAIN, "fire_forget", _mock_service) + + # Mock the send_homeassistant_action_response method + mock_client.send_homeassistant_action_response = Mock() + + # Call service without call_id (fire-and-forget) + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.fire_forget", + data={"test": "data"}, + ) + ) + await hass.async_block_till_done() + + # Verify service was called but no response was sent + assert len(mock_calls) == 1 + assert mock_calls[0].data == {"test": "data"} + mock_client.send_homeassistant_action_response.assert_not_called() + + async def test_esphome_device_with_old_bluetooth( hass: HomeAssistant, mock_client: APIClient,