From 01b873f3bc5e56f6bbd1e4a5eb488a5d4dc00b87 Mon Sep 17 00:00:00 2001 From: aryanhasgithub Date: Fri, 20 Mar 2026 17:05:51 +0530 Subject: [PATCH] Add Lichess Integration (#166051) --- CODEOWNERS | 2 + homeassistant/components/lichess/__init__.py | 31 ++ .../components/lichess/config_flow.py | 52 +++ homeassistant/components/lichess/const.py | 3 + .../components/lichess/coordinator.py | 44 ++ homeassistant/components/lichess/entity.py | 26 ++ homeassistant/components/lichess/icons.json | 30 ++ .../components/lichess/manifest.json | 11 + .../components/lichess/quality_scale.yaml | 72 +++ homeassistant/components/lichess/sensor.py | 116 +++++ homeassistant/components/lichess/strings.json | 54 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/lichess/__init__.py | 12 + tests/components/lichess/conftest.py | 68 +++ .../lichess/snapshots/test_init.ambr | 32 ++ .../lichess/snapshots/test_sensor.ambr | 429 ++++++++++++++++++ tests/components/lichess/test_config_flow.py | 92 ++++ tests/components/lichess/test_init.py | 43 ++ tests/components/lichess/test_sensor.py | 28 ++ 22 files changed, 1158 insertions(+) create mode 100644 homeassistant/components/lichess/__init__.py create mode 100644 homeassistant/components/lichess/config_flow.py create mode 100644 homeassistant/components/lichess/const.py create mode 100644 homeassistant/components/lichess/coordinator.py create mode 100644 homeassistant/components/lichess/entity.py create mode 100644 homeassistant/components/lichess/icons.json create mode 100644 homeassistant/components/lichess/manifest.json create mode 100644 homeassistant/components/lichess/quality_scale.yaml create mode 100644 homeassistant/components/lichess/sensor.py create mode 100644 homeassistant/components/lichess/strings.json create mode 100644 tests/components/lichess/__init__.py create mode 100644 tests/components/lichess/conftest.py create mode 100644 tests/components/lichess/snapshots/test_init.ambr create mode 100644 tests/components/lichess/snapshots/test_sensor.ambr create mode 100644 tests/components/lichess/test_config_flow.py create mode 100644 tests/components/lichess/test_init.py create mode 100644 tests/components/lichess/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fff21a1d590..95d2c319c64 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -947,6 +947,8 @@ build.json @home-assistant/supervisor /tests/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44 +/homeassistant/components/lichess/ @aryanhasgithub +/tests/components/lichess/ @aryanhasgithub /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/liebherr/ @mettolen diff --git a/homeassistant/components/lichess/__init__.py b/homeassistant/components/lichess/__init__.py new file mode 100644 index 00000000000..2e76d6ed2b1 --- /dev/null +++ b/homeassistant/components/lichess/__init__.py @@ -0,0 +1,31 @@ +"""The Lichess integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LichessConfigEntry, LichessCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: LichessConfigEntry) -> bool: + """Set up Lichess from a config entry.""" + + coordinator = LichessCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LichessConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/lichess/config_flow.py b/homeassistant/components/lichess/config_flow.py new file mode 100644 index 00000000000..3cc71b389e2 --- /dev/null +++ b/homeassistant/components/lichess/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow for the Lichess integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiolichess import AioLichess +from aiolichess.exceptions import AioLichessError, AuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LichessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Lichess.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + session = async_get_clientsession(self.hass) + client = AioLichess(session=session) + try: + user = await client.get_all(token=user_input[CONF_API_TOKEN]) + except AuthError: + errors["base"] = "invalid_auth" + except AioLichessError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + username = user.username + player_id = user.id + await self.async_set_unique_id(player_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=username, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/lichess/const.py b/homeassistant/components/lichess/const.py new file mode 100644 index 00000000000..26b116653e4 --- /dev/null +++ b/homeassistant/components/lichess/const.py @@ -0,0 +1,3 @@ +"""Constants for the Lichess integration.""" + +DOMAIN = "lichess" diff --git a/homeassistant/components/lichess/coordinator.py b/homeassistant/components/lichess/coordinator.py new file mode 100644 index 00000000000..1111d157cf0 --- /dev/null +++ b/homeassistant/components/lichess/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for Lichess.""" + +from datetime import timedelta +import logging + +from aiolichess import AioLichess +from aiolichess.exceptions import AioLichessError +from aiolichess.models import LichessStatistics + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +type LichessConfigEntry = ConfigEntry[LichessCoordinator] + + +class LichessCoordinator(DataUpdateCoordinator[LichessStatistics]): + """Coordinator for Lichess.""" + + config_entry: LichessConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LichessConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=timedelta(hours=1), + ) + self.client = AioLichess(session=async_get_clientsession(hass)) + + async def _async_update_data(self) -> LichessStatistics: + """Update data for Lichess.""" + try: + return await self.client.get_statistics( + token=self.config_entry.data[CONF_API_TOKEN] + ) + except AioLichessError as err: + raise UpdateFailed("Error in communicating with Lichess") from err diff --git a/homeassistant/components/lichess/entity.py b/homeassistant/components/lichess/entity.py new file mode 100644 index 00000000000..1f6dec10fb2 --- /dev/null +++ b/homeassistant/components/lichess/entity.py @@ -0,0 +1,26 @@ +"""Base entity for Lichess integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LichessCoordinator + + +class LichessEntity(CoordinatorEntity[LichessCoordinator]): + """Base entity for Lichess integration.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LichessCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Lichess", + ) diff --git a/homeassistant/components/lichess/icons.json b/homeassistant/components/lichess/icons.json new file mode 100644 index 00000000000..6ea1bda81d8 --- /dev/null +++ b/homeassistant/components/lichess/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "blitz_games": { + "default": "mdi:chess-pawn" + }, + "blitz_rating": { + "default": "mdi:chart-line" + }, + "bullet_games": { + "default": "mdi:chess-pawn" + }, + "bullet_rating": { + "default": "mdi:chart-line" + }, + "classical_games": { + "default": "mdi:chess-pawn" + }, + "classical_rating": { + "default": "mdi:chart-line" + }, + "rapid_games": { + "default": "mdi:chess-pawn" + }, + "rapid_rating": { + "default": "mdi:chart-line" + } + } + } +} diff --git a/homeassistant/components/lichess/manifest.json b/homeassistant/components/lichess/manifest.json new file mode 100644 index 00000000000..a461e8b3a11 --- /dev/null +++ b/homeassistant/components/lichess/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lichess", + "name": "Lichess", + "codeowners": ["@aryanhasgithub"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lichess", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aiolichess==1.2.0"] +} diff --git a/homeassistant/components/lichess/quality_scale.yaml b/homeassistant/components/lichess/quality_scale.yaml new file mode 100644 index 00000000000..c6fcad64df9 --- /dev/null +++ b/homeassistant/components/lichess/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: There are no custom actions present + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: There are no custom actions present + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The entities do not explicitly subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: There are no custom actions + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration does not use discovery + discovery: + status: exempt + comment: The integration does not use discovery + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/lichess/sensor.py b/homeassistant/components/lichess/sensor.py new file mode 100644 index 00000000000..8e57d2dd59a --- /dev/null +++ b/homeassistant/components/lichess/sensor.py @@ -0,0 +1,116 @@ +"""Sensor platform for Lichess integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from aiolichess.models import LichessStatistics + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LichessConfigEntry +from .coordinator import LichessCoordinator +from .entity import LichessEntity + + +@dataclass(kw_only=True, frozen=True) +class LichessEntityDescription(SensorEntityDescription): + """Sensor description for Lichess player.""" + + value_fn: Callable[[LichessStatistics], int | None] + + +SENSORS: tuple[LichessEntityDescription, ...] = ( + LichessEntityDescription( + key="bullet_rating", + translation_key="bullet_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.bullet_rating, + ), + LichessEntityDescription( + key="bullet_games", + translation_key="bullet_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.bullet_games, + ), + LichessEntityDescription( + key="blitz_rating", + translation_key="blitz_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.blitz_rating, + ), + LichessEntityDescription( + key="blitz_games", + translation_key="blitz_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.blitz_games, + ), + LichessEntityDescription( + key="rapid_rating", + translation_key="rapid_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.rapid_rating, + ), + LichessEntityDescription( + key="rapid_games", + translation_key="rapid_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.rapid_games, + ), + LichessEntityDescription( + key="classical_rating", + translation_key="classical_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.classical_rating, + ), + LichessEntityDescription( + key="classical_games", + translation_key="classical_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.classical_games, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LichessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + coordinator = entry.runtime_data + + async_add_entities( + LichessPlayerSensor(coordinator, description) for description in SENSORS + ) + + +class LichessPlayerSensor(LichessEntity, SensorEntity): + """Lichess sensor.""" + + entity_description: LichessEntityDescription + + def __init__( + self, + coordinator: LichessCoordinator, + description: LichessEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}.{description.key}" + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lichess/strings.json b/homeassistant/components/lichess/strings.json new file mode 100644 index 00000000000..024d41e61d0 --- /dev/null +++ b/homeassistant/components/lichess/strings.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The Lichess API token of the player." + } + } + } + }, + "entity": { + "sensor": { + "blitz_games": { + "name": "Blitz games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "blitz_rating": { + "name": "Blitz rating" + }, + "bullet_games": { + "name": "Bullet games", + "unit_of_measurement": "games" + }, + "bullet_rating": { + "name": "Bullet rating" + }, + "classical_games": { + "name": "Classical games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "classical_rating": { + "name": "Classical rating" + }, + "rapid_games": { + "name": "Rapid games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "rapid_rating": { + "name": "Rapid rating" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6f2757d8191..ad9a1910090 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -388,6 +388,7 @@ FLOWS = { "lg_soundbar", "lg_thinq", "libre_hardware_monitor", + "lichess", "lidarr", "liebherr", "lifx", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4c0cf22db37..21c95f05e2b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3683,6 +3683,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "lichess": { + "name": "Lichess", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "lidarr": { "name": "Lidarr", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 4de3d236f70..0c83f0c867e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -305,6 +305,9 @@ aiokef==0.2.16 # homeassistant.components.rehlko aiokem==1.0.1 +# homeassistant.components.lichess +aiolichess==1.2.0 + # homeassistant.components.lifx aiolifx-effects==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50c23bf10d9..77ea49d12b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -290,6 +290,9 @@ aiokafka==0.10.0 # homeassistant.components.rehlko aiokem==1.0.1 +# homeassistant.components.lichess +aiolichess==1.2.0 + # homeassistant.components.lifx aiolifx-effects==0.3.2 diff --git a/tests/components/lichess/__init__.py b/tests/components/lichess/__init__.py new file mode 100644 index 00000000000..8801d53d9f8 --- /dev/null +++ b/tests/components/lichess/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Lichess integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Lichess integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lichess/conftest.py b/tests/components/lichess/conftest.py new file mode 100644 index 00000000000..27ee65293cf --- /dev/null +++ b/tests/components/lichess/conftest.py @@ -0,0 +1,68 @@ +"""Common fixtures for the Lichess tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiolichess.models import LichessStatistics, LichessUser +import pytest + +from homeassistant.components.lichess.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lichess.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="DrNykterstein", + unique_id="drnykterstien", + data={CONF_API_TOKEN: "my_secret_token"}, + ) + + +@pytest.fixture +def mock_lichess_client() -> Generator[AsyncMock]: + """Mock Lichess client.""" + with ( + patch( + "homeassistant.components.lichess.coordinator.AioLichess", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.lichess.config_flow.AioLichess", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_all.return_value = LichessUser( + id="drnykterstien", + username="DrNykterstein", + url="https://lichess.org/@/DrNykterstein", + created_at=1420502920988, + seen_at=1747342929853, + play_time=999999, + ) + client.get_user_id.return_value = "drnykterstien" + client.get_statistics.return_value = LichessStatistics( + blitz_rating=944, + rapid_rating=1050, + bullet_rating=1373, + classical_rating=888, + blitz_games=31, + rapid_games=324, + bullet_games=7, + classical_games=1, + ) + yield client diff --git a/tests/components/lichess/snapshots/test_init.ambr b/tests/components/lichess/snapshots/test_init.ambr new file mode 100644 index 00000000000..f2e13cbc054 --- /dev/null +++ b/tests/components/lichess/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'lichess', + 'drnykterstien', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Lichess', + 'model': None, + 'model_id': None, + 'name': 'DrNykterstein', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/lichess/snapshots/test_sensor.ambr b/tests/components/lichess/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5d6755c6eed --- /dev/null +++ b/tests/components/lichess/snapshots/test_sensor.ambr @@ -0,0 +1,429 @@ +# serializer version: 1 +# name: test_all_entities[sensor.drnykterstein_blitz_games-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.drnykterstein_blitz_games', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blitz games', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Blitz games', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blitz_games', + 'unique_id': 'drnykterstien.blitz_games', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_blitz_games-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Blitz games', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_blitz_games', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_blitz_rating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.drnykterstein_blitz_rating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blitz rating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Blitz rating', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blitz_rating', + 'unique_id': 'drnykterstien.blitz_rating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.drnykterstein_blitz_rating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Blitz rating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_blitz_rating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '944', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_bullet_games-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.drnykterstein_bullet_games', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bullet games', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bullet games', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bullet_games', + 'unique_id': 'drnykterstien.bullet_games', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_bullet_games-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Bullet games', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_bullet_games', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_bullet_rating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.drnykterstein_bullet_rating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bullet rating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bullet rating', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bullet_rating', + 'unique_id': 'drnykterstien.bullet_rating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.drnykterstein_bullet_rating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Bullet rating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_bullet_rating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1373', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_classical_games-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.drnykterstein_classical_games', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Classical games', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Classical games', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'classical_games', + 'unique_id': 'drnykterstien.classical_games', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_classical_games-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Classical games', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_classical_games', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_classical_rating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.drnykterstein_classical_rating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Classical rating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Classical rating', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'classical_rating', + 'unique_id': 'drnykterstien.classical_rating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.drnykterstein_classical_rating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Classical rating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_classical_rating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '888', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_rapid_games-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.drnykterstein_rapid_games', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rapid games', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rapid games', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rapid_games', + 'unique_id': 'drnykterstien.rapid_games', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_rapid_games-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Rapid games', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_rapid_games', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '324', + }) +# --- +# name: test_all_entities[sensor.drnykterstein_rapid_rating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.drnykterstein_rapid_rating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rapid rating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rapid rating', + 'platform': 'lichess', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rapid_rating', + 'unique_id': 'drnykterstien.rapid_rating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.drnykterstein_rapid_rating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DrNykterstein Rapid rating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.drnykterstein_rapid_rating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1050', + }) +# --- diff --git a/tests/components/lichess/test_config_flow.py b/tests/components/lichess/test_config_flow.py new file mode 100644 index 00000000000..2f5a874c809 --- /dev/null +++ b/tests/components/lichess/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the Lichess config flow.""" + +from unittest.mock import AsyncMock + +from aiolichess.exceptions import AioLichessError, AuthError +import pytest + +from homeassistant.components.lichess.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_lichess_client") +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "my_secret_token"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "DrNykterstein" + assert result["data"] == {CONF_API_TOKEN: "my_secret_token"} + assert result["result"].unique_id == "drnykterstien" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthError, "invalid_auth"), + (AioLichessError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_lichess_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle form errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_lichess_client.get_all.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "my_secret_token"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_lichess_client.get_all.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "my_secret_token"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_lichess_client") +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "my_secret_token"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/lichess/test_init.py b/tests/components/lichess/test_init.py new file mode 100644 index 00000000000..78ada1f061a --- /dev/null +++ b/tests/components/lichess/test_init.py @@ -0,0 +1,43 @@ +"""Test the Lichess initialization.""" + +from unittest.mock import AsyncMock + +from aiolichess.exceptions import AioLichessError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lichess.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_lichess_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Lichess device.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(DOMAIN, "drnykterstien")}) + assert device + assert device == snapshot + + +async def test_setup_entry_failed( + hass: HomeAssistant, + mock_lichess_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup fails when API raises an error.""" + mock_lichess_client.get_statistics.side_effect = AioLichessError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/lichess/test_sensor.py b/tests/components/lichess/test_sensor.py new file mode 100644 index 00000000000..128fa410b4e --- /dev/null +++ b/tests/components/lichess/test_sensor.py @@ -0,0 +1,28 @@ +"""Tests for the Lichess sensor.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_lichess_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lichess._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)