From fce1b6d24840f545474b17b2ac1391fc92f44a73 Mon Sep 17 00:00:00 2001 From: Patrick Frazer Date: Fri, 22 Dec 2023 08:24:08 -0500 Subject: [PATCH] Add DROP integration (#104319) * 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 --- CODEOWNERS | 2 + .../components/drop_connect/__init__.py | 66 ++++ .../components/drop_connect/config_flow.py | 98 ++++++ .../components/drop_connect/const.py | 25 ++ .../components/drop_connect/coordinator.py | 25 ++ .../components/drop_connect/entity.py | 53 +++ .../components/drop_connect/manifest.json | 11 + .../components/drop_connect/sensor.py | 285 ++++++++++++++++ .../components/drop_connect/strings.json | 30 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/mqtt.py | 3 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/drop_connect/__init__.py | 1 + tests/components/drop_connect/common.py | 51 +++ tests/components/drop_connect/conftest.py | 177 ++++++++++ .../drop_connect/test_config_flow.py | 178 ++++++++++ .../drop_connect/test_coordinator.py | 74 ++++ tests/components/drop_connect/test_sensor.py | 319 ++++++++++++++++++ 20 files changed, 1411 insertions(+) create mode 100644 homeassistant/components/drop_connect/__init__.py create mode 100644 homeassistant/components/drop_connect/config_flow.py create mode 100644 homeassistant/components/drop_connect/const.py create mode 100644 homeassistant/components/drop_connect/coordinator.py create mode 100644 homeassistant/components/drop_connect/entity.py create mode 100644 homeassistant/components/drop_connect/manifest.json create mode 100644 homeassistant/components/drop_connect/sensor.py create mode 100644 homeassistant/components/drop_connect/strings.json create mode 100644 tests/components/drop_connect/__init__.py create mode 100644 tests/components/drop_connect/common.py create mode 100644 tests/components/drop_connect/conftest.py create mode 100644 tests/components/drop_connect/test_config_flow.py create mode 100644 tests/components/drop_connect/test_coordinator.py create mode 100644 tests/components/drop_connect/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 7a8b3ea1885..d5ae7848b15 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py new file mode 100644 index 00000000000..45978a48d9a --- /dev/null +++ b/homeassistant/components/drop_connect/__init__.py @@ -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 diff --git a/homeassistant/components/drop_connect/config_flow.py b/homeassistant/components/drop_connect/config_flow.py new file mode 100644 index 00000000000..a2b93ad1da1 --- /dev/null +++ b/homeassistant/components/drop_connect/config_flow.py @@ -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") diff --git a/homeassistant/components/drop_connect/const.py b/homeassistant/components/drop_connect/const.py new file mode 100644 index 00000000000..38a8a57ea72 --- /dev/null +++ b/homeassistant/components/drop_connect/const.py @@ -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" diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py new file mode 100644 index 00000000000..eb440d224d7 --- /dev/null +++ b/homeassistant/components/drop_connect/coordinator.py @@ -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() diff --git a/homeassistant/components/drop_connect/entity.py b/homeassistant/components/drop_connect/entity.py new file mode 100644 index 00000000000..85c506b19a3 --- /dev/null +++ b/homeassistant/components/drop_connect/entity.py @@ -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], + ) + } + ) diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json new file mode 100644 index 00000000000..f65c1848aff --- /dev/null +++ b/homeassistant/components/drop_connect/manifest.json @@ -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"] +} diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py new file mode 100644 index 00000000000..c5215df8395 --- /dev/null +++ b/homeassistant/components/drop_connect/sensor.py @@ -0,0 +1,285 @@ +"""Support for DROP sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + EntityCategory, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +FLOW_ICON = "mdi:shower-head" +GAUGE_ICON = "mdi:gauge" +TDS_ICON = "mdi:water-opacity" + +# Sensor type constants +CURRENT_FLOW_RATE = "current_flow_rate" +PEAK_FLOW_RATE = "peak_flow_rate" +WATER_USED_TODAY = "water_used_today" +AVERAGE_WATER_USED = "average_water_used" +CAPACITY_REMAINING = "capacity_remaining" +CURRENT_SYSTEM_PRESSURE = "current_system_pressure" +HIGH_SYSTEM_PRESSURE = "high_system_pressure" +LOW_SYSTEM_PRESSURE = "low_system_pressure" +BATTERY = "battery" +TEMPERATURE = "temperature" +INLET_TDS = "inlet_tds" +OUTLET_TDS = "outlet_tds" +CARTRIDGE_1_LIFE = "cart1" +CARTRIDGE_2_LIFE = "cart2" +CARTRIDGE_3_LIFE = "cart3" + + +@dataclass(kw_only=True, frozen=True) +class DROPSensorEntityDescription(SensorEntityDescription): + """Describes DROP sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], float | int | None] + + +SENSORS: list[DROPSensorEntityDescription] = [ + DROPSensorEntityDescription( + key=CURRENT_FLOW_RATE, + translation_key=CURRENT_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=PEAK_FLOW_RATE, + translation_key=PEAK_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.peak_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=WATER_USED_TODAY, + translation_key=WATER_USED_TODAY, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.water_used_today(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=AVERAGE_WATER_USED, + translation_key=AVERAGE_WATER_USED, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.average_water_used(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CAPACITY_REMAINING, + translation_key=CAPACITY_REMAINING, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.capacity_remaining(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CURRENT_SYSTEM_PRESSURE, + translation_key=CURRENT_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=HIGH_SYSTEM_PRESSURE, + translation_key=HIGH_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.high_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=LOW_SYSTEM_PRESSURE, + translation_key=LOW_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.low_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=BATTERY, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.battery(), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DROPSensorEntityDescription( + key=TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.temperature(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=INLET_TDS, + translation_key=INLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.inlet_tds(), + ), + DROPSensorEntityDescription( + key=OUTLET_TDS, + translation_key=OUTLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.outlet_tds(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_1_LIFE, + translation_key=CARTRIDGE_1_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart1(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_2_LIFE, + translation_key=CARTRIDGE_2_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart2(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_3_LIFE, + translation_key=CARTRIDGE_3_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart3(), + ), +] + +# Defines which sensors are used by each device type +DEVICE_SENSORS: dict[str, list[str]] = { + DEV_HUB: [ + AVERAGE_WATER_USED, + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + HIGH_SYSTEM_PRESSURE, + LOW_SYSTEM_PRESSURE, + PEAK_FLOW_RATE, + WATER_USED_TODAY, + ], + DEV_SOFTENER: [ + BATTERY, + CAPACITY_REMAINING, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + ], + DEV_FILTER: [BATTERY, CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE], + DEV_LEAK_DETECTOR: [BATTERY, TEMPERATURE], + DEV_PROTECTION_VALVE: [ + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + TEMPERATURE, + ], + DEV_PUMP_CONTROLLER: [CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE, TEMPERATURE], + DEV_RO_FILTER: [ + CARTRIDGE_1_LIFE, + CARTRIDGE_2_LIFE, + CARTRIDGE_3_LIFE, + INLET_TDS, + OUTLET_TDS, + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP sensors from config entry.""" + _LOGGER.debug( + "Set up sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: + async_add_entities( + DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in SENSORS + if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSensor(DROPEntity, SensorEntity): + """Representation of a DROP sensor.""" + + entity_description: DROPSensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def native_value(self) -> float | int | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json new file mode 100644 index 00000000000..0674515412f --- /dev/null +++ b/homeassistant/components/drop_connect/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "not_supported": "Configuration for DROP is through MQTT discovery. Use the DROP Connect app to connect your DROP Hub to your MQTT broker." + }, + "step": { + "confirm": { + "title": "Confirm association", + "description": "Do you want to configure the DROP {device_type} named {device_name}?'" + } + } + }, + "entity": { + "sensor": { + "current_flow_rate": { "name": "Water flow rate" }, + "peak_flow_rate": { "name": "Peak water flow rate today" }, + "water_used_today": { "name": "Total water used today" }, + "average_water_used": { "name": "Average daily water usage" }, + "capacity_remaining": { "name": "Capacity remaining" }, + "current_system_pressure": { "name": "Current water pressure" }, + "high_system_pressure": { "name": "High water pressure today" }, + "low_system_pressure": { "name": "Low water pressure today" }, + "inlet_tds": { "name": "Inlet TDS" }, + "outlet_tds": { "name": "Outlet TDS" }, + "cart1": { "name": "Cartridge 1 life remaining" }, + "cart2": { "name": "Cartridge 2 life remaining" }, + "cart3": { "name": "Cartridge 3 life remaining" } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1deeae819a0..9fcc3ea93b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -111,6 +111,7 @@ FLOWS = { "doorbird", "dormakaba_dkey", "dremel_3d_printer", + "drop_connect", "dsmr", "dsmr_reader", "dunehd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9479153dd0d..61cb665af2f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1253,6 +1253,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "drop_connect": { + "name": "DROP", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dsmr": { "name": "DSMR Slimme Meter", "integration_type": "hub", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 69abf7c64fe..0c456774e4d 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -4,6 +4,9 @@ To update, run python3 -m script.hassfest """ MQTT = { + "drop_connect": [ + "drop_connect/discovery/#", + ], "dsmr_reader": [ "dsmr/#", ], diff --git a/requirements_all.txt b/requirements_all.txt index b903bc7daca..ce2d1672483 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -712,6 +712,9 @@ dovado==0.4.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.1 + # homeassistant.components.dsmr dsmr-parser==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88b9fa3e8d0..274a928e8b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -581,6 +581,9 @@ discovery30303==0.2.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.1 + # homeassistant.components.dsmr dsmr-parser==1.3.1 diff --git a/tests/components/drop_connect/__init__.py b/tests/components/drop_connect/__init__.py new file mode 100644 index 00000000000..f67b77b906e --- /dev/null +++ b/tests/components/drop_connect/__init__.py @@ -0,0 +1 @@ +"""Tests for the DROP integration.""" diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py new file mode 100644 index 00000000000..9a07c71cb71 --- /dev/null +++ b/tests/components/drop_connect/common.py @@ -0,0 +1,51 @@ +"""Define common test values.""" + +TEST_DATA_HUB_TOPIC = "drop_connect/DROP-1_C0FFEE/255" +TEST_DATA_HUB = ( + '{"curFlow":5.77,"peakFlow":13.8,"usedToday":232.77,"avgUsed":76,"psi":62.2,"psiLow":61,"psiHigh":62,' + '"water":1,"bypass":0,"pMode":"HOME","battery":50,"notif":1,"leak":0}' +) +TEST_DATA_HUB_RESET = ( + '{"curFlow":0,"peakFlow":0,"usedToday":0,"avgUsed":0,"psi":0,"psiLow":0,"psiHigh":0,' + '"water":0,"bypass":0,"pMode":"AWAY","battery":0,"notif":0,"leak":0}' +) + +TEST_DATA_SALT_TOPIC = "drop_connect/DROP-1_C0FFEE/8" +TEST_DATA_SALT = '{"salt":1}' +TEST_DATA_SALT_RESET = '{"salt":0}' + +TEST_DATA_LEAK_TOPIC = "drop_connect/DROP-1_C0FFEE/20" +TEST_DATA_LEAK = '{"battery":100,"leak":1,"temp":68.2}' +TEST_DATA_LEAK_RESET = '{"battery":0,"leak":0,"temp":0}' + +TEST_DATA_SOFTENER_TOPIC = "drop_connect/DROP-1_C0FFEE/0" +TEST_DATA_SOFTENER = ( + '{"curFlow":5.0,"bypass":0,"battery":20,"capacity":1000,"resInUse":1,"psi":50.5}' +) +TEST_DATA_SOFTENER_RESET = ( + '{"curFlow":0,"bypass":0,"battery":0,"capacity":0,"resInUse":0,"psi":null}' +) + +TEST_DATA_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/4" +TEST_DATA_FILTER = '{"curFlow":19.84,"bypass":0,"battery":12,"psi":38.2}' +TEST_DATA_FILTER_RESET = '{"curFlow":0,"bypass":0,"battery":0,"psi":null}' + +TEST_DATA_PROTECTION_VALVE_TOPIC = "drop_connect/DROP-1_C0FFEE/78" +TEST_DATA_PROTECTION_VALVE = ( + '{"curFlow":7.1,"psi":61.3,"water":1,"battery":0,"leak":1,"temp":70.5}' +) +TEST_DATA_PROTECTION_VALVE_RESET = ( + '{"curFlow":0,"psi":0,"water":0,"battery":0,"leak":0,"temp":0}' +) + +TEST_DATA_PUMP_CONTROLLER_TOPIC = "drop_connect/DROP-1_C0FFEE/83" +TEST_DATA_PUMP_CONTROLLER = '{"curFlow":2.2,"psi":62.2,"pump":1,"leak":1,"temp":68.8}' +TEST_DATA_PUMP_CONTROLLER_RESET = '{"curFlow":0,"psi":0,"pump":0,"leak":0,"temp":0}' + +TEST_DATA_RO_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/95" +TEST_DATA_RO_FILTER = ( + '{"leak":1,"tdsIn":164,"tdsOut":9,"cart1":59,"cart2":80,"cart3":59}' +) +TEST_DATA_RO_FILTER_RESET = ( + '{"leak":0,"tdsIn":0,"tdsOut":0,"cart1":0,"cart2":0,"cart3":0}' +) diff --git a/tests/components/drop_connect/conftest.py b/tests/components/drop_connect/conftest.py new file mode 100644 index 00000000000..ce68a6f0c13 --- /dev/null +++ b/tests/components/drop_connect/conftest.py @@ -0,0 +1,177 @@ +"""Define fixtures available for all tests.""" +import pytest + +from homeassistant.components.drop_connect.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, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry_hub(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/255/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/255/#", + CONF_DEVICE_DESC: "Hub", + CONF_DEVICE_ID: 255, + CONF_DEVICE_NAME: "Hub DROP-1_C0FFEE", + CONF_DEVICE_TYPE: "hub", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_salt(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_8", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/8/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/8/#", + CONF_DEVICE_DESC: "Salt Sensor", + CONF_DEVICE_ID: 8, + CONF_DEVICE_NAME: "Salt Sensor", + CONF_DEVICE_TYPE: "salt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_leak(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_20", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/20/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/20/#", + CONF_DEVICE_DESC: "Leak Detector", + CONF_DEVICE_ID: 20, + CONF_DEVICE_NAME: "Leak Detector", + CONF_DEVICE_TYPE: "leak", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_softener(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_0", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/0/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/0/#", + CONF_DEVICE_DESC: "Softener", + CONF_DEVICE_ID: 0, + CONF_DEVICE_NAME: "Softener", + CONF_DEVICE_TYPE: "soft", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_filter(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_4", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/4/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/4/#", + CONF_DEVICE_DESC: "Filter", + CONF_DEVICE_ID: 4, + CONF_DEVICE_NAME: "Filter", + CONF_DEVICE_TYPE: "filt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_protection_valve(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_78", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/78/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/78/#", + CONF_DEVICE_DESC: "Protection Valve", + CONF_DEVICE_ID: 78, + CONF_DEVICE_NAME: "Protection Valve", + CONF_DEVICE_TYPE: "pv", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_pump_controller(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_83", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/83/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/83/#", + CONF_DEVICE_DESC: "Pump Controller", + CONF_DEVICE_ID: 83, + CONF_DEVICE_NAME: "Pump Controller", + CONF_DEVICE_TYPE: "pc", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_ro_filter(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/95/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/95/#", + CONF_DEVICE_DESC: "RO Filter", + CONF_DEVICE_ID: 95, + CONF_DEVICE_NAME: "RO Filter", + CONF_DEVICE_TYPE: "ro", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) diff --git a/tests/components/drop_connect/test_config_flow.py b/tests/components/drop_connect/test_config_flow.py new file mode 100644 index 00000000000..fb727d2c7fd --- /dev/null +++ b/tests/components/drop_connect/test_config_flow.py @@ -0,0 +1,178 @@ +"""Test config flow.""" +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.typing import MqttMockHAClient + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "drop_command_topic": "drop_connect/DROP-1_C0FFEE/cmd/255", + "drop_data_topic": "drop_connect/DROP-1_C0FFEE/data/255/#", + "device_desc": "Hub", + "device_id": "255", + "name": "Hub DROP-1_C0FFEE", + "device_type": "hub", + "drop_hub_id": "DROP-1_C0FFEE", + "drop_device_owner_id": "DROP-1_C0FFEE_255", + } + + +async def test_duplicate(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + + # Attempting configuration of the same object should abort + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_incomplete_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_json( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="{BAD JSON}", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/FOO", + payload=('{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}'), + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_no_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test user setup.""" + result = await hass.config_entries.flow.async_init( + "drop_connect", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "not_supported" diff --git a/tests/components/drop_connect/test_coordinator.py b/tests/components/drop_connect/test_coordinator.py new file mode 100644 index 00000000000..50f2633e241 --- /dev/null +++ b/tests/components/drop_connect/test_coordinator.py @@ -0,0 +1,74 @@ +"""Test DROP coordinator.""" +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_bad_json( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test bad JSON.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, "{BAD JSON}") + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == STATE_UNKNOWN + + +async def test_unload( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test entity unload.""" + # Load the hub device + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.8 + + # Unload the device + await hass.config_entries.async_unload(config_entry_hub.entry_id) + await hass.async_block_till_done() + + assert config_entry_hub.state is ConfigEntryState.NOT_LOADED + + # Verify sensor is unavailable + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == STATE_UNAVAILABLE + + +async def test_no_mqtt(hass: HomeAssistant, config_entry_hub) -> None: + """Test no MQTT.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select is None diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py new file mode 100644 index 00000000000..589fd08488c --- /dev/null +++ b/tests/components/drop_connect/test_sensor.py @@ -0,0 +1,319 @@ +"""Test DROP sensor entities.""" +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_sensors_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + peak_flow_sensor_name = "sensor.hub_drop_1_c0ffee_peak_water_flow_rate_today" + hass.states.async_set(peak_flow_sensor_name, STATE_UNKNOWN) + used_today_sensor_name = "sensor.hub_drop_1_c0ffee_total_water_used_today" + hass.states.async_set(used_today_sensor_name, STATE_UNKNOWN) + average_usage_sensor_name = "sensor.hub_drop_1_c0ffee_average_daily_water_usage" + hass.states.async_set(average_usage_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.hub_drop_1_c0ffee_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + psi_high_sensor_name = "sensor.hub_drop_1_c0ffee_high_water_pressure_today" + hass.states.async_set(psi_high_sensor_name, STATE_UNKNOWN) + psi_low_sensor_name = "sensor.hub_drop_1_c0ffee_low_water_pressure_today" + hass.states.async_set(psi_low_sensor_name, STATE_UNKNOWN) + battery_sensor_name = "sensor.hub_drop_1_c0ffee_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.8 + + peak_flow_sensor = hass.states.get(peak_flow_sensor_name) + assert peak_flow_sensor + assert round(float(peak_flow_sensor.state), 1) == 13.8 + + used_today_sensor = hass.states.get(used_today_sensor_name) + assert used_today_sensor + assert round(float(used_today_sensor.state), 1) == 881.1 # liters + + average_usage_sensor = hass.states.get(average_usage_sensor_name) + assert average_usage_sensor + assert round(float(average_usage_sensor.state), 1) == 287.7 # liters + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 428.9 # centibars + + psi_high_sensor = hass.states.get(psi_high_sensor_name) + assert psi_high_sensor + assert round(float(psi_high_sensor.state), 1) == 427.5 # centibars + + psi_low_sensor = hass.states.get(psi_low_sensor_name) + assert psi_low_sensor + assert round(float(psi_low_sensor.state), 1) == 420.6 # centibars + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 50 + + +async def test_sensors_leak( + hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for leak detectors.""" + config_entry_leak.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.leak_detector_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.leak_detector_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 100 + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 20.1 # C + + +async def test_sensors_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.softener_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.softener_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.softener_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + capacity_sensor_name = "sensor.softener_capacity_remaining" + hass.states.async_set(capacity_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 20 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.0 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 348.2 # centibars + + capacity_sensor = hass.states.get(capacity_sensor_name) + assert capacity_sensor + assert round(float(capacity_sensor.state), 1) == 3785.4 # liters + + +async def test_sensors_filter( + hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for filters.""" + config_entry_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.filter_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.filter_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.filter_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert round(float(battery_sensor.state), 1) == 12.0 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 19.8 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 263.4 # centibars + + +async def test_sensors_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.protection_valve_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.protection_valve_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.protection_valve_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.protection_valve_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 0 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 7.1 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 422.6 # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 21.4 # C + + +async def test_sensors_pump_controller( + hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for pump controllers.""" + config_entry_pump_controller.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.pump_controller_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.pump_controller_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.pump_controller_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 2.2 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 428.9 # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 20.4 # C + + +async def test_sensors_ro_filter( + hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for RO filters.""" + config_entry_ro_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + tds_in_sensor_name = "sensor.ro_filter_inlet_tds" + hass.states.async_set(tds_in_sensor_name, STATE_UNKNOWN) + tds_out_sensor_name = "sensor.ro_filter_outlet_tds" + hass.states.async_set(tds_out_sensor_name, STATE_UNKNOWN) + cart1_sensor_name = "sensor.ro_filter_cartridge_1_life_remaining" + hass.states.async_set(cart1_sensor_name, STATE_UNKNOWN) + cart2_sensor_name = "sensor.ro_filter_cartridge_2_life_remaining" + hass.states.async_set(cart2_sensor_name, STATE_UNKNOWN) + cart3_sensor_name = "sensor.ro_filter_cartridge_3_life_remaining" + hass.states.async_set(cart3_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + + tds_in_sensor = hass.states.get(tds_in_sensor_name) + assert tds_in_sensor + assert int(tds_in_sensor.state) == 164 + + tds_out_sensor = hass.states.get(tds_out_sensor_name) + assert tds_out_sensor + assert int(tds_out_sensor.state) == 9 + + cart1_sensor = hass.states.get(cart1_sensor_name) + assert cart1_sensor + assert int(cart1_sensor.state) == 59 + + cart2_sensor = hass.states.get(cart2_sensor_name) + assert cart2_sensor + assert int(cart2_sensor.state) == 80 + + cart3_sensor = hass.states.get(cart3_sensor_name) + assert cart3_sensor + assert int(cart3_sensor.state) == 59