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:
@@ -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
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
37
homeassistant/components/droplet/__init__.py
Normal file
37
homeassistant/components/droplet/__init__.py
Normal 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)
|
||||
118
homeassistant/components/droplet/config_flow.py
Normal file
118
homeassistant/components/droplet/config_flow.py
Normal 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,
|
||||
)
|
||||
11
homeassistant/components/droplet/const.py
Normal file
11
homeassistant/components/droplet/const.py
Normal 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"
|
||||
84
homeassistant/components/droplet/coordinator.py
Normal file
84
homeassistant/components/droplet/coordinator.py
Normal 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()
|
||||
15
homeassistant/components/droplet/icons.json
Normal file
15
homeassistant/components/droplet/icons.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_flow_rate": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"server_connectivity": {
|
||||
"default": "mdi:web"
|
||||
},
|
||||
"signal_quality": {
|
||||
"default": "mdi:waveform"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/droplet/manifest.json
Normal file
11
homeassistant/components/droplet/manifest.json
Normal 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."]
|
||||
}
|
||||
72
homeassistant/components/droplet/quality_scale.yaml
Normal file
72
homeassistant/components/droplet/quality_scale.yaml
Normal 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
|
||||
131
homeassistant/components/droplet/sensor.py
Normal file
131
homeassistant/components/droplet/sensor.py
Normal 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)
|
||||
46
homeassistant/components/droplet/strings.json
Normal file
46
homeassistant/components/droplet/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -149,6 +149,7 @@ FLOWS = {
|
||||
"downloader",
|
||||
"dremel_3d_printer",
|
||||
"drop_connect",
|
||||
"droplet",
|
||||
"dsmr",
|
||||
"dsmr_reader",
|
||||
"duke_energy",
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@@ -464,6 +464,11 @@ ZEROCONF = {
|
||||
"domain": "daikin",
|
||||
},
|
||||
],
|
||||
"_droplet._tcp.local.": [
|
||||
{
|
||||
"domain": "droplet",
|
||||
},
|
||||
],
|
||||
"_dvl-deviceapi._tcp.local.": [
|
||||
{
|
||||
"domain": "devolo_home_control",
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
13
tests/components/droplet/__init__.py
Normal file
13
tests/components/droplet/__init__.py
Normal 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()
|
||||
108
tests/components/droplet/conftest.py
Normal file
108
tests/components/droplet/conftest.py
Normal 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
|
||||
240
tests/components/droplet/snapshots/test_sensor.ambr
Normal file
240
tests/components/droplet/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
271
tests/components/droplet/test_config_flow.py
Normal file
271
tests/components/droplet/test_config_flow.py
Normal 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
|
||||
41
tests/components/droplet/test_init.py
Normal file
41
tests/components/droplet/test_init.py
Normal 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
|
||||
46
tests/components/droplet/test_sensor.py
Normal file
46
tests/components/droplet/test_sensor.py
Normal 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"
|
||||
Reference in New Issue
Block a user