From b48ebeaa8a6f9ccdafb0744ef0d9fe9a6403357a Mon Sep 17 00:00:00 2001 From: Michael Heyman <2618514+michaelheyman@users.noreply.github.com> Date: Mon, 23 Jun 2025 07:09:41 -0700 Subject: [PATCH] Tilt Pi integration (#139726) * Create component via script.scaffold * Create sensor definition * Define coordinator * Define config flow * Refine sensor definition and add tests * Refine coordinator after testing end to end * Redefine sensor in a more idiomatic way * Use entity (common-module) * Follow config-flow conventions more closely * Use custom ConfigEntry to conform to strict-typing * Define API object instead of using aio directly * Test before setup in init * Add diagnostics * Make some more quality changes * Move scan interval to const * Commit generated files * Add quality scale * feedback: Apply consistent language to Tilt Pi refs * feedback: Remove empty manifest fields * feedback: Use translations instead of hardcoded name * feedback: Remove diagnostics * feedback: Idiomatic and general improvements * Use tilt-pi library * feedback: Coordinator data returns dict * feedback: Move client creation to coordinator * feedback: Request only Tilt Pi URL from user * Update homeassistant/components/tilt_pi/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tilt_pi/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tilt_pi/entity.py Co-authored-by: Joost Lekkerkerker * feedback: Avoid redundant keyword arguments in function calls * feedback: Remove unused models and variables * feedback: Use icons.json * feedback: Style best practices * Update homeassistant/components/tilt_pi/entity.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tilt_pi/test_config_flow.py Co-authored-by: Joost Lekkerkerker * feedback: Improve config flow unit tests * feedback: Patch TiltPi client mock * feedback: Mark entity-device-class as done * feedback: Align quaity scale with current state * feeback: Create brands file for Tilt brand * feedback: Demonstrate recovery in config flow * feedback: Test coordinator behavior via sensors * Update homeassistant/components/tilt_pi/config_flow.py Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/coordinator.py Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/config_flow.py Co-authored-by: Josef Zweck * feedback: Update tilt_pi quality scale * feedback: Move const to coordinator * feedback: Correct strings.json for incorrect and missing fields * feedback: Use tiltpi package version published via CI * Run ruff format manually * Add missing string for invalid host * Fix * Fix --------- Co-authored-by: Michael Heyman Co-authored-by: Joost Lekkerkerker Co-authored-by: Josef Zweck --- CODEOWNERS | 2 + homeassistant/brands/tilt.json | 5 + homeassistant/components/tilt_pi/__init__.py | 28 +++ .../components/tilt_pi/config_flow.py | 63 +++++ homeassistant/components/tilt_pi/const.py | 8 + .../components/tilt_pi/coordinator.py | 53 +++++ homeassistant/components/tilt_pi/entity.py | 39 ++++ homeassistant/components/tilt_pi/icons.json | 9 + .../components/tilt_pi/manifest.json | 10 + .../components/tilt_pi/quality_scale.yaml | 80 +++++++ homeassistant/components/tilt_pi/sensor.py | 93 ++++++++ homeassistant/components/tilt_pi/strings.json | 31 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 21 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tilt_pi/__init__.py | 12 + tests/components/tilt_pi/conftest.py | 70 ++++++ .../tilt_pi/snapshots/test_sensor.ambr | 217 ++++++++++++++++++ tests/components/tilt_pi/test_config_flow.py | 125 ++++++++++ tests/components/tilt_pi/test_sensor.py | 84 +++++++ 21 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/tilt.json create mode 100644 homeassistant/components/tilt_pi/__init__.py create mode 100644 homeassistant/components/tilt_pi/config_flow.py create mode 100644 homeassistant/components/tilt_pi/const.py create mode 100644 homeassistant/components/tilt_pi/coordinator.py create mode 100644 homeassistant/components/tilt_pi/entity.py create mode 100644 homeassistant/components/tilt_pi/icons.json create mode 100644 homeassistant/components/tilt_pi/manifest.json create mode 100644 homeassistant/components/tilt_pi/quality_scale.yaml create mode 100644 homeassistant/components/tilt_pi/sensor.py create mode 100644 homeassistant/components/tilt_pi/strings.json create mode 100644 tests/components/tilt_pi/__init__.py create mode 100644 tests/components/tilt_pi/conftest.py create mode 100644 tests/components/tilt_pi/snapshots/test_sensor.ambr create mode 100644 tests/components/tilt_pi/test_config_flow.py create mode 100644 tests/components/tilt_pi/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1ceb6ff0e7d..da247c06cb8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1580,6 +1580,8 @@ build.json @home-assistant/supervisor /tests/components/tile/ @bachya /homeassistant/components/tilt_ble/ @apt-itude /tests/components/tilt_ble/ @apt-itude +/homeassistant/components/tilt_pi/ @michaelheyman +/tests/components/tilt_pi/ @michaelheyman /homeassistant/components/time/ @home-assistant/core /tests/components/time/ @home-assistant/core /homeassistant/components/time_date/ @fabaff diff --git a/homeassistant/brands/tilt.json b/homeassistant/brands/tilt.json new file mode 100644 index 00000000000..0b78925780f --- /dev/null +++ b/homeassistant/brands/tilt.json @@ -0,0 +1,5 @@ +{ + "domain": "tilt", + "name": "Tilt", + "integrations": ["tilt_ble", "tilt_pi"] +} diff --git a/homeassistant/components/tilt_pi/__init__.py b/homeassistant/components/tilt_pi/__init__.py new file mode 100644 index 00000000000..6b292aed302 --- /dev/null +++ b/homeassistant/components/tilt_pi/__init__.py @@ -0,0 +1,28 @@ +"""The Tilt Pi integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: TiltPiConfigEntry) -> bool: + """Set up Tilt Pi from a config entry.""" + coordinator = TiltPiDataUpdateCoordinator( + 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: TiltPiConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tilt_pi/config_flow.py b/homeassistant/components/tilt_pi/config_flow.py new file mode 100644 index 00000000000..7770ce372d8 --- /dev/null +++ b/homeassistant/components/tilt_pi/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Tilt Pi integration.""" + +from typing import Any + +import aiohttp +from tiltpi import TiltPiClient, TiltPiError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class TiltPiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tilt Pi.""" + + async def _check_connection(self, host: str, port: int) -> str | None: + """Check if we can connect to the TiltPi instance.""" + client = TiltPiClient( + host, + port, + session=async_get_clientsession(self.hass), + ) + try: + await client.get_hydrometers() + except (TiltPiError, TimeoutError, aiohttp.ClientError): + return "cannot_connect" + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a configuration flow initialized by the user.""" + + errors = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + if (host := url.host) is None: + errors[CONF_URL] = "invalid_host" + else: + self._async_abort_entries_match({CONF_HOST: host}) + port = url.port + assert port + error = await self._check_connection(host=host, port=port) + if error: + errors["base"] = error + else: + return self.async_create_entry( + title="Tilt Pi", + data={ + CONF_HOST: host, + CONF_PORT: port, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_URL): str}), + errors=errors, + ) diff --git a/homeassistant/components/tilt_pi/const.py b/homeassistant/components/tilt_pi/const.py new file mode 100644 index 00000000000..a60b737c20f --- /dev/null +++ b/homeassistant/components/tilt_pi/const.py @@ -0,0 +1,8 @@ +"""Constants for the Tilt Pi integration.""" + +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "tilt_pi" diff --git a/homeassistant/components/tilt_pi/coordinator.py b/homeassistant/components/tilt_pi/coordinator.py new file mode 100644 index 00000000000..e2b14861a89 --- /dev/null +++ b/homeassistant/components/tilt_pi/coordinator.py @@ -0,0 +1,53 @@ +"""Data update coordinator for Tilt Pi.""" + +from datetime import timedelta +from typing import Final + +from tiltpi import TiltHydrometerData, TiltPiClient, TiltPiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +SCAN_INTERVAL: Final = timedelta(seconds=60) + +type TiltPiConfigEntry = ConfigEntry[TiltPiDataUpdateCoordinator] + + +class TiltPiDataUpdateCoordinator(DataUpdateCoordinator[dict[str, TiltHydrometerData]]): + """Class to manage fetching Tilt Pi data.""" + + config_entry: TiltPiConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TiltPiConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Tilt Pi", + update_interval=SCAN_INTERVAL, + ) + self._api = TiltPiClient( + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + session=async_get_clientsession(hass), + ) + self.identifier = config_entry.entry_id + + async def _async_update_data(self) -> dict[str, TiltHydrometerData]: + """Fetch data from Tilt Pi and return as a dict keyed by mac_id.""" + try: + hydrometers = await self._api.get_hydrometers() + except TiltPiError as err: + raise UpdateFailed(f"Error communicating with Tilt Pi: {err}") from err + + return {h.mac_id: h for h in hydrometers} diff --git a/homeassistant/components/tilt_pi/entity.py b/homeassistant/components/tilt_pi/entity.py new file mode 100644 index 00000000000..c1cf8913843 --- /dev/null +++ b/homeassistant/components/tilt_pi/entity.py @@ -0,0 +1,39 @@ +"""Base entity for Tilt Pi integration.""" + +from tiltpi import TiltHydrometerData + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TiltPiDataUpdateCoordinator + + +class TiltEntity(CoordinatorEntity[TiltPiDataUpdateCoordinator]): + """Base class for Tilt entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TiltPiDataUpdateCoordinator, + hydrometer: TiltHydrometerData, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._mac_id = hydrometer.mac_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, hydrometer.mac_id)}, + name=f"Tilt {hydrometer.color}", + manufacturer="Tilt Hydrometer", + model=f"{hydrometer.color} Tilt Hydrometer", + ) + + @property + def current_hydrometer(self) -> TiltHydrometerData: + """Return the current hydrometer data for this entity.""" + return self.coordinator.data[self._mac_id] + + @property + def available(self) -> bool: + """Return True if the hydrometer is available (present in coordinator data).""" + return super().available and self._mac_id in self.coordinator.data diff --git a/homeassistant/components/tilt_pi/icons.json b/homeassistant/components/tilt_pi/icons.json new file mode 100644 index 00000000000..1f23c518c38 --- /dev/null +++ b/homeassistant/components/tilt_pi/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "gravity": { + "default": "mdi:water" + } + } + } +} diff --git a/homeassistant/components/tilt_pi/manifest.json b/homeassistant/components/tilt_pi/manifest.json new file mode 100644 index 00000000000..94c6b7ade86 --- /dev/null +++ b/homeassistant/components/tilt_pi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tilt_pi", + "name": "Tilt Pi", + "codeowners": ["@michaelheyman"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tilt_pi", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["tilt-pi==0.2.1"] +} diff --git a/homeassistant/components/tilt_pi/quality_scale.yaml b/homeassistant/components/tilt_pi/quality_scale.yaml new file mode 100644 index 00000000000..725ff971067 --- /dev/null +++ b/homeassistant/components/tilt_pi/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does 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: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: done + comment: | + The entities are categorized well by using default category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: No disabled entities implemented + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/tilt_pi/sensor.py b/homeassistant/components/tilt_pi/sensor.py new file mode 100644 index 00000000000..4ce40e70bdb --- /dev/null +++ b/homeassistant/components/tilt_pi/sensor.py @@ -0,0 +1,93 @@ +"""Support for Tilt Hydrometer sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from tiltpi import TiltHydrometerData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator +from .entity import TiltEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +ATTR_TEMPERATURE = "temperature" +ATTR_GRAVITY = "gravity" + + +@dataclass(frozen=True, kw_only=True) +class TiltEntityDescription(SensorEntityDescription): + """Describes TiltHydrometerData sensor entity.""" + + value_fn: Callable[[TiltHydrometerData], StateType] + + +SENSOR_TYPES: Final[list[TiltEntityDescription]] = [ + TiltEntityDescription( + key=ATTR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.temperature, + ), + TiltEntityDescription( + key=ATTR_GRAVITY, + translation_key="gravity", + native_unit_of_measurement="SG", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.gravity, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TiltPiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Tilt Hydrometer sensors.""" + coordinator = config_entry.runtime_data + + async_add_entities( + TiltSensor( + coordinator, + description, + hydrometer, + ) + for description in SENSOR_TYPES + for hydrometer in coordinator.data.values() + ) + + +class TiltSensor(TiltEntity, SensorEntity): + """Defines a Tilt sensor.""" + + entity_description: TiltEntityDescription + + def __init__( + self, + coordinator: TiltPiDataUpdateCoordinator, + description: TiltEntityDescription, + hydrometer: TiltHydrometerData, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, hydrometer) + self.entity_description = description + self._attr_unique_id = f"{hydrometer.mac_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.current_hydrometer) diff --git a/homeassistant/components/tilt_pi/strings.json b/homeassistant/components/tilt_pi/strings.json new file mode 100644 index 00000000000..9af85c86641 --- /dev/null +++ b/homeassistant/components/tilt_pi/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Tilt Pi instance." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "entity": { + "sensor": { + "gravity": { + "name": "Gravity" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 119830b6111..e164bc09929 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -647,6 +647,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "tilt_pi", "time_date", "todoist", "tolo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02dce5f8a3b..1202d7d51ec 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6745,11 +6745,22 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "tilt_ble": { - "name": "Tilt Hydrometer BLE", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "tilt": { + "name": "Tilt", + "integrations": { + "tilt_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Tilt Hydrometer BLE" + }, + "tilt_pi": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Tilt Pi" + } + } }, "time_date": { "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 980d443db58..ffb47933565 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2936,6 +2936,9 @@ tikteck==0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 +# homeassistant.components.tilt_pi +tilt-pi==0.2.1 + # homeassistant.components.tmb tmb==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e83587684e..cb71a90eee1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2413,6 +2413,9 @@ thinqconnect==1.0.5 # homeassistant.components.tilt_ble tilt-ble==0.2.3 +# homeassistant.components.tilt_pi +tilt-pi==0.2.1 + # homeassistant.components.todoist todoist-api-python==2.1.7 diff --git a/tests/components/tilt_pi/__init__.py b/tests/components/tilt_pi/__init__.py new file mode 100644 index 00000000000..a6109c66ca5 --- /dev/null +++ b/tests/components/tilt_pi/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Tilt Pi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/tilt_pi/conftest.py b/tests/components/tilt_pi/conftest.py new file mode 100644 index 00000000000..dada9596be5 --- /dev/null +++ b/tests/components/tilt_pi/conftest.py @@ -0,0 +1,70 @@ +"""Common fixtures for the Tilt Pi tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from tiltpi import TiltColor, TiltHydrometerData + +from homeassistant.components.tilt_pi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +TEST_NAME = "Test Tilt Pi" +TEST_HOST = "192.168.1.123" +TEST_PORT = 1880 +TEST_URL = f"http://{TEST_HOST}:{TEST_PORT}" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.tilt_pi.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + }, + ) + + +@pytest.fixture +def mock_tiltpi_client() -> Generator[AsyncMock]: + """Mock a TiltPi client.""" + with ( + patch( + "homeassistant.components.tilt_pi.coordinator.TiltPiClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.tilt_pi.config_flow.TiltPiClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_hydrometers.return_value = [ + TiltHydrometerData( + mac_id="00:1A:2B:3C:4D:5E", + color=TiltColor.BLACK, + temperature=55.0, + gravity=1.010, + ), + TiltHydrometerData( + mac_id="00:1s:99:f1:d2:4f", + color=TiltColor.YELLOW, + temperature=68.0, + gravity=1.015, + ), + ] + yield client diff --git a/tests/components/tilt_pi/snapshots/test_sensor.ambr b/tests/components/tilt_pi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bcee6881d75 --- /dev/null +++ b/tests/components/tilt_pi/snapshots/test_sensor.ambr @@ -0,0 +1,217 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.tilt_black_gravity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.tilt_black_gravity', + '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': 'Gravity', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gravity', + 'unique_id': '00:1A:2B:3C:4D:5E_gravity', + 'unit_of_measurement': 'SG', + }) +# --- +# name: test_all_sensors[sensor.tilt_black_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tilt Black Gravity', + 'state_class': , + 'unit_of_measurement': 'SG', + }), + 'context': , + 'entity_id': 'sensor.tilt_black_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.01', + }) +# --- +# name: test_all_sensors[sensor.tilt_black_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.tilt_black_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:1A:2B:3C:4D:5E_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.tilt_black_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Tilt Black Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.tilt_black_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.7777777777778', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_gravity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.tilt_yellow_gravity', + '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': 'Gravity', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gravity', + 'unique_id': '00:1s:99:f1:d2:4f_gravity', + 'unit_of_measurement': 'SG', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tilt Yellow Gravity', + 'state_class': , + 'unit_of_measurement': 'SG', + }), + 'context': , + 'entity_id': 'sensor.tilt_yellow_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.015', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.tilt_yellow_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:1s:99:f1:d2:4f_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Tilt Yellow Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.tilt_yellow_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- diff --git a/tests/components/tilt_pi/test_config_flow.py b/tests/components/tilt_pi/test_config_flow.py new file mode 100644 index 00000000000..f9b9693b9f8 --- /dev/null +++ b/tests/components/tilt_pi/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the Tilt config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.tilt_pi.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_async_step_user_gets_form_and_creates_entry( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the we can view the form and that the config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort if we attempt to submit the same entry twice.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_successful_recovery_after_invalid_host( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when user submits invalid host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a invalid host error by providing an invalid URL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "not-a-valid-url"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"url": "invalid_host"} + + # Demonstrate successful connection on retry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } + + +async def test_successful_recovery_after_connection_error( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when connection fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a connection error by raising a TimeoutError + mock_tiltpi_client.get_hydrometers.side_effect = TimeoutError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Simulate successful connection on retry + mock_tiltpi_client.get_hydrometers.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } diff --git a/tests/components/tilt_pi/test_sensor.py b/tests/components/tilt_pi/test_sensor.py new file mode 100644 index 00000000000..cb4e02818c7 --- /dev/null +++ b/tests/components/tilt_pi/test_sensor.py @@ -0,0 +1,84 @@ +"""Test the Tilt Hydrometer sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion +from tiltpi import TiltColor, TiltPiConnectionError + +from homeassistant.const import STATE_UNAVAILABLE, 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, snapshot_platform + + +async def test_all_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tiltpi_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Tilt Pi sensors. + + When making changes to this test, ensure that the snapshot reflects the + new data by generating it via: + + $ pytest tests/components/tilt_pi/test_sensor.py -v --snapshot-update + """ + with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tiltpi_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities become unavailable when the coordinator fails.""" + with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + # Simulate a coordinator update failure + mock_tiltpi_client.get_hydrometers.side_effect = TiltPiConnectionError() + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that entities are unavailable + for color in (TiltColor.BLACK, TiltColor.YELLOW): + temperature_entity_id = f"sensor.tilt_{color}_temperature" + gravity_entity_id = f"sensor.tilt_{color}_gravity" + + temperature_state = hass.states.get(temperature_entity_id) + assert temperature_state is not None + assert temperature_state.state == STATE_UNAVAILABLE + + gravity_state = hass.states.get(gravity_entity_id) + assert gravity_state is not None + assert gravity_state.state == STATE_UNAVAILABLE + + # Simulate a coordinator update success + mock_tiltpi_client.get_hydrometers.side_effect = None + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that entities are now available + for color in (TiltColor.BLACK, TiltColor.YELLOW): + temperature_entity_id = f"sensor.tilt_{color}_temperature" + gravity_entity_id = f"sensor.tilt_{color}_gravity" + + temperature_state = hass.states.get(temperature_entity_id) + assert temperature_state is not None + assert temperature_state.state != STATE_UNAVAILABLE + + gravity_state = hass.states.get(gravity_entity_id) + assert gravity_state is not None + assert gravity_state.state != STATE_UNAVAILABLE