Skip to content

Commit ceeeb22

Browse files
sarahseidmanNoRi2909zweckjjoostlek
authored
Add integration for Droplet (#149989)
Co-authored-by: Norbert Rittel <[email protected]> Co-authored-by: Josef Zweck <[email protected]> Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 2cda081 commit ceeeb22

23 files changed

+1275
-0
lines changed

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ homeassistant.components.dnsip.*
169169
homeassistant.components.doorbird.*
170170
homeassistant.components.dormakaba_dkey.*
171171
homeassistant.components.downloader.*
172+
homeassistant.components.droplet.*
172173
homeassistant.components.dsmr.*
173174
homeassistant.components.duckdns.*
174175
homeassistant.components.dunehd.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""The Droplet integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from homeassistant.const import Platform
8+
from homeassistant.core import HomeAssistant
9+
10+
from .coordinator import DropletConfigEntry, DropletDataCoordinator
11+
12+
PLATFORMS: list[Platform] = [
13+
Platform.SENSOR,
14+
]
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
async def async_setup_entry(
20+
hass: HomeAssistant, config_entry: DropletConfigEntry
21+
) -> bool:
22+
"""Set up Droplet from a config entry."""
23+
24+
droplet_coordinator = DropletDataCoordinator(hass, config_entry)
25+
await droplet_coordinator.async_config_entry_first_refresh()
26+
config_entry.runtime_data = droplet_coordinator
27+
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
28+
29+
return True
30+
31+
32+
async def async_unload_entry(
33+
hass: HomeAssistant, config_entry: DropletConfigEntry
34+
) -> bool:
35+
"""Unload a config entry."""
36+
37+
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Config flow for Droplet integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pydroplet.droplet import DropletConnection, DropletDiscovery
8+
import voluptuous as vol
9+
10+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
11+
from homeassistant.const import CONF_CODE, CONF_DEVICE_ID, CONF_IP_ADDRESS, CONF_PORT
12+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
13+
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
14+
15+
from .const import DOMAIN
16+
17+
18+
class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
19+
"""Handle Droplet config flow."""
20+
21+
_droplet_discovery: DropletDiscovery
22+
23+
async def async_step_zeroconf(
24+
self, discovery_info: ZeroconfServiceInfo
25+
) -> ConfigFlowResult:
26+
"""Handle zeroconf discovery."""
27+
self._droplet_discovery = DropletDiscovery(
28+
discovery_info.host,
29+
discovery_info.port,
30+
discovery_info.name,
31+
)
32+
if not self._droplet_discovery.is_valid():
33+
return self.async_abort(reason="invalid_discovery_info")
34+
35+
# In this case, device ID was part of the zeroconf discovery info
36+
device_id: str = await self._droplet_discovery.get_device_id()
37+
await self.async_set_unique_id(device_id)
38+
39+
self._abort_if_unique_id_configured(
40+
updates={CONF_IP_ADDRESS: self._droplet_discovery.host},
41+
)
42+
43+
self.context.update({"title_placeholders": {"name": device_id}})
44+
return await self.async_step_confirm()
45+
46+
async def async_step_confirm(
47+
self, user_input: dict[str, Any] | None = None
48+
) -> ConfigFlowResult:
49+
"""Confirm the setup."""
50+
errors: dict[str, str] = {}
51+
device_id: str = await self._droplet_discovery.get_device_id()
52+
if user_input is not None:
53+
# Test if we can connect before returning
54+
session = async_get_clientsession(self.hass)
55+
if await self._droplet_discovery.try_connect(
56+
session, user_input[CONF_CODE]
57+
):
58+
device_data = {
59+
CONF_IP_ADDRESS: self._droplet_discovery.host,
60+
CONF_PORT: self._droplet_discovery.port,
61+
CONF_DEVICE_ID: device_id,
62+
CONF_CODE: user_input[CONF_CODE],
63+
}
64+
65+
return self.async_create_entry(
66+
title=device_id,
67+
data=device_data,
68+
)
69+
errors["base"] = "cannot_connect"
70+
return self.async_show_form(
71+
step_id="confirm",
72+
data_schema=vol.Schema(
73+
{
74+
vol.Required(CONF_CODE): str,
75+
}
76+
),
77+
description_placeholders={
78+
"device_name": device_id,
79+
},
80+
errors=errors,
81+
)
82+
83+
async def async_step_user(
84+
self, user_input: dict[str, Any] | None = None
85+
) -> ConfigFlowResult:
86+
"""Handle a flow initialized by the user."""
87+
errors: dict[str, str] = {}
88+
if user_input is not None:
89+
self._droplet_discovery = DropletDiscovery(
90+
user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, ""
91+
)
92+
session = async_get_clientsession(self.hass)
93+
if await self._droplet_discovery.try_connect(
94+
session, user_input[CONF_CODE]
95+
) and (device_id := await self._droplet_discovery.get_device_id()):
96+
device_data = {
97+
CONF_IP_ADDRESS: self._droplet_discovery.host,
98+
CONF_PORT: self._droplet_discovery.port,
99+
CONF_DEVICE_ID: device_id,
100+
CONF_CODE: user_input[CONF_CODE],
101+
}
102+
await self.async_set_unique_id(device_id, raise_on_progress=False)
103+
self._abort_if_unique_id_configured(
104+
description_placeholders={CONF_DEVICE_ID: device_id},
105+
)
106+
107+
return self.async_create_entry(
108+
title=device_id,
109+
data=device_data,
110+
)
111+
errors["base"] = "cannot_connect"
112+
return self.async_show_form(
113+
step_id="user",
114+
data_schema=vol.Schema(
115+
{vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_CODE): str}
116+
),
117+
errors=errors,
118+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Constants for the droplet integration."""
2+
3+
CONNECT_DELAY = 5
4+
5+
DOMAIN = "droplet"
6+
DEVICE_NAME = "Droplet"
7+
8+
KEY_CURRENT_FLOW_RATE = "current_flow_rate"
9+
KEY_VOLUME = "volume"
10+
KEY_SIGNAL_QUALITY = "signal_quality"
11+
KEY_SERVER_CONNECTIVITY = "server_connectivity"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Droplet device data update coordinator object."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
import time
8+
9+
from pydroplet.droplet import Droplet
10+
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import ConfigEntryNotReady
15+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
16+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
17+
18+
from .const import CONNECT_DELAY, DOMAIN
19+
20+
VERSION_TIMEOUT = 5
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
TIMEOUT = 1
25+
26+
type DropletConfigEntry = ConfigEntry[DropletDataCoordinator]
27+
28+
29+
class DropletDataCoordinator(DataUpdateCoordinator[None]):
30+
"""Droplet device object."""
31+
32+
config_entry: DropletConfigEntry
33+
34+
def __init__(self, hass: HomeAssistant, entry: DropletConfigEntry) -> None:
35+
"""Initialize the device."""
36+
super().__init__(
37+
hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}"
38+
)
39+
self.droplet = Droplet(
40+
host=entry.data[CONF_IP_ADDRESS],
41+
port=entry.data[CONF_PORT],
42+
token=entry.data[CONF_CODE],
43+
session=async_get_clientsession(self.hass),
44+
logger=_LOGGER,
45+
)
46+
assert entry.unique_id is not None
47+
self.unique_id = entry.unique_id
48+
49+
async def _async_setup(self) -> None:
50+
if not await self.setup():
51+
raise ConfigEntryNotReady("Device is offline")
52+
53+
# Droplet should send its metadata within 5 seconds
54+
end = time.time() + VERSION_TIMEOUT
55+
while not self.droplet.version_info_available():
56+
await asyncio.sleep(TIMEOUT)
57+
if time.time() > end:
58+
_LOGGER.warning("Failed to get version info from Droplet")
59+
return
60+
61+
async def _async_update_data(self) -> None:
62+
if not self.droplet.connected:
63+
raise UpdateFailed(
64+
translation_domain=DOMAIN, translation_key="connection_error"
65+
)
66+
67+
async def setup(self) -> bool:
68+
"""Set up droplet client."""
69+
self.config_entry.async_on_unload(self.droplet.stop_listening)
70+
self.config_entry.async_create_background_task(
71+
self.hass,
72+
self.droplet.listen_forever(CONNECT_DELAY, self.async_set_updated_data),
73+
"droplet-listen",
74+
)
75+
end = time.time() + CONNECT_DELAY
76+
while time.time() < end:
77+
if self.droplet.connected:
78+
return True
79+
await asyncio.sleep(TIMEOUT)
80+
return False
81+
82+
def get_availability(self) -> bool:
83+
"""Retrieve Droplet's availability status."""
84+
return self.droplet.get_availability()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"entity": {
3+
"sensor": {
4+
"current_flow_rate": {
5+
"default": "mdi:chart-line"
6+
},
7+
"server_connectivity": {
8+
"default": "mdi:web"
9+
},
10+
"signal_quality": {
11+
"default": "mdi:waveform"
12+
}
13+
}
14+
}
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"domain": "droplet",
3+
"name": "Droplet",
4+
"codeowners": ["@sarahseidman"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/droplet",
7+
"iot_class": "local_push",
8+
"quality_scale": "bronze",
9+
"requirements": ["pydroplet==2.3.2"],
10+
"zeroconf": ["_droplet._tcp.local."]
11+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
rules:
2+
# Bronze
3+
action-setup:
4+
status: exempt
5+
comment: |
6+
No custom actions defined
7+
appropriate-polling:
8+
status: exempt
9+
comment: |
10+
No polling
11+
brands: done
12+
common-modules: done
13+
config-flow-test-coverage: done
14+
config-flow: done
15+
dependency-transparency: done
16+
docs-actions:
17+
status: exempt
18+
comment: |
19+
No custom actions are defined.
20+
docs-high-level-description: done
21+
docs-installation-instructions: done
22+
docs-removal-instructions: done
23+
entity-event-setup: done
24+
entity-unique-id: done
25+
has-entity-name: done
26+
runtime-data: done
27+
test-before-configure: done
28+
test-before-setup: done
29+
unique-config-entry: done
30+
31+
# Silver
32+
action-exceptions:
33+
status: exempt
34+
comment: |
35+
No custom actions are defined.
36+
config-entry-unloading: done
37+
docs-configuration-parameters: todo
38+
docs-installation-parameters: done
39+
entity-unavailable: done
40+
integration-owner: done
41+
log-when-unavailable: todo
42+
parallel-updates: todo
43+
reauthentication-flow: todo
44+
test-coverage: todo
45+
46+
# Gold
47+
devices: done
48+
diagnostics: todo
49+
discovery-update-info: done
50+
discovery: done
51+
docs-data-update: done
52+
docs-examples: todo
53+
docs-known-limitations: todo
54+
docs-supported-devices: todo
55+
docs-supported-functions: done
56+
docs-troubleshooting: todo
57+
docs-use-cases: done
58+
dynamic-devices: todo
59+
entity-category: done
60+
entity-device-class: done
61+
entity-disabled-by-default: todo
62+
entity-translations: done
63+
exception-translations: todo
64+
icon-translations: done
65+
reconfiguration-flow: todo
66+
repair-issues: todo
67+
stale-devices: todo
68+
69+
# Platinum
70+
async-dependency: todo
71+
inject-websession: done
72+
strict-typing: done

0 commit comments

Comments
 (0)