diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index b1b5a28ef67..fe01d239f3c 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -20,6 +20,7 @@ PLATFORMS: list[Platform] = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index eb529a99ae3..1dd5fc61a16 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -154,6 +154,11 @@ "display_brightness": { "name": "[%key:component::airgradient::entity::number::display_brightness::name%]" } + }, + "switch": { + "post_data_to_airgradient": { + "name": "Post data to Airgradient" + } } } } diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py new file mode 100644 index 00000000000..60c3f83ae5e --- /dev/null +++ b/homeassistant/components/airgradient/switch.py @@ -0,0 +1,110 @@ +"""Support for AirGradient switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from airgradient import AirGradientClient, Config +from airgradient.models import ConfigurationControl + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AirGradientConfigEntry +from .const import DOMAIN +from .coordinator import AirGradientConfigCoordinator +from .entity import AirGradientEntity + + +@dataclass(frozen=True, kw_only=True) +class AirGradientSwitchEntityDescription(SwitchEntityDescription): + """Describes AirGradient switch entity.""" + + value_fn: Callable[[Config], bool] + set_value_fn: Callable[[AirGradientClient, bool], Awaitable[None]] + + +POST_DATA_TO_AIRGRADIENT = AirGradientSwitchEntityDescription( + key="post_data_to_airgradient", + translation_key="post_data_to_airgradient", + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: config.post_data_to_airgradient, + set_value_fn=lambda client, value: client.enable_sharing_data(enable=value), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirGradientConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AirGradient switch entities based on a config entry.""" + coordinator = entry.runtime_data.config + + added_entities = False + + @callback + def _async_check_entities() -> None: + nonlocal added_entities + + if ( + coordinator.data.configuration_control is ConfigurationControl.LOCAL + and not added_entities + ): + async_add_entities( + [AirGradientSwitch(coordinator, POST_DATA_TO_AIRGRADIENT)] + ) + added_entities = True + elif ( + coordinator.data.configuration_control is not ConfigurationControl.LOCAL + and added_entities + ): + entity_registry = er.async_get(hass) + unique_id = f"{coordinator.serial_number}-{POST_DATA_TO_AIRGRADIENT.key}" + if entity_id := entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + added_entities = False + + coordinator.async_add_listener(_async_check_entities) + _async_check_entities() + + +class AirGradientSwitch(AirGradientEntity, SwitchEntity): + """Defines an AirGradient switch entity.""" + + entity_description: AirGradientSwitchEntityDescription + coordinator: AirGradientConfigCoordinator + + def __init__( + self, + coordinator: AirGradientConfigCoordinator, + description: AirGradientSwitchEntityDescription, + ) -> None: + """Initialize AirGradient switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.set_value_fn(self.coordinator.client, True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.set_value_fn(self.coordinator.client, False) + await self.coordinator.async_request_refresh() diff --git a/tests/components/airgradient/snapshots/test_switch.ambr b/tests/components/airgradient/snapshots/test_switch.ambr new file mode 100644 index 00000000000..752355dbe97 --- /dev/null +++ b/tests/components/airgradient/snapshots/test_switch.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[switch.airgradient_post_data_to_airgradient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airgradient_post_data_to_airgradient', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Post data to Airgradient', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'post_data_to_airgradient', + 'unique_id': '84fce612f5b8-post_data_to_airgradient', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.airgradient_post_data_to_airgradient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Post data to Airgradient', + }), + 'context': , + 'entity_id': 'switch.airgradient_post_data_to_airgradient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index ba659829c50..0803c0d437f 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -1,4 +1,4 @@ -"""Tests for the AirGradient button platform.""" +"""Tests for the AirGradient number platform.""" from datetime import timedelta from unittest.mock import AsyncMock, patch diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py new file mode 100644 index 00000000000..20a1cb7470b --- /dev/null +++ b/tests/components/airgradient/test_switch.py @@ -0,0 +1,101 @@ +"""Tests for the AirGradient switch platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from airgradient import Config +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "switch.airgradient_post_data_to_airgradient"}, + blocking=True, + ) + mock_airgradient_client.enable_sharing_data.assert_called_once() + mock_airgradient_client.enable_sharing_data.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: "switch.airgradient_post_data_to_airgradient"}, + blocking=True, + ) + mock_airgradient_client.enable_sharing_data.assert_called_once() + + +async def test_cloud_creates_no_switch( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test cloud configuration control.""" + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_all()) == 0 + + mock_cloud_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config_local.json", DOMAIN) + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + mock_cloud_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config_cloud.json", DOMAIN) + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0