mirror of
https://github.com/home-assistant/core.git
synced 2025-12-20 02:48:57 +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.doorbird.*
|
||||||
homeassistant.components.dormakaba_dkey.*
|
homeassistant.components.dormakaba_dkey.*
|
||||||
homeassistant.components.downloader.*
|
homeassistant.components.downloader.*
|
||||||
|
homeassistant.components.droplet.*
|
||||||
homeassistant.components.dsmr.*
|
homeassistant.components.dsmr.*
|
||||||
homeassistant.components.duckdns.*
|
homeassistant.components.duckdns.*
|
||||||
homeassistant.components.dunehd.*
|
homeassistant.components.dunehd.*
|
||||||
|
|||||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -377,6 +377,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/dremel_3d_printer/ @tkdrob
|
/tests/components/dremel_3d_printer/ @tkdrob
|
||||||
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||||
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||||
|
/homeassistant/components/droplet/ @sarahseidman
|
||||||
|
/tests/components/droplet/ @sarahseidman
|
||||||
/homeassistant/components/dsmr/ @Robbie1221
|
/homeassistant/components/dsmr/ @Robbie1221
|
||||||
/tests/components/dsmr/ @Robbie1221
|
/tests/components/dsmr/ @Robbie1221
|
||||||
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
/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",
|
"downloader",
|
||||||
"dremel_3d_printer",
|
"dremel_3d_printer",
|
||||||
"drop_connect",
|
"drop_connect",
|
||||||
|
"droplet",
|
||||||
"dsmr",
|
"dsmr",
|
||||||
"dsmr_reader",
|
"dsmr_reader",
|
||||||
"duke_energy",
|
"duke_energy",
|
||||||
|
|||||||
@@ -1441,6 +1441,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_push"
|
"iot_class": "local_push"
|
||||||
},
|
},
|
||||||
|
"droplet": {
|
||||||
|
"name": "Droplet",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"dsmr": {
|
"dsmr": {
|
||||||
"name": "DSMR Smart Meter",
|
"name": "DSMR Smart Meter",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@@ -464,6 +464,11 @@ ZEROCONF = {
|
|||||||
"domain": "daikin",
|
"domain": "daikin",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"_droplet._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "droplet",
|
||||||
|
},
|
||||||
|
],
|
||||||
"_dvl-deviceapi._tcp.local.": [
|
"_dvl-deviceapi._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "devolo_home_control",
|
"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_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.dsmr.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_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
|
# homeassistant.components.android_ip_webcam
|
||||||
pydroid-ipcam==3.0.0
|
pydroid-ipcam==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.droplet
|
||||||
|
pydroplet==2.3.2
|
||||||
|
|
||||||
# homeassistant.components.ebox
|
# homeassistant.components.ebox
|
||||||
pyebox==1.1.4
|
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
|
# homeassistant.components.android_ip_webcam
|
||||||
pydroid-ipcam==3.0.0
|
pydroid-ipcam==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.droplet
|
||||||
|
pydroplet==2.3.2
|
||||||
|
|
||||||
# homeassistant.components.ecoforest
|
# homeassistant.components.ecoforest
|
||||||
pyecoforest==0.4.0
|
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