diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 3a8f2265044b1e..b527c8ab9372ad 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -506,7 +506,7 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() -async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 +async def async_devices_payload(hass: HomeAssistant) -> dict: """Return detailed information about entities and devices.""" dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) @@ -538,6 +538,22 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 integration_input = integration_inputs.setdefault(integration_domain, ([], [])) integration_input[1].append(entity_entry.entity_id) + integrations = { + domain: integration + for domain, integration in ( + await async_get_integrations(hass, integration_inputs.keys()) + ).items() + if isinstance(integration, Integration) + } + + # Filter out custom integrations + integration_inputs = { + domain: integration_info + for domain, integration_info in integration_inputs.items() + if (integration := integrations.get(domain)) is not None + and integration.is_built_in + } + # Call integrations that implement the analytics platform for integration_domain, integration_input in integration_inputs.items(): if ( @@ -688,23 +704,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 else: entities_info.append(entity_info) - integrations = { - domain: integration - for domain, integration in ( - await async_get_integrations(hass, integrations_info.keys()) - ).items() - if isinstance(integration, Integration) - } - - for domain, integration_info in integrations_info.items(): - if integration := integrations.get(domain): - integration_info["is_custom_integration"] = not integration.is_built_in - # Include version for custom integrations - if not integration.is_built_in and integration.version: - integration_info["custom_integration_version"] = str( - integration.version - ) - return { "version": "home-assistant:1", "home_assistant": HA_VERSION, diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index eeda91a70a3104..5d066968873725 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -13,20 +13,30 @@ RequestLimitReached, WebsocketError, ) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + ServiceValidationError, +) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( + BCU_APP, CHARGEPOINT_SETTINGS, CHARGEPOINT_STATUS, + CHARGING_CARD_ID, DOMAIN, EVSE_ID, LOGGER, PLUG_AND_CHARGE, + SERVICE_START_CHARGE_SESSION, VALUE, ) @@ -34,6 +44,7 @@ PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" +CHARGE_CARDS = "CHARGE_CARDS" DATA = "data" DELAY = 5 @@ -41,6 +52,16 @@ OBJECT = "object" VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + # When no charging card is provided, use no charging card (BCU_APP = no charging card). + vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string, + } +) + async def async_setup_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry @@ -67,6 +88,66 @@ async def async_setup_entry( return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Blue Current.""" + + async def start_charge_session(service_call: ServiceCall) -> None: + """Start a charge session with the provided device and charge card ID.""" + # When no charge card is provided, use the default charge card set in the config flow. + charging_card_id = service_call.data[CHARGING_CARD_ID] + device_id = service_call.data[CONF_DEVICE_ID] + + # Get the device based on the given device ID. + device = dr.async_get(hass).devices.get(device_id) + + if device is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_device_id" + ) + + blue_current_config_entry: ConfigEntry | None = None + + for config_entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry_id) + if not config_entry or config_entry.domain != DOMAIN: + # Not the blue_current config entry. + continue + + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="config_entry_not_loaded" + ) + + blue_current_config_entry = config_entry + break + + if not blue_current_config_entry: + # The device is not connected to a valid blue_current config entry. + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="no_config_entry" + ) + + connector = blue_current_config_entry.runtime_data + + # Get the evse_id from the identifier of the device. + evse_id = next( + identifier[1] + for identifier in device.identifiers + if identifier[0] == DOMAIN + ) + + await connector.client.start_session(evse_id, charging_card_id) + + hass.services.async_register( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + start_charge_session, + SERVICE_START_CHARGE_SESSION_SCHEMA, + ) + + return True + + async def async_unload_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry ) -> bool: @@ -87,6 +168,7 @@ def __init__( self.client = client self.charge_points: dict[str, dict] = {} self.grid: dict[str, Any] = {} + self.charge_cards: dict[str, dict[str, Any]] = {} async def on_data(self, message: dict) -> None: """Handle received data.""" diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 33e0e8b1176a84..16b737730b9224 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,6 +8,12 @@ EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +CARD = "card" +UID = "uid" +BCU_APP = "BCU-APP" +WITHOUT_CHARGING_CARD = "without_charging_card" +CHARGING_CARD_ID = "charging_card_id" +SERVICE_START_CHARGE_SESSION = "start_charge_session" PLUG_AND_CHARGE = "plug_and_charge" VALUE = "value" PERMISSION = "permission" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index 28d4acbc1d8019..b8c6a5f045b10d 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -42,5 +42,10 @@ "default": "mdi:lock" } } + }, + "services": { + "start_charge_session": { + "service": "mdi:play" + } } } diff --git a/homeassistant/components/blue_current/services.yaml b/homeassistant/components/blue_current/services.yaml new file mode 100644 index 00000000000000..70992b5f277bab --- /dev/null +++ b/homeassistant/components/blue_current/services.yaml @@ -0,0 +1,12 @@ +start_charge_session: + fields: + device_id: + selector: + device: + integration: blue_current + required: true + + charging_card_id: + selector: + text: + required: false diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 0a99af603ccae8..9fdbd756392d02 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -22,6 +22,16 @@ "wrong_account": "Wrong account: Please authenticate with the API token for {email}." } }, + "options": { + "step": { + "init": { + "data": { + "card": "Card" + }, + "description": "Select the default charging card you want to use" + } + } + }, "entity": { "sensor": { "activity": { @@ -136,5 +146,39 @@ "name": "Block charge point" } } + }, + "selector": { + "select_charging_card": { + "options": { + "without_charging_card": "Without charging card" + } + } + }, + "services": { + "start_charge_session": { + "name": "Start charge session", + "description": "Starts a new charge session on a specified charge point.", + "fields": { + "charging_card_id": { + "name": "Charging card ID", + "description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used." + }, + "device_id": { + "name": "Device ID", + "description": "The ID of the Blue Current charge point." + } + } + } + }, + "exceptions": { + "invalid_device_id": { + "message": "Invalid device ID given." + }, + "config_entry_not_loaded": { + "message": "Config entry not loaded." + }, + "no_config_entry": { + "message": "Device has not a valid blue_current config entry." + } } } diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index f12d22cc8b528d..556ae2ac9fd2a2 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -10,6 +10,7 @@ from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from bleak import BleakScanner from habluetooth import ( BaseHaScanner, BluetoothScannerDevice, @@ -38,13 +39,16 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: @hass_callback -def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper: - """Return a HaBleakScannerWrapper. +def async_get_scanner(hass: HomeAssistant) -> BleakScanner: + """Return a HaBleakScannerWrapper cast to BleakScanner. This is a wrapper around our BleakScanner singleton that allows multiple integrations to share the same BleakScanner. + + The wrapper is cast to BleakScanner for type compatibility with + libraries expecting a BleakScanner instance. """ - return HaBleakScannerWrapper() + return cast(BleakScanner, HaBleakScannerWrapper()) @hass_callback diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index ca4e66d6fd0087..0c9cc394fb3925 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -141,9 +141,9 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mid": "Mid", - "high": "High", + "high": "[%key:common::state::high%]", "extra_high": "Extra high" } }, @@ -194,7 +194,7 @@ "state": { "none": "None", "heavy": "Heavy", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "light": "Light", "extra_light": "Extra light", "extra_heavy": "Extra heavy", @@ -626,7 +626,7 @@ "name": "Power freeze" }, "auto_cycle_link": { - "name": "Auto cycle link" + "name": "Auto Cycle Link" }, "sanitize": { "name": "Sanitize" diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index a0bde29979e5ce..9a63f4b29cba8b 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1121,25 +1121,6 @@ async def test_devices_payload_no_entities( }, ], "entities": [], - "is_custom_integration": False, - }, - "test": { - "devices": [ - { - "entities": [], - "entry_type": None, - "has_configuration_url": False, - "hw_version": None, - "manufacturer": "test-manufacturer7", - "model": None, - "model_id": "test-model-id7", - "sw_version": None, - "via_device": None, - }, - ], - "entities": [], - "is_custom_integration": True, - "custom_integration_version": "1.2.3", }, }, } @@ -1299,7 +1280,6 @@ async def test_devices_payload_with_entities( "unit_of_measurement": "°C", }, ], - "is_custom_integration": False, }, "template": { "devices": [], @@ -1315,7 +1295,6 @@ async def test_devices_payload_with_entities( "unit_of_measurement": None, }, ], - "is_custom_integration": False, }, }, } @@ -1429,7 +1408,6 @@ async def async_modify_analytics( "unit_of_measurement": None, }, ], - "is_custom_integration": False, }, }, } diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 402d644747a2fb..420c3bdfdc5be3 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -10,7 +10,8 @@ from bluecurrent_api import Client from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE -from homeassistant.components.blue_current.const import PUBLIC_CHARGING +from homeassistant.components.blue_current.const import PUBLIC_CHARGING, UID +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -87,6 +88,16 @@ async def get_grid_status(evse_id: str) -> None: """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def get_charge_cards() -> None: + """Send the charge cards list to the callback.""" + await client_mock.receiver( + { + "object": "CHARGE_CARDS", + "default_card": {UID: "BCU-APP", CONF_ID: "BCU-APP"}, + "cards": [{UID: "MOCK-CARD", CONF_ID: "MOCK-CARD", "valid": 1}], + } + ) + async def update_charge_point( evse_id: str, event_object: str, settings: dict[str, Any] ) -> None: @@ -100,6 +111,7 @@ async def update_charge_point( client_mock.get_charge_points.side_effect = get_charge_points client_mock.get_status.side_effect = get_status client_mock.get_grid_status.side_effect = get_grid_status + client_mock.get_charge_cards.side_effect = get_charge_cards client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index b740e6c91f9aa6..563a8392dc8418 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -1,7 +1,7 @@ """Test Blue Current Init Component.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from bluecurrent_api.exceptions import ( BlueCurrentException, @@ -10,15 +10,24 @@ WebsocketError, ) import pytest +from voluptuous import MultipleInvalid -from homeassistant.components.blue_current import async_setup_entry +from homeassistant.components.blue_current import ( + CHARGING_CARD_ID, + DOMAIN, + SERVICE_START_CHARGE_SESSION, + async_setup_entry, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, IntegrationError, + ServiceValidationError, ) +from homeassistant.helpers.device_registry import DeviceRegistry from . import init_integration @@ -32,6 +41,7 @@ async def test_load_unload_entry( with ( patch("homeassistant.components.blue_current.Client.validate_api_token"), patch("homeassistant.components.blue_current.Client.wait_for_charge_points"), + patch("homeassistant.components.blue_current.Client.get_charge_cards"), patch("homeassistant.components.blue_current.Client.disconnect"), patch( "homeassistant.components.blue_current.Client.connect", @@ -103,3 +113,108 @@ async def test_connect_request_limit_reached_error( await started_loop.wait() assert mock_client.get_next_reset_delta.call_count == 1 assert mock_client.connect.call_count == 2 + + +async def test_start_charging_action( + hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry +) -> None: + """Test the start charing action when a charging card is provided.""" + integration = await init_integration(hass, config_entry, Platform.BUTTON) + client = integration[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + CHARGING_CARD_ID: "TEST_CARD", + }, + blocking=True, + ) + + client.start_session.assert_called_once_with("101", "TEST_CARD") + + +async def test_start_charging_action_without_card( + hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry +) -> None: + """Test the start charing action when no charging card is provided.""" + integration = await init_integration(hass, config_entry, Platform.BUTTON) + client = integration[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) + + client.start_session.assert_called_once_with("101", "BCU-APP") + + +async def test_start_charging_action_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test the start charing action errors.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + with pytest.raises(MultipleInvalid): + # No device id + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + {}, + blocking=True, + ) + + with pytest.raises(ServiceValidationError): + # Invalid device id + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + {CONF_DEVICE_ID: "INVALID"}, + blocking=True, + ) + + # Test when the device is not connected to a valid blue_current config entry. + get_entry_mock = MagicMock() + get_entry_mock.state = ConfigEntryState.LOADED + + with ( + patch.object( + hass.config_entries, "async_get_entry", return_value=get_entry_mock + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) + + # Test when the blue_current config entry is not loaded. + get_entry_mock = MagicMock() + get_entry_mock.domain = DOMAIN + get_entry_mock.state = ConfigEntryState.NOT_LOADED + + with ( + patch.object( + hass.config_entries, "async_get_entry", return_value=get_entry_mock + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 5797d9e74c5498..1bd79b3307c6de 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -840,7 +840,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto cycle link', + 'original_name': 'Auto Cycle Link', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -853,7 +853,7 @@ # name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AirDresser Auto cycle link', + 'friendly_name': 'AirDresser Auto Cycle Link', }), 'context': , 'entity_id': 'switch.airdresser_auto_cycle_link', diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 7402cf2899f3d2..d28d0bc1a1c96d 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -56,14 +56,10 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: assert integration_mock.call_args == call(hass, "test") -async def test_trigger_variables(hass: HomeAssistant) -> None: - """Test trigger variables.""" - - -async def test_if_fires_on_event( +async def test_trigger_variables( hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: - """Test the firing of events.""" + """Test trigger variables.""" assert await async_setup_component( hass, "automation",