diff --git a/.strict-typing b/.strict-typing index bf5b90b0091..882dec39d44 100644 --- a/.strict-typing +++ b/.strict-typing @@ -169,6 +169,7 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* +homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* homeassistant.components.dunehd.* diff --git a/CODEOWNERS b/CODEOWNERS index 6a5e4ea437b..b3e1f6c04ba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -377,6 +377,8 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer +/homeassistant/components/droplet/ @sarahseidman +/tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 /tests/components/dsmr/ @Robbie1221 /homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna diff --git a/homeassistant/components/droplet/__init__.py b/homeassistant/components/droplet/__init__.py new file mode 100644 index 00000000000..47378742804 --- /dev/null +++ b/homeassistant/components/droplet/__init__.py @@ -0,0 +1,37 @@ +"""The Droplet integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import DropletConfigEntry, DropletDataCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + +logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: DropletConfigEntry +) -> bool: + """Set up Droplet from a config entry.""" + + droplet_coordinator = DropletDataCoordinator(hass, config_entry) + await droplet_coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = droplet_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: DropletConfigEntry +) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/droplet/config_flow.py b/homeassistant/components/droplet/config_flow.py new file mode 100644 index 00000000000..c08e8c608e5 --- /dev/null +++ b/homeassistant/components/droplet/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for Droplet integration.""" + +from __future__ import annotations + +from typing import Any + +from pydroplet.droplet import DropletConnection, DropletDiscovery +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CODE, CONF_DEVICE_ID, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN + + +class DropletConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle Droplet config flow.""" + + _droplet_discovery: DropletDiscovery + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._droplet_discovery = DropletDiscovery( + discovery_info.host, + discovery_info.port, + discovery_info.name, + ) + if not self._droplet_discovery.is_valid(): + return self.async_abort(reason="invalid_discovery_info") + + # In this case, device ID was part of the zeroconf discovery info + device_id: str = await self._droplet_discovery.get_device_id() + await self.async_set_unique_id(device_id) + + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: self._droplet_discovery.host}, + ) + + self.context.update({"title_placeholders": {"name": device_id}}) + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + errors: dict[str, str] = {} + device_id: str = await self._droplet_discovery.get_device_id() + if user_input is not None: + # Test if we can connect before returning + session = async_get_clientsession(self.hass) + if await self._droplet_discovery.try_connect( + session, user_input[CONF_CODE] + ): + device_data = { + CONF_IP_ADDRESS: self._droplet_discovery.host, + CONF_PORT: self._droplet_discovery.port, + CONF_DEVICE_ID: device_id, + CONF_CODE: user_input[CONF_CODE], + } + + return self.async_create_entry( + title=device_id, + data=device_data, + ) + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_CODE): str, + } + ), + description_placeholders={ + "device_name": device_id, + }, + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + self._droplet_discovery = DropletDiscovery( + user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, "" + ) + session = async_get_clientsession(self.hass) + if await self._droplet_discovery.try_connect( + session, user_input[CONF_CODE] + ) and (device_id := await self._droplet_discovery.get_device_id()): + device_data = { + CONF_IP_ADDRESS: self._droplet_discovery.host, + CONF_PORT: self._droplet_discovery.port, + CONF_DEVICE_ID: device_id, + CONF_CODE: user_input[CONF_CODE], + } + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + description_placeholders={CONF_DEVICE_ID: device_id}, + ) + + return self.async_create_entry( + title=device_id, + data=device_data, + ) + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_CODE): str} + ), + errors=errors, + ) diff --git a/homeassistant/components/droplet/const.py b/homeassistant/components/droplet/const.py new file mode 100644 index 00000000000..3456b4fe432 --- /dev/null +++ b/homeassistant/components/droplet/const.py @@ -0,0 +1,11 @@ +"""Constants for the droplet integration.""" + +CONNECT_DELAY = 5 + +DOMAIN = "droplet" +DEVICE_NAME = "Droplet" + +KEY_CURRENT_FLOW_RATE = "current_flow_rate" +KEY_VOLUME = "volume" +KEY_SIGNAL_QUALITY = "signal_quality" +KEY_SERVER_CONNECTIVITY = "server_connectivity" diff --git a/homeassistant/components/droplet/coordinator.py b/homeassistant/components/droplet/coordinator.py new file mode 100644 index 00000000000..33a5468ebd8 --- /dev/null +++ b/homeassistant/components/droplet/coordinator.py @@ -0,0 +1,84 @@ +"""Droplet device data update coordinator object.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from pydroplet.droplet import Droplet + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECT_DELAY, DOMAIN + +VERSION_TIMEOUT = 5 + +_LOGGER = logging.getLogger(__name__) + +TIMEOUT = 1 + +type DropletConfigEntry = ConfigEntry[DropletDataCoordinator] + + +class DropletDataCoordinator(DataUpdateCoordinator[None]): + """Droplet device object.""" + + config_entry: DropletConfigEntry + + def __init__(self, hass: HomeAssistant, entry: DropletConfigEntry) -> None: + """Initialize the device.""" + super().__init__( + hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}" + ) + self.droplet = Droplet( + host=entry.data[CONF_IP_ADDRESS], + port=entry.data[CONF_PORT], + token=entry.data[CONF_CODE], + session=async_get_clientsession(self.hass), + logger=_LOGGER, + ) + assert entry.unique_id is not None + self.unique_id = entry.unique_id + + async def _async_setup(self) -> None: + if not await self.setup(): + raise ConfigEntryNotReady("Device is offline") + + # Droplet should send its metadata within 5 seconds + end = time.time() + VERSION_TIMEOUT + while not self.droplet.version_info_available(): + await asyncio.sleep(TIMEOUT) + if time.time() > end: + _LOGGER.warning("Failed to get version info from Droplet") + return + + async def _async_update_data(self) -> None: + if not self.droplet.connected: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="connection_error" + ) + + async def setup(self) -> bool: + """Set up droplet client.""" + self.config_entry.async_on_unload(self.droplet.stop_listening) + self.config_entry.async_create_background_task( + self.hass, + self.droplet.listen_forever(CONNECT_DELAY, self.async_set_updated_data), + "droplet-listen", + ) + end = time.time() + CONNECT_DELAY + while time.time() < end: + if self.droplet.connected: + return True + await asyncio.sleep(TIMEOUT) + return False + + def get_availability(self) -> bool: + """Retrieve Droplet's availability status.""" + return self.droplet.get_availability() diff --git a/homeassistant/components/droplet/icons.json b/homeassistant/components/droplet/icons.json new file mode 100644 index 00000000000..43e87959490 --- /dev/null +++ b/homeassistant/components/droplet/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "current_flow_rate": { + "default": "mdi:chart-line" + }, + "server_connectivity": { + "default": "mdi:web" + }, + "signal_quality": { + "default": "mdi:waveform" + } + } + } +} diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json new file mode 100644 index 00000000000..bd5f1ba2a0b --- /dev/null +++ b/homeassistant/components/droplet/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "droplet", + "name": "Droplet", + "codeowners": ["@sarahseidman"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/droplet", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["pydroplet==2.3.2"], + "zeroconf": ["_droplet._tcp.local."] +} diff --git a/homeassistant/components/droplet/quality_scale.yaml b/homeassistant/components/droplet/quality_scale.yaml new file mode 100644 index 00000000000..5ef0df9f3cc --- /dev/null +++ b/homeassistant/components/droplet/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions defined + appropriate-polling: + status: exempt + comment: | + No polling + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + 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: todo + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/droplet/sensor.py b/homeassistant/components/droplet/sensor.py new file mode 100644 index 00000000000..73420abc121 --- /dev/null +++ b/homeassistant/components/droplet/sensor.py @@ -0,0 +1,131 @@ +"""Support for Droplet.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from pydroplet.droplet import Droplet + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfVolume, UnitOfVolumeFlowRate +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + KEY_CURRENT_FLOW_RATE, + KEY_SERVER_CONNECTIVITY, + KEY_SIGNAL_QUALITY, + KEY_VOLUME, +) +from .coordinator import DropletConfigEntry, DropletDataCoordinator + +ML_L_CONVERSION = 1000 + + +@dataclass(kw_only=True, frozen=True) +class DropletSensorEntityDescription(SensorEntityDescription): + """Describes Droplet sensor entity.""" + + value_fn: Callable[[Droplet], float | str | None] + last_reset_fn: Callable[[Droplet], datetime | None] = lambda _: None + + +SENSORS: list[DropletSensorEntityDescription] = [ + DropletSensorEntityDescription( + key=KEY_CURRENT_FLOW_RATE, + translation_key=KEY_CURRENT_FLOW_RATE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + suggested_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.get_flow_rate(), + ), + DropletSensorEntityDescription( + key=KEY_VOLUME, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + suggested_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL, + value_fn=lambda device: device.get_volume_delta() / ML_L_CONVERSION, + last_reset_fn=lambda device: device.get_volume_last_fetched(), + ), + DropletSensorEntityDescription( + key=KEY_SERVER_CONNECTIVITY, + translation_key=KEY_SERVER_CONNECTIVITY, + device_class=SensorDeviceClass.ENUM, + options=["connected", "connecting", "disconnected"], + value_fn=lambda device: device.get_server_status(), + entity_category=EntityCategory.DIAGNOSTIC, + ), + DropletSensorEntityDescription( + key=KEY_SIGNAL_QUALITY, + translation_key=KEY_SIGNAL_QUALITY, + device_class=SensorDeviceClass.ENUM, + options=["no_signal", "weak_signal", "strong_signal"], + value_fn=lambda device: device.get_signal_quality(), + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: DropletConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Droplet sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities([DropletSensor(coordinator, sensor) for sensor in SENSORS]) + + +class DropletSensor(CoordinatorEntity[DropletDataCoordinator], SensorEntity): + """Representation of a Droplet.""" + + entity_description: DropletSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DropletDataCoordinator, + entity_description: DropletSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + unique_id = coordinator.config_entry.unique_id + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer=self.coordinator.droplet.get_manufacturer(), + model=self.coordinator.droplet.get_model(), + sw_version=self.coordinator.droplet.get_fw_version(), + serial_number=self.coordinator.droplet.get_sn(), + ) + + @property + def available(self) -> bool: + """Get Droplet's availability.""" + return self.coordinator.get_availability() + + @property + def native_value(self) -> float | str | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.droplet) + + @property + def last_reset(self) -> datetime | None: + """Return the last reset of the sensor, if applicable.""" + return self.entity_description.last_reset_fn(self.coordinator.droplet) diff --git a/homeassistant/components/droplet/strings.json b/homeassistant/components/droplet/strings.json new file mode 100644 index 00000000000..dd3697708bf --- /dev/null +++ b/homeassistant/components/droplet/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Droplet integration", + "description": "Manually enter Droplet's connection details.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "code": "Pairing code" + }, + "data_description": { + "ip_address": "Droplet's IP address", + "code": "Code from the Droplet app" + } + }, + "confirm": { + "title": "Confirm association", + "description": "Enter pairing code to connect to {device_name}.", + "data": { + "code": "[%key:component::droplet::config::step::user::data::code%]" + }, + "data_description": { + "code": "[%key:component::droplet::config::step::user::data_description::code%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "server_connectivity": { "name": "Server status" }, + "signal_quality": { "name": "Signal quality" }, + "current_flow_rate": { "name": "Flow rate" } + } + }, + "exceptions": { + "connection_error": { + "message": "Disconnected from Droplet" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d636fce1d3c..fdbaf7f0451 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -149,6 +149,7 @@ FLOWS = { "downloader", "dremel_3d_printer", "drop_connect", + "droplet", "dsmr", "dsmr_reader", "duke_energy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 183c7956275..63b10e06e48 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1441,6 +1441,12 @@ "config_flow": true, "iot_class": "local_push" }, + "droplet": { + "name": "Droplet", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dsmr": { "name": "DSMR Smart Meter", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 742840fa849..2162af50158 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -464,6 +464,11 @@ ZEROCONF = { "domain": "daikin", }, ], + "_droplet._tcp.local.": [ + { + "domain": "droplet", + }, + ], "_dvl-deviceapi._tcp.local.": [ { "domain": "devolo_home_control", diff --git a/mypy.ini b/mypy.ini index 5787bb8de84..b147bdd3f5a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1446,6 +1446,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.droplet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dsmr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 095220e8f6a..bb93e3e7c68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1945,6 +1945,9 @@ pydrawise==2025.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 +# homeassistant.components.droplet +pydroplet==2.3.2 + # homeassistant.components.ebox pyebox==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d1fc01b5b6..e38058831e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1626,6 +1626,9 @@ pydrawise==2025.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 +# homeassistant.components.droplet +pydroplet==2.3.2 + # homeassistant.components.ecoforest pyecoforest==0.4.0 diff --git a/tests/components/droplet/__init__.py b/tests/components/droplet/__init__.py new file mode 100644 index 00000000000..633b89a9749 --- /dev/null +++ b/tests/components/droplet/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Droplet 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 component.""" + 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/droplet/conftest.py b/tests/components/droplet/conftest.py new file mode 100644 index 00000000000..8b3792a95fe --- /dev/null +++ b/tests/components/droplet/conftest.py @@ -0,0 +1,108 @@ +"""Common fixtures for the Droplet tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.droplet.const import DOMAIN +from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT + +from tests.common import MockConfigEntry + +MOCK_CODE = "11223" +MOCK_HOST = "192.168.1.2" +MOCK_PORT = 443 +MOCK_DEVICE_ID = "Droplet-1234" +MOCK_MANUFACTURER = "Hydrific, part of LIXIL" +MOCK_SN = "1234" +MOCK_SW_VERSION = "v1.0.0" +MOCK_MODEL = "Droplet 1.0" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_CODE: MOCK_CODE}, + unique_id=MOCK_DEVICE_ID, + ) + + +@pytest.fixture +def mock_droplet() -> Generator[AsyncMock]: + """Mock a Droplet client.""" + with ( + patch( + "homeassistant.components.droplet.coordinator.Droplet", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + client.get_signal_quality.return_value = "strong_signal" + client.get_server_status.return_value = "connected" + client.get_flow_rate.return_value = 0.1 + client.get_manufacturer.return_value = MOCK_MANUFACTURER + client.get_model.return_value = MOCK_MODEL + client.get_fw_version.return_value = MOCK_SW_VERSION + client.get_sn.return_value = MOCK_SN + client.get_volume_last_fetched.return_value = datetime( + year=2020, month=1, day=1 + ) + yield client + + +@pytest.fixture(autouse=True) +def mock_timeout() -> Generator[None]: + """Mock the timeout.""" + with ( + patch( + "homeassistant.components.droplet.coordinator.TIMEOUT", + 0.05, + ), + patch( + "homeassistant.components.droplet.coordinator.VERSION_TIMEOUT", + 0.1, + ), + patch( + "homeassistant.components.droplet.coordinator.CONNECT_DELAY", + 0.1, + ), + ): + yield + + +@pytest.fixture +def mock_droplet_connection() -> Generator[AsyncMock]: + """Mock a Droplet connection.""" + with ( + patch( + "homeassistant.components.droplet.config_flow.DropletConnection", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_droplet_discovery(request: pytest.FixtureRequest) -> Generator[AsyncMock]: + """Mock a DropletDiscovery.""" + with ( + patch( + "homeassistant.components.droplet.config_flow.DropletDiscovery", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + # Not all tests set this value + try: + client.host = request.param + except AttributeError: + client.host = MOCK_HOST + client.port = MOCK_PORT + client.try_connect.return_value = True + client.get_device_id.return_value = MOCK_DEVICE_ID + yield client diff --git a/tests/components/droplet/snapshots/test_sensor.ambr b/tests/components/droplet/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..aa92d6df0af --- /dev/null +++ b/tests/components/droplet/snapshots/test_sensor.ambr @@ -0,0 +1,240 @@ +# serializer version: 1 +# name: test_sensors[sensor.mock_title_flow_rate-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.mock_title_flow_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow rate', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_flow_rate', + 'unique_id': 'Droplet-1234_current_flow_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.mock_title_flow_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Mock Title Flow rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_flow_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0264172052358148', + }) +# --- +# name: test_sensors[sensor.mock_title_server_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'connecting', + 'disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_server_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Server status', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'server_connectivity', + 'unique_id': 'Droplet-1234_server_connectivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_server_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Server status', + 'options': list([ + 'connected', + 'connecting', + 'disconnected', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_server_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_sensors[sensor.mock_title_signal_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_signal', + 'weak_signal', + 'strong_signal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_signal_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal quality', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'signal_quality', + 'unique_id': 'Droplet-1234_signal_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_signal_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Signal quality', + 'options': list([ + 'no_signal', + 'weak_signal', + 'strong_signal', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_signal_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'strong_signal', + }) +# --- +# name: test_sensors[sensor.mock_title_water-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.mock_title_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Droplet-1234_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.mock_title_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Mock Title Water', + 'last_reset': '2020-01-01T00:00:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.264172052358148', + }) +# --- diff --git a/tests/components/droplet/test_config_flow.py b/tests/components/droplet/test_config_flow.py new file mode 100644 index 00000000000..88a66664c8f --- /dev/null +++ b/tests/components/droplet/test_config_flow.py @@ -0,0 +1,271 @@ +"""Test Droplet config flow.""" + +from ipaddress import IPv4Address +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.droplet.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import ( + ATTR_CODE, + CONF_CODE, + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .conftest import MOCK_CODE, MOCK_DEVICE_ID, MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry + + +async def test_user_setup( + hass: HomeAssistant, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test successful Droplet user setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: "192.168.1.2"}, + ) + assert result is not None + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_CODE: MOCK_CODE, + CONF_DEVICE_ID: MOCK_DEVICE_ID, + CONF_IP_ADDRESS: MOCK_HOST, + CONF_PORT: MOCK_PORT, + } + assert result.get("context") is not None + assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID + + +@pytest.mark.parametrize( + ("device_id", "connect_res"), + [ + ( + "", + True, + ), + (MOCK_DEVICE_ID, False), + ], + ids=["no_device_id", "cannot_connect"], +) +async def test_user_setup_fail( + hass: HomeAssistant, + device_id: str, + connect_res: bool, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test user setup failing due to no device ID or failed connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + attrs = { + "get_device_id.return_value": device_id, + "try_connect.return_value": connect_res, + } + mock_droplet_discovery.configure_mock(**attrs) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST}, + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "cannot_connect"} + + # The user should be able to try again. Maybe the droplet was disconnected from the network or something + attrs = { + "get_device_id.return_value": MOCK_DEVICE_ID, + "try_connect.return_value": True, + } + mock_droplet_discovery.configure_mock(**attrs) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST}, + ) + assert result is not None + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_user_setup_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet: AsyncMock, + mock_droplet_connection: AsyncMock, +) -> None: + """Test user setup of an already-configured device.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST}, + ) + assert result is not None + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_zeroconf_setup( + hass: HomeAssistant, + mock_droplet_discovery: AsyncMock, + mock_droplet: AsyncMock, + mock_droplet_connection: AsyncMock, +) -> None: + """Test successful setup of Droplet via zeroconf.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CODE: MOCK_CODE} + ) + assert result is not None + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_DEVICE_ID: MOCK_DEVICE_ID, + CONF_IP_ADDRESS: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_CODE: MOCK_CODE, + } + assert result.get("context") is not None + assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID + + +@pytest.mark.parametrize("mock_droplet_discovery", ["192.168.1.5"], indirect=True) +async def test_zeroconf_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, +) -> None: + """Test updating Droplet's host with zeroconf.""" + mock_config_entry.add_to_hass(hass) + + # We start with a different host + new_host = "192.168.1.5" + assert mock_config_entry.data[CONF_IP_ADDRESS] != new_host + + # After this discovery message, host should be updated + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(new_host), + ip_addresses=[IPv4Address(new_host)], + port=MOCK_PORT, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result is not None + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + assert mock_config_entry.data[CONF_IP_ADDRESS] == new_host + + +async def test_zeroconf_invalid_discovery(hass: HomeAssistant) -> None: + """Test that invalid discovery information causes the config flow to abort.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=-1, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result is not None + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_confirm_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet_discovery: AsyncMock, +) -> None: + """Test that config flow fails when Droplet can't connect.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result.get("type") is FlowResultType.FORM + + # Mock the connection failing + mock_droplet_discovery.try_connect.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {ATTR_CODE: MOCK_CODE} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors")["base"] == "cannot_connect" + + mock_droplet_discovery.try_connect.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ATTR_CODE: MOCK_CODE} + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY, result diff --git a/tests/components/droplet/test_init.py b/tests/components/droplet/test_init.py new file mode 100644 index 00000000000..7c4f98c62e7 --- /dev/null +++ b/tests/components/droplet/test_init.py @@ -0,0 +1,41 @@ +"""Test Droplet initialization.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_no_version_info( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator setup where Droplet never sends version info.""" + mock_droplet.version_info_available.return_value = False + await setup_integration(hass, mock_config_entry) + + assert "Failed to get version info from Droplet" in caplog.text + + +async def test_setup_droplet_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test integration setup when Droplet is offline.""" + mock_droplet.connected = False + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/droplet/test_sensor.py b/tests/components/droplet/test_sensor.py new file mode 100644 index 00000000000..9dcc72403f6 --- /dev/null +++ b/tests/components/droplet/test_sensor.py @@ -0,0 +1,46 @@ +"""Test Droplet sensors.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Droplet sensors.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_update_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test Droplet async update data.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.mock_title_flow_rate").state == "0.0264172052358148" + + mock_droplet.get_flow_rate.return_value = 0.5 + + mock_droplet.listen_forever.call_args_list[0][0][1]({}) + + assert hass.states.get("sensor.mock_title_flow_rate").state == "0.132086026179074"