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 <joostlek@outlook.com>

* Update homeassistant/components/tilt_pi/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/tilt_pi/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* Update tests/components/tilt_pi/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <josef@zweck.dev>

* Update homeassistant/components/tilt_pi/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/tilt_pi/quality_scale.yaml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/tilt_pi/quality_scale.yaml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/tilt_pi/quality_scale.yaml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/tilt_pi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* 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 <michaelheyman@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
This commit is contained in:
Michael Heyman
2025-06-23 07:09:41 -07:00
committed by GitHub
parent 0c08b4fc8b
commit b48ebeaa8a
21 changed files with 952 additions and 5 deletions

2
CODEOWNERS generated
View File

@ -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

View File

@ -0,0 +1,5 @@
{
"domain": "tilt",
"name": "Tilt",
"integrations": ["tilt_ble", "tilt_pi"]
}

View File

@ -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)

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
"""Constants for the Tilt Pi integration."""
import logging
from typing import Final
LOGGER = logging.getLogger(__package__)
DOMAIN: Final = "tilt_pi"

View File

@ -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}

View File

@ -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

View File

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"gravity": {
"default": "mdi:water"
}
}
}
}

View File

@ -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"]
}

View File

@ -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

View File

@ -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)

View File

@ -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"
}
}
}
}

View File

@ -647,6 +647,7 @@ FLOWS = {
"tibber",
"tile",
"tilt_ble",
"tilt_pi",
"time_date",
"todoist",
"tolo",

View File

@ -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",

3
requirements_all.txt generated
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.tilt_black_gravity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'SG',
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_black_gravity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.01',
})
# ---
# name: test_all_sensors[sensor.tilt_black_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.tilt_black_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_sensors[sensor.tilt_black_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Tilt Black Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_black_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.7777777777778',
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_gravity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.tilt_yellow_gravity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'SG',
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_yellow_gravity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.015',
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.tilt_yellow_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Tilt Yellow Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_yellow_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20.0',
})
# ---

View File

@ -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,
}

View File

@ -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