Skip to content

Commit

Permalink
Add DROP integration (#104319)
Browse files Browse the repository at this point in the history
* Add DROP integration

* Remove all but one platform for first PR

* Simplify initialization of hass.data[] structure

* Remove unnecessary mnemonic 'DROP_' prefix from DOMAIN constants

* Remove unnecessary whitespace

* Clarify configuration 'confirm' step description

* Remove unnecessary whitespace

* Use device class where applicable

* Remove unnecessary constructor and change its elements to class variables

* Change base entity inheritance to CoordinatorEntity

* Make sensor definitions more concise

* Rename HA domain from drop to drop_connect

* Remove underscores from class and function names

* Remove duplicate temperature sensor

* Change title capitalization

* Refactor using SensorEntityDescription

* Remove unnecessary intermediate dict layer

* Remove generated translations file

* Remove currently unused string values

* Use constants in sensor definitions

* Replace values with constants

* Move translation keys

* Remove unnecessary unique ID and config entry references

* Clean up DROPEntity initialization

* Clean up sensors

* Rename vars and functions according to style

* Remove redundant self references

* Clean up DROPSensor initializer

* Add missing state classes

* Simplify detection of configured devices

* Change entity identifiers to create device linkage

* Move device_info to coordinator

* Remove unnecessary properties

* Correct hub device IDs

* Remove redundant attribute

* Replace optional UID with assert

* Remove redundant attribute

* Correct coordinator initialization

* Fix mypy error

* Move API functionality to 3rd party library

* Abstract device to sensor map into a dict

* Unsubscribe MQTT on unload

* Move entity device information

* Make type checking for mypy conditional

* Bump dropmqttapi to 1.0.1

* Freeze dataclass to match parent class

* Fix race condition in MQTT unsubscribe setup

* Ensure unit tests begin with invalid MQTT state

* Change unit tests to reflect device firmware

* Move MQTT subscription out of the coordinator

* Tidy up initializer

* Move entirety of MQTT subscription out of the coordinator

* Make drop_api a class property

* Remove unnecessary type checks

* Simplify some unit test asserts

* Remove argument matching default

* Add entity category to battery and cartridge life sensors
  • Loading branch information
pfrazer authored Dec 22, 2023
1 parent 243ee22 commit fce1b6d
Show file tree
Hide file tree
Showing 20 changed files with 1,411 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ build.json @home-assistant/supervisor
/tests/components/dormakaba_dkey/ @emontnemery
/homeassistant/components/dremel_3d_printer/ @tkdrob
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dsmr/ @Robbie1221 @frenck
/tests/components/dsmr/ @Robbie1221 @frenck
/homeassistant/components/dsmr_reader/ @depl0y @glodenox
Expand Down
66 changes: 66 additions & 0 deletions homeassistant/components/drop_connect/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""The drop_connect integration."""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback

from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN
from .coordinator import DROPDeviceDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up DROP from a config entry."""

# Make sure MQTT integration is enabled and the client is available.
if not await mqtt.async_wait_for_mqtt_client(hass):
_LOGGER.error("MQTT integration is not available")
return False

if TYPE_CHECKING:
assert config_entry.unique_id is not None
drop_data_coordinator = DROPDeviceDataUpdateCoordinator(
hass, config_entry.unique_id
)

@callback
def mqtt_callback(msg: ReceiveMessage) -> None:
"""Pass MQTT payload to DROP API parser."""
if drop_data_coordinator.drop_api.parse_drop_message(
msg.topic, msg.payload, msg.qos, msg.retain
):
drop_data_coordinator.async_set_updated_data(None)

config_entry.async_on_unload(
await mqtt.async_subscribe(
hass, config_entry.data[CONF_DATA_TOPIC], mqtt_callback
)
)
_LOGGER.debug(
"Entry %s (%s) subscribed to %s",
config_entry.unique_id,
config_entry.data[CONF_DEVICE_TYPE],
config_entry.data[CONF_DATA_TOPIC],
)

hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
):
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
98 changes: 98 additions & 0 deletions homeassistant/components/drop_connect/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Config flow for drop_connect integration."""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

from dropmqttapi.discovery import DropDiscovery

from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo

from .const import (
CONF_COMMAND_TOPIC,
CONF_DATA_TOPIC,
CONF_DEVICE_DESC,
CONF_DEVICE_ID,
CONF_DEVICE_NAME,
CONF_DEVICE_OWNER_ID,
CONF_DEVICE_TYPE,
CONF_HUB_ID,
DISCOVERY_TOPIC,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)


class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle DROP config flow."""

VERSION = 1

_drop_discovery: DropDiscovery | None = None

async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult:
"""Handle a flow initialized by MQTT discovery."""

# Abort if the topic does not match our discovery topic or the payload is empty.
if (
discovery_info.subscribed_topic != DISCOVERY_TOPIC
or not discovery_info.payload
):
return self.async_abort(reason="invalid_discovery_info")

self._drop_discovery = DropDiscovery(DOMAIN)
if not (
await self._drop_discovery.parse_discovery(
discovery_info.topic, discovery_info.payload
)
):
return self.async_abort(reason="invalid_discovery_info")
existing_entry = await self.async_set_unique_id(
f"{self._drop_discovery.hub_id}_{self._drop_discovery.device_id}"
)
if existing_entry is not None:
# Note: returning "invalid_discovery_info" here instead of "already_configured"
# allows discovery of additional device types.
return self.async_abort(reason="invalid_discovery_info")

self.context.update({"title_placeholders": {"name": self._drop_discovery.name}})

return await self.async_step_confirm()

async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm the setup."""
if TYPE_CHECKING:
assert self._drop_discovery is not None
if user_input is not None:
device_data = {
CONF_COMMAND_TOPIC: self._drop_discovery.command_topic,
CONF_DATA_TOPIC: self._drop_discovery.data_topic,
CONF_DEVICE_DESC: self._drop_discovery.device_desc,
CONF_DEVICE_ID: self._drop_discovery.device_id,
CONF_DEVICE_NAME: self._drop_discovery.name,
CONF_DEVICE_TYPE: self._drop_discovery.device_type,
CONF_HUB_ID: self._drop_discovery.hub_id,
CONF_DEVICE_OWNER_ID: self._drop_discovery.owner_id,
}
return self.async_create_entry(
title=self._drop_discovery.name, data=device_data
)

return self.async_show_form(
step_id="confirm",
description_placeholders={
"device_name": self._drop_discovery.name,
"device_type": self._drop_discovery.device_desc,
},
)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
return self.async_abort(reason="not_supported")
25 changes: 25 additions & 0 deletions homeassistant/components/drop_connect/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Constants for the drop_connect integration."""

# Keys for values used in the config_entry data dictionary
CONF_COMMAND_TOPIC = "drop_command_topic"
CONF_DATA_TOPIC = "drop_data_topic"
CONF_DEVICE_DESC = "device_desc"
CONF_DEVICE_ID = "device_id"
CONF_DEVICE_TYPE = "device_type"
CONF_HUB_ID = "drop_hub_id"
CONF_DEVICE_NAME = "name"
CONF_DEVICE_OWNER_ID = "drop_device_owner_id"

# Values for DROP device types
DEV_FILTER = "filt"
DEV_HUB = "hub"
DEV_LEAK_DETECTOR = "leak"
DEV_PROTECTION_VALVE = "pv"
DEV_PUMP_CONTROLLER = "pc"
DEV_RO_FILTER = "ro"
DEV_SALT_SENSOR = "salt"
DEV_SOFTENER = "soft"

DISCOVERY_TOPIC = "drop_connect/discovery/#"

DOMAIN = "drop_connect"
25 changes: 25 additions & 0 deletions homeassistant/components/drop_connect/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""DROP device data update coordinator object."""
from __future__ import annotations

import logging

from dropmqttapi.mqttapi import DropAPI

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator):
"""DROP device object."""

config_entry: ConfigEntry

def __init__(self, hass: HomeAssistant, unique_id: str) -> None:
"""Initialize the device."""
super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}")
self.drop_api = DropAPI()
53 changes: 53 additions & 0 deletions homeassistant/components/drop_connect/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Base entity class for DROP entities."""
from __future__ import annotations

from typing import TYPE_CHECKING

from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import (
CONF_DEVICE_DESC,
CONF_DEVICE_NAME,
CONF_DEVICE_OWNER_ID,
CONF_DEVICE_TYPE,
CONF_HUB_ID,
DEV_HUB,
DOMAIN,
)
from .coordinator import DROPDeviceDataUpdateCoordinator


class DROPEntity(CoordinatorEntity[DROPDeviceDataUpdateCoordinator]):
"""Representation of a DROP device entity."""

_attr_has_entity_name = True

def __init__(
self, entity_type: str, coordinator: DROPDeviceDataUpdateCoordinator
) -> None:
"""Init DROP entity."""
super().__init__(coordinator)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id is not None
unique_id = coordinator.config_entry.unique_id
self._attr_unique_id = f"{unique_id}_{entity_type}"
entry_data = coordinator.config_entry.data
model: str = entry_data[CONF_DEVICE_DESC]
if entry_data[CONF_DEVICE_TYPE] == DEV_HUB:
model = f"Hub {entry_data[CONF_HUB_ID]}"
self._attr_device_info = DeviceInfo(
manufacturer="Chandler Systems, Inc.",
model=model,
name=entry_data[CONF_DEVICE_NAME],
identifiers={(DOMAIN, unique_id)},
)
if entry_data[CONF_DEVICE_TYPE] != DEV_HUB:
self._attr_device_info.update(
{
"via_device": (
DOMAIN,
entry_data[CONF_DEVICE_OWNER_ID],
)
}
)
11 changes: 11 additions & 0 deletions homeassistant/components/drop_connect/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "drop_connect",
"name": "DROP",
"codeowners": ["@ChandlerSystems", "@pfrazer"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/drop_connect",
"iot_class": "local_push",
"mqtt": ["drop_connect/discovery/#"],
"requirements": ["dropmqttapi==1.0.1"]
}
Loading

0 comments on commit fce1b6d

Please sign in to comment.