1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +00:00

Add integration for Droplet (#149989)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Sarah Seidman
2025-09-10 11:38:49 -04:00
committed by GitHub
parent 2cda0817b2
commit ceeeb22040
23 changed files with 1275 additions and 0 deletions

View File

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

2
CODEOWNERS generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"current_flow_rate": {
"default": "mdi:chart-line"
},
"server_connectivity": {
"default": "mdi:web"
},
"signal_quality": {
"default": "mdi:waveform"
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -149,6 +149,7 @@ FLOWS = {
"downloader",
"dremel_3d_printer",
"drop_connect",
"droplet",
"dsmr",
"dsmr_reader",
"duke_energy",

View File

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

View File

@@ -464,6 +464,11 @@ ZEROCONF = {
"domain": "daikin",
},
],
"_droplet._tcp.local.": [
{
"domain": "droplet",
},
],
"_dvl-deviceapi._tcp.local.": [
{
"domain": "devolo_home_control",

10
mypy.ini generated
View File

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

3
requirements_all.txt generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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': <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.mock_title_flow_rate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 'gal/min'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME_FLOW_RATE: 'volume_flow_rate'>,
'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': <UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 'gal/min'>,
})
# ---
# 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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 'gal/min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_flow_rate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_title_server_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_server_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_title_signal_quality',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_signal_quality',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'strong_signal',
})
# ---
# name: test_sensors[sensor.mock_title_water-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'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.mock_title_water',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
}),
}),
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'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': <UnitOfVolume.GALLONS: 'gal'>,
})
# ---
# 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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_water',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.264172052358148',
})
# ---

View File

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

View File

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

View File

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