From 1ae1f060a20767956531b9e794863e998dd6a2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Mrozek?= Date: Sat, 16 Dec 2023 19:14:31 +0100 Subject: [PATCH] feat: support 500 device type (#3) Resolves #1 --- README.md | 18 ++- custom_components/venta/__init__.py | 25 +++- custom_components/venta/binary_sensor.py | 65 +++++++--- custom_components/venta/config_flow.py | 25 ++-- custom_components/venta/const.py | 2 - custom_components/venta/humidifier.py | 128 +++++++++++++++---- custom_components/venta/light.py | 2 + custom_components/venta/manifest.json | 2 +- custom_components/venta/select.py | 11 +- custom_components/venta/sensor.py | 31 ++++- custom_components/venta/strings.json | 6 +- custom_components/venta/switch.py | 121 ++++++++++++++++++ custom_components/venta/translations/en.json | 14 +- custom_components/venta/translations/pt.json | 14 +- custom_components/venta/venta.py | 49 +++++-- info.md | 18 ++- pylint.rc | 2 +- requirements_test.txt | 10 +- tox.ini | 2 +- 19 files changed, 446 insertions(+), 99 deletions(-) create mode 100644 custom_components/venta/switch.py diff --git a/README.md b/README.md index fe83eb8..c333351 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@ # ha-venta -A Home Assistant custom Integration for Venta devices with wifi module. +A Home Assistant custom Integration for Venta devices (protocol: v2, v3) with wifi module. The following Venta device are currently tested and supported: -* LW73/LW74 Humidifier +- **Protocol v2:** + - LW73/LW74 Humidifier + +- **Protocol v3:** + - AH550/AH555/AH510/AH515 Humidifier ## Features -* Humidifier control (fan speed, target humidity, and auto/sleep mode). -* LED strip control (on/off, color, mode). -* Diagnostic sensors (water level, temperature, cleaning time, etc.). +- Humidifier control (fan speed, target humidity, and auto/sleep mode). +- LED strip control (on/off, color, mode). +- Diagnostic sensors (water level, temperature, cleaning time, etc.). ## Installation @@ -20,12 +24,12 @@ For manual installation, copy the venta folder and all of its contents into your ## Usage -Before the next steps make sure the device is configured using the Venta Home app and connected to the network. +Before the next steps make sure the device is configured using the Venta Home app and connected to the network. Next note down it's IP address, then visit `http://` and find property `ProtocolV` - this will be yours API version to fill later on. ### Adding the Integration To start configuring the integration, just press the "+ADD INTEGRATION" button in the Settings - Integrations page, and select Venta from the drop-down menu. -The configuration page will appear, requesting to input ip of the device. +The configuration page will appear, requesting to input ip and API version of the device. ## Contributing diff --git a/custom_components/venta/__init__.py b/custom_components/venta/__init__.py index 93fbf2e..3dc5ae4 100644 --- a/custom_components/venta/__init__.py +++ b/custom_components/venta/__init__.py @@ -11,11 +11,11 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_API_VERSION from .const import DOMAIN, TIMEOUT -from .venta import VentaApi, VentaDevice, VentaDataUpdateCoordinator +from .venta import VentaApi, VentaDevice, VentaDataUpdateCoordinator, VentaApiVersion _LOGGER = logging.getLogger(__name__) @@ -27,6 +27,7 @@ Platform.LIGHT, Platform.SELECT, Platform.BINARY_SENSOR, + Platform.SWITCH, ] @@ -36,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=conf[CONF_MAC]) - api = await venta_api_setup(hass, conf[CONF_HOST]) + api = await venta_api_setup(hass, conf[CONF_HOST], conf[CONF_API_VERSION]) if not api: return False @@ -60,12 +61,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def venta_api_setup(hass: HomeAssistant, host): +async def venta_api_setup(hass: HomeAssistant, host, api_version): """Create a Venta instance only once.""" session = async_get_clientsession(hass) try: async with asyncio.timeout(TIMEOUT): - device = VentaDevice(host, session) + device = VentaDevice(host, api_version, session) await device.init() except asyncio.TimeoutError as err: _LOGGER.debug("Connection to %s timed out", host) @@ -80,3 +81,17 @@ async def venta_api_setup(hass: HomeAssistant, host): api = VentaApi(device) return api + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + new = {**entry.data, CONF_API_VERSION: VentaApiVersion.V2.value} + entry.version = 2 + hass.config_entries.async_update_entry(entry, data=new) + + _LOGGER.debug("Migration to version %s successful", entry.version) + + return True diff --git a/custom_components/venta/binary_sensor.py b/custom_components/venta/binary_sensor.py index ab0845f..f702096 100644 --- a/custom_components/venta/binary_sensor.py +++ b/custom_components/venta/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, NO_WATER_THRESHOLD -from .venta import VentaData, VentaDataUpdateCoordinator +from .venta import VentaData, VentaDataUpdateCoordinator, VentaDeviceType ATTR_NEEDS_REFILL = "needs_refill" @@ -35,23 +35,50 @@ class VentaBinarySensorEntityDescription( """Describes Venta binary sensor entity.""" -BINARY_SENSOR_TYPES: tuple[VentaBinarySensorEntityDescription, ...] = ( - VentaBinarySensorEntityDescription( - key=ATTR_NEEDS_REFILL, - translation_key="needs_refill", - icon="mdi:water-alert", - value_func=( - lambda data: data.info.get("Warnings") != 0 - and data.measure.get("WaterLevel") < NO_WATER_THRESHOLD - ), - ), - VentaBinarySensorEntityDescription( - key=ATTR_NEEDS_SERVICE, - translation_key="needs_service", - icon="mdi:account-wrench", - value_func=(lambda data: data.info.get("Warnings") == 16), - ), -) +def _supported_sensors( + device_type: VentaDeviceType, +) -> tuple[VentaBinarySensorEntityDescription, ...]: + """Return supported sensors for given device type.""" + match device_type: + case VentaDeviceType.LW73_LW74 | VentaDeviceType.UNKNOWN: + return ( + VentaBinarySensorEntityDescription( + key=ATTR_NEEDS_REFILL, + translation_key="needs_refill", + icon="mdi:water-alert", + value_func=( + lambda data: data.info.get("Warnings") != 0 + and data.measure.get("WaterLevel") < NO_WATER_THRESHOLD + ), + ), + VentaBinarySensorEntityDescription( + key=ATTR_NEEDS_SERVICE, + translation_key="needs_service", + icon="mdi:account-wrench", + value_func=( + lambda data: 16 <= data.info.get("Warnings") + and data.info.get("Warnings") <= 17 + ), + ), + ) + case VentaDeviceType.AH550_AH555: + return ( + VentaBinarySensorEntityDescription( + key=ATTR_NEEDS_REFILL, + translation_key="needs_refill", + icon="mdi:water-alert", + value_func=(lambda data: data.info.get("Warnings") == 1), + ), + VentaBinarySensorEntityDescription( + key=ATTR_NEEDS_SERVICE, + translation_key="needs_service", + icon="mdi:account-wrench", + value_func=( + lambda data: data.info.get("ServiceT") is not None + and data.info.get("ServiceT") >= data.info.get("ServiceMax") + ), + ), + ) async def async_setup_entry( @@ -65,7 +92,7 @@ async def async_setup_entry( ] entities = [ VentaBinarySensor(coordinator, description) - for description in BINARY_SENSOR_TYPES + for description in _supported_sensors(coordinator.api.device.device_type) if description.key in sensors ] async_add_entities(entities) diff --git a/custom_components/venta/config_flow.py b/custom_components/venta/config_flow.py index d4782ee..208115e 100644 --- a/custom_components/venta/config_flow.py +++ b/custom_components/venta/config_flow.py @@ -10,17 +10,21 @@ from aiohttp import ClientError from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult -from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_API_VERSION from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, TIMEOUT -from .venta import VentaDevice +from .venta import VentaDevice, VentaApiVersion _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, + vol.Required(CONF_HOST): str, + vol.Required( + CONF_API_VERSION, + default=VentaApiVersion.V2.value, + ): vol.In([entry.value for entry in list(VentaApiVersion)]), } ) @@ -28,7 +32,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Venta.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -38,8 +42,11 @@ async def async_step_user( if user_input is not None: try: host = user_input[CONF_HOST] + api_version = user_input[CONF_API_VERSION] async with asyncio.timeout(TIMEOUT): - device = VentaDevice(host, async_get_clientsession(self.hass)) + device = VentaDevice( + host, api_version, async_get_clientsession(self.hass) + ) await device.init() except (asyncio.TimeoutError, ClientError): _LOGGER.debug("Connection to %s timed out", host) @@ -48,13 +55,15 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return await self._create_entry(device.host, device.mac) + return await self._create_entry( + device.host, device.api_version, device.mac + ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def _create_entry(self, host, mac): + async def _create_entry(self, host, api_version, mac): """Register new entry.""" if not self.unique_id: await self.async_set_unique_id(mac) @@ -62,5 +71,5 @@ async def _create_entry(self, host, mac): return self.async_create_entry( title=host, - data={CONF_HOST: host, CONF_MAC: mac}, + data={CONF_HOST: host, CONF_API_VERSION: api_version, CONF_MAC: mac}, ) diff --git a/custom_components/venta/const.py b/custom_components/venta/const.py index 1010677..67a4962 100644 --- a/custom_components/venta/const.py +++ b/custom_components/venta/const.py @@ -5,8 +5,6 @@ TIMEOUT = 10 NO_WATER_THRESHOLD = 50000 -UNKNOWN_DEVICE_TYPE = "Unknown" - MODE_SLEEP = "sleep" MODE_LEVEL_1 = "level 1" MODE_LEVEL_2 = "level 2" diff --git a/custom_components/venta/humidifier.py b/custom_components/venta/humidifier.py index ed2cdd8..0cd738f 100644 --- a/custom_components/venta/humidifier.py +++ b/custom_components/venta/humidifier.py @@ -25,7 +25,7 @@ MODE_LEVEL_3, MODE_LEVEL_4, ) -from .venta import VentaDataUpdateCoordinator +from .venta import VentaDataUpdateCoordinator, VentaDeviceType, VentaApiVersion AVAILABLE_MODES = [ MODE_AUTO, @@ -56,14 +56,18 @@ async def async_setup_entry( coordinator: VentaDataUpdateCoordinator = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( - [VentaHumidifierEntity(coordinator, HUMIDIFIER_ENTITY_DESCRIPTION)] + [ + VentaV2HumidifierEntity(coordinator, HUMIDIFIER_ENTITY_DESCRIPTION) + if coordinator.api.version == VentaApiVersion.V2 + else VentaV3HumidifierEntity(coordinator, HUMIDIFIER_ENTITY_DESCRIPTION) + ] ) -class VentaHumidifierEntity( +class VentaBaseHumidifierEntity( CoordinatorEntity[VentaDataUpdateCoordinator], HumidifierEntity ): - """Venta humidifier Device.""" + """Venta base humidifier device.""" _attr_has_entity_name = True _attr_device_class = HumidifierDeviceClass.HUMIDIFIER @@ -94,7 +98,7 @@ def mode(self) -> str | None: data = self.coordinator.data if data.action.get("Automatic"): return MODE_AUTO - if data.action.get("SleepMode"): + if MODE_SLEEP in self._attr_available_modes and data.action.get("SleepMode"): return MODE_SLEEP level = data.action.get("FanSpeed", 1) return f"level {level}" @@ -109,39 +113,117 @@ def current_humidity(self) -> int | None: """Return the current humidity.""" return self.coordinator.data.measure.get("Humidity") + async def _send_action(self, json_action=None) -> None: + """Send action to device.""" + await self._device.update(json_action) + await self.coordinator.async_request_refresh() + + +class VentaV2HumidifierEntity(VentaBaseHumidifierEntity): + """Venta humidifier device for protocol version 2.""" + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self._device.update({"Action": {"Power": True}}) - await self.coordinator.async_request_refresh() + await self._send_action({"Action": {"Power": True}}) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self._device.update({"Action": {"Power": False}}) - await self.coordinator.async_request_refresh() + await self._send_action({"Action": {"Power": False}}) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self._device.update({"Action": {"TargetHum": humidity}}) - await self.coordinator.async_request_refresh() + await self._send_action({"Action": {"TargetHum": humidity}}) async def async_set_mode(self, mode: str) -> None: """Set new target preset mode.""" + action = {"Power": True} if mode == MODE_AUTO: - await self._device.update( - {"Action": {"Power": True, "SleepMode": False, "Automatic": True}} - ) + action.update({"SleepMode": False, "Automatic": True}) + elif mode == MODE_SLEEP: + action.update({"SleepMode": True}) + else: + level = int(mode[-1]) + action.update({"SleepMode": False, "Automatic": False, "FanSpeed": level}) + + await self._send_action({"Action": action}) + + +class VentaV3HumidifierEntity(VentaBaseHumidifierEntity): + """Venta humidifier device for protocol version 3.""" + + def __init__( + self, + coordinator: VentaDataUpdateCoordinator, + description: VentaHumidifierEntityDescription, + ) -> None: + """Initialize Venta V3 humidifier.""" + super().__init__(coordinator, description) + if coordinator.api.device.device_type == VentaDeviceType.AH550_AH555: + self._attr_available_modes = [ + MODE_AUTO, + MODE_LEVEL_1, + MODE_LEVEL_2, + MODE_LEVEL_3, + ] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + state = self.coordinator.data.action + action = { + "Power": True, + "Automatic": state.get("Automatic"), + "FanSpeed": state.get("FanSpeed"), + "Action": "control", + } + if not action.get("Automatic"): + action.update({"SleepMode": state.get("SleepMode")}) + await self._send_action(action) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + state = self.coordinator.data.action + action = { + "Power": False, + "Automatic": state.get("Automatic"), + "FanSpeed": state.get("FanSpeed"), + "Action": "control", + } + if not action.get("Automatic"): + action.update({"SleepMode": state.get("SleepMode")}) + await self._send_action(action) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + state = self.coordinator.data.action + await self._send_action( + { + "Power": state.get("Power"), + "Automatic": state.get("Automatic"), + "TargetHum": humidity, + "Action": "control", + } + ) + + async def async_set_mode(self, mode: str) -> None: + """Set new target preset mode.""" + state = self.coordinator.data.action + + action = { + "Power": True, + "Action": "control", + } + if mode == MODE_AUTO: + action.update({"Automatic": True}) elif mode == MODE_SLEEP: - await self._device.update({"Action": {"Power": True, "SleepMode": True}}) + action.update({"SleepMode": True, "Automatic": False}) else: level = int(mode[-1]) - await self._device.update( + action.update( { - "Action": { - "Power": True, - "SleepMode": False, - "Automatic": False, - "FanSpeed": level, - } + "SleepMode": state.get("SleepMode"), + "Automatic": False, + "FanSpeed": level, } ) - await self.coordinator.async_request_refresh() + + await self._send_action(action) diff --git a/custom_components/venta/light.py b/custom_components/venta/light.py index 955ab90..8cf858a 100644 --- a/custom_components/venta/light.py +++ b/custom_components/venta/light.py @@ -40,6 +40,8 @@ async def async_setup_entry( ) -> None: """Set up the Venta light platform.""" coordinator: VentaDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + if coordinator.data.action.get("LEDStrip") is None: + return async_add_entities([VentaLight(coordinator, LIGHT_ENTITY_DESCRIPTION)]) diff --git a/custom_components/venta/manifest.json b/custom_components/venta/manifest.json index 7cad08e..7053c01 100644 --- a/custom_components/venta/manifest.json +++ b/custom_components/venta/manifest.json @@ -9,5 +9,5 @@ "issue_tracker": "https://github.com/Michsior14/ha-venta/issues", "loggers": ["custom_components.venta"], "requirements": [], - "version": "0.2.0" + "version": "0.3.0" } diff --git a/custom_components/venta/select.py b/custom_components/venta/select.py index 808069d..6ad70d6 100644 --- a/custom_components/venta/select.py +++ b/custom_components/venta/select.py @@ -37,8 +37,9 @@ class VentaSelectRequiredKeysMixin: """Mixin for required keys.""" + exists_func: Callable[[VentaDataUpdateCoordinator], bool] value_func: Callable[[VentaData], str | None] - action_fun: Callable[[str], dict | None] + action_func: Callable[[str], dict | None] @dataclass @@ -53,8 +54,10 @@ class VentaSelectEntityDescription( key=ATTR_LED_STRIP_MODE, translation_key="led_strip_mode", entity_category=EntityCategory.CONFIG, + exists_func=lambda coordinator: coordinator.data.action.get("LEDStripMode") + is not None, value_func=lambda data: LED_STRIP_MODES.get(data.action.get("LEDStripMode")), - action_fun=( + action_func=( lambda option: { "Action": { "LEDStripMode": LED_STRIP_MODES_KEYS[ @@ -77,7 +80,7 @@ async def async_setup_entry( entities = [ VentaSelect(coordinator, description) for description in SENSOR_TYPES - if description.key in sensors + if description.key in sensors and description.exists_func(coordinator) ] async_add_entities(entities) @@ -108,6 +111,6 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.coordinator.api.device.update( - self.entity_description.action_fun(option) + self.entity_description.action_func(option) ) await self.coordinator.async_request_refresh() diff --git a/custom_components/venta/sensor.py b/custom_components/venta/sensor.py index 29d48a5..9d1e400 100644 --- a/custom_components/venta/sensor.py +++ b/custom_components/venta/sensor.py @@ -32,6 +32,7 @@ ATTR_DISC_ION_TIME = "disc_ion_time" ATTR_CLEANING_TIME = "cleaning_time" ATTR_SERVICE_TIME = "service_time" +ATTR_SERVICE_MAX_TIME = "service_max_time" ATTR_WARNINGS = "warnings" @@ -39,6 +40,7 @@ class VentaSensorRequiredKeysMixin: """Mixin for required keys.""" + exists_func: Callable[[VentaDataUpdateCoordinator], bool] value_func: Callable[[VentaData], int | None] @@ -58,6 +60,8 @@ class VentaSensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + exists_func=lambda coordinator: coordinator.data.measure.get("Temperature") + is not None, value_func=lambda data: data.measure.get("Temperature"), ), VentaSensorEntityDescription( @@ -66,6 +70,8 @@ class VentaSensorEntityDescription( icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("WaterLevel") + is not None, value_func=lambda data: data.measure.get("WaterLevel"), ), VentaSensorEntityDescription( @@ -75,6 +81,8 @@ class VentaSensorEntityDescription( icon="mdi:fast-forward", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("FanRpm") + is not None, value_func=lambda data: data.measure.get("FanRpm"), ), VentaSensorEntityDescription( @@ -83,6 +91,8 @@ class VentaSensorEntityDescription( icon="mdi:power-settings", native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("OperationT") + is not None, value_func=lambda data: data.info.get("OperationT"), ), VentaSensorEntityDescription( @@ -91,6 +101,8 @@ class VentaSensorEntityDescription( icon="mdi:power-settings", native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("DiscIonT") + is not None, value_func=lambda data: data.info.get("DiscIonT"), ), VentaSensorEntityDescription( @@ -99,6 +111,8 @@ class VentaSensorEntityDescription( icon="mdi:power-settings", native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("CleaningT") + is not None, value_func=lambda data: data.info.get("CleaningT"), ), VentaSensorEntityDescription( @@ -107,13 +121,27 @@ class VentaSensorEntityDescription( icon="mdi:power-settings", native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("ServiceT") + is not None, value_func=lambda data: data.info.get("ServiceT"), ), + VentaSensorEntityDescription( + key=ATTR_SERVICE_MAX_TIME, + translation_key="service_max_time", + icon="mdi:power-settings", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("ServiceMax") + is not None, + value_func=lambda data: data.info.get("ServiceMax"), + ), VentaSensorEntityDescription( key=ATTR_WARNINGS, translation_key="warnings", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + exists_func=lambda coordinator: coordinator.data.measure.get("Warnings") + is not None, value_func=lambda data: data.info.get("Warnings"), ), ) @@ -132,12 +160,13 @@ async def async_setup_entry( ATTR_DISC_ION_TIME, ATTR_CLEANING_TIME, ATTR_SERVICE_TIME, + ATTR_SERVICE_MAX_TIME, ATTR_WARNINGS, ] entities = [ VentaSensor(coordinator, description) for description in SENSOR_TYPES - if description.key in sensors + if description.key in sensors and description.exists_func(coordinator) ] async_add_entities(entities) diff --git a/custom_components/venta/strings.json b/custom_components/venta/strings.json index 06315a7..798fafa 100644 --- a/custom_components/venta/strings.json +++ b/custom_components/venta/strings.json @@ -3,7 +3,11 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "[%key:common::config_flow::data::host%]", + "api_version": "API version" + }, + "data_description": { + "api_version": "Match with `ProtocolV` property available at the device page." } } }, diff --git a/custom_components/venta/switch.py b/custom_components/venta/switch.py new file mode 100644 index 0000000..e02ef08 --- /dev/null +++ b/custom_components/venta/switch.py @@ -0,0 +1,121 @@ +"""Support for Venta switch.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import ( + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .venta import VentaData, VentaDataUpdateCoordinator, VentaDeviceType + + +ATTR_SLEEP_MODE = "sleep_mode" + + +@dataclass +class VentaSwitchRequiredKeysMixin: + """Mixin for required keys.""" + + exists_func: Callable[[VentaDataUpdateCoordinator], bool] + value_func: Callable[[VentaData], str | None] + action_func: Callable[[VentaData, bool], dict | None] + + +@dataclass +class VentaSwitchEntityDescription( + SwitchEntityDescription, VentaSwitchRequiredKeysMixin +): + """Describes Venta switch entity.""" + + +SENSOR_TYPES: tuple[VentaSwitchEntityDescription, ...] = ( + VentaSwitchEntityDescription( + key=ATTR_SLEEP_MODE, + translation_key="sleep_mode", + entity_category=EntityCategory.CONFIG, + exists_func=lambda coordinator: coordinator.api.device.device_type + == VentaDeviceType.AH550_AH555, + value_func=lambda data: data.action.get("SleepMode"), + action_func=( + lambda data, is_on: { + "Power": data.action.get("Power"), + "SleepMode": True, + "Automatic": False, + "FanSpeed": data.action.get("FanSpeed"), + "Action": "control", + } + if is_on + else { + "Power": data.action.get("Power"), + "SleepMode": False, + "Automatic": data.action.get("Automatic"), + "FanSpeed": data.action.get("FanSpeed"), + "Action": "control", + } + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Venta switch on config_entry.""" + coordinator: VentaDataUpdateCoordinator = hass.data[DOMAIN].get(entry.entry_id) + sensors = [ATTR_SLEEP_MODE] + entities = [ + VentaSwitch(coordinator, description) + for description in SENSOR_TYPES + if description.key in sensors and description.exists_func(coordinator) + ] + async_add_entities(entities) + + +class VentaSwitch(CoordinatorEntity[VentaDataUpdateCoordinator], SwitchEntity): + """Representation of a switch.""" + + _attr_has_entity_name = True + entity_description: VentaSwitchEntityDescription + + def __init__( + self, + coordinator: VentaDataUpdateCoordinator, + description: VentaSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.api.device.mac}-{description.key}" + self._device = coordinator.api.device + + @property + def is_on(self) -> str | None: + """Return if switch is on.""" + return self.entity_description.value_func(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._send_action(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._send_action(False) + + async def _send_action(self, on: bool): + await self._device.update( + self.entity_description.action_func(self.coordinator.data, on) + ) + await self.coordinator.async_request_refresh() diff --git a/custom_components/venta/translations/en.json b/custom_components/venta/translations/en.json index 636a0cf..0d8c56c 100644 --- a/custom_components/venta/translations/en.json +++ b/custom_components/venta/translations/en.json @@ -10,7 +10,11 @@ "step": { "user": { "data": { - "host": "Host" + "host": "Host", + "api_version": "API version" + }, + "data_description": { + "api_version": "Match with `ProtocolV` property available at the device page." } } } @@ -43,6 +47,9 @@ "service_time": { "name": "Service time" }, + "service_max_time": { + "name": "Service max time" + }, "warnings": { "name": "Warnings" } @@ -64,6 +71,11 @@ "led_strip_mode": { "name": "LED strip mode" } + }, + "switch": { + "sleep_mode": { + "name": "Sleep mode" + } } } } diff --git a/custom_components/venta/translations/pt.json b/custom_components/venta/translations/pt.json index 5aa76aa..d0a4272 100644 --- a/custom_components/venta/translations/pt.json +++ b/custom_components/venta/translations/pt.json @@ -10,7 +10,11 @@ "step": { "user": { "data": { - "host": "Endereço" + "host": "Endereço", + "api_version": "Versão da API", + "data_description": { + "api_version": "Combine com a propriedade `ProtocolV` disponível na página do dispositivo." + } } } } @@ -43,6 +47,9 @@ "service_time": { "name": "Tempo de serviço" }, + "service_max_time": { + "name": "Tempo máximo de serviço" + }, "warnings": { "name": "Avisos" } @@ -64,6 +71,11 @@ "led_strip_mode": { "name": "Modo da fita de LED " } + }, + "switch": { + "sleep_mode": { + "name": "Modo dormir" + } } } } diff --git a/custom_components/venta/venta.py b/custom_components/venta/venta.py index dec8fc3..9462bb8 100644 --- a/custom_components/venta/venta.py +++ b/custom_components/venta/venta.py @@ -3,6 +3,7 @@ from datetime import timedelta import asyncio from dataclasses import dataclass +from enum import Enum from aiohttp import ClientConnectionError from aiohttp import ClientSession, ServerDisconnectedError @@ -11,13 +12,32 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import UNKNOWN_DEVICE_TYPE, DOMAIN, TIMEOUT +from .const import DOMAIN, TIMEOUT _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=10) -DEVICE_TYPES = {106: "LW73/LW74"} + +class VentaDeviceType(Enum): + """Venta device types.""" + + UNKNOWN = -1 + LW73_LW74 = 106 + AH550_AH555 = 500 + + +class VentaApiVersion(Enum): + """Veta api versions.""" + + V2 = 2 + V3 = 3 + + +API_VERSION_ENDPOINTS = { + VentaApiVersion.V2: "datastructure", + VentaApiVersion.V3: "api/telemetry?request=set", +} @dataclass @@ -33,20 +53,25 @@ class VentaData: class VentaDevice: """Representation of a Venta device.""" - def __init__(self, host, session=None) -> None: + def __init__(self, host, api_version, session=None) -> None: """Venta device constructor.""" self.host = host + self.api_version = VentaApiVersion(api_version) self.mac = None - self.device_type = None + self.device_type = VentaDeviceType.UNKNOWN self._session = session + self._endpoint = ( + f"http://{self.host}/{API_VERSION_ENDPOINTS.get(self.api_version)}" + ) async def init(self): """Initialize the Venta device.""" data = await self.update() self.mac = data.header.get("MacAdress") - self.device_type = DEVICE_TYPES.get( - data.header.get("DeviceType"), UNKNOWN_DEVICE_TYPE - ) + try: + self.device_type = VentaDeviceType(data.header.get("DeviceType")) + except ValueError: + self.device_type = VentaDeviceType.UNKNOWN async def update(self, json_action=None): """Update the Venta device.""" @@ -73,9 +98,7 @@ async def _get_data(self, json_action=None, retries=3): async def _run_get_data(self, json=None): """Make the http request.""" _LOGGER.debug("Sending update request with data: %s", str(json)) - async with self._session.post( - f"http://{self.host}/datastructure", json=json - ) as resp: + async with self._session.post(self._endpoint, json=json) as resp: return await resp.json(content_type="text/plain") @@ -87,6 +110,7 @@ def __init__(self, device: VentaDevice) -> None: self.device = device self.name = "Venta" self.host = device.host + self.version = device.api_version async def async_update(self, **kwargs) -> VentaData: """Pull the latest data from Venta.""" @@ -115,10 +139,11 @@ async def _async_update_data(self) -> VentaData: def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" device = self.api.device + model = device.device_type.name.replace("_", "/") return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, device.mac)}, manufacturer="Venta", - name=f"Venta {device.device_type}", - model=device.device_type, + name=f"Venta {model}", + model=model, sw_version=self.data.info.get("SWMain"), ) diff --git a/info.md b/info.md index 01d4e79..c333351 100644 --- a/info.md +++ b/info.md @@ -1,16 +1,20 @@ # ha-venta -A Home Assistant custom Integration for Venta devices with wifi module. +A Home Assistant custom Integration for Venta devices (protocol: v2, v3) with wifi module. The following Venta device are currently tested and supported: -* LW74 Humidifier +- **Protocol v2:** + - LW73/LW74 Humidifier + +- **Protocol v3:** + - AH550/AH555/AH510/AH515 Humidifier ## Features -* Humidifier control (fan speed, target humidity, and auto/sleep mode). -* LED strip control (on/off, color, mode). -* Diagnostic sensors (water level, temperature, cleaning time, etc.). +- Humidifier control (fan speed, target humidity, and auto/sleep mode). +- LED strip control (on/off, color, mode). +- Diagnostic sensors (water level, temperature, cleaning time, etc.). ## Installation @@ -20,12 +24,12 @@ For manual installation, copy the venta folder and all of its contents into your ## Usage -Before the next steps make sure the device is configured using the Venta Home app and connected to the network. +Before the next steps make sure the device is configured using the Venta Home app and connected to the network. Next note down it's IP address, then visit `http://` and find property `ProtocolV` - this will be yours API version to fill later on. ### Adding the Integration To start configuring the integration, just press the "+ADD INTEGRATION" button in the Settings - Integrations page, and select Venta from the drop-down menu. -The configuration page will appear, requesting to input ip of the device. +The configuration page will appear, requesting to input ip and API version of the device. ## Contributing diff --git a/pylint.rc b/pylint.rc index ae74163..5fa9146 100644 --- a/pylint.rc +++ b/pylint.rc @@ -300,7 +300,7 @@ max-branches=12 max-locals=15 # Maximum number of parents for a class (see R0901). -max-parents=7 +max-parents=8 # Maximum number of public methods for a class (see R0904). max-public-methods=20 diff --git a/requirements_test.txt b/requirements_test.txt index b75b31b..14698b4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,8 @@ -black==23.9.1 -codespell==2.2.5 +codespell==2.2.6 flake8==6.1.0 -mypy==1.5.1 +mypy==1.7.1 pydocstyle==6.3.0 -pylint==2.17.5 +pylint==3.0.3 pylint-strict-informational==0.1 -homeassistant==2023.9.2 \ No newline at end of file +homeassistant==2023.12.2 +ruff==0.1.8 \ No newline at end of file diff --git a/tox.ini b/tox.ini index adc5591..041a694 100644 --- a/tox.ini +++ b/tox.ini @@ -28,7 +28,7 @@ deps = commands = codespell -q 4 -L {[tox]cs_exclude_words} --skip="*.pyc,*.pyi,*~,*.json" custom_components flake8 custom_components - black --fast --check . + ruff format --no-cache --check . pydocstyle -v custom_components pylint custom_components/venta --rcfile=pylint.rc