diff --git a/custom_components/tapo/__init__.py b/custom_components/tapo/__init__.py index 80235de..dda89ff 100755 --- a/custom_components/tapo/__init__.py +++ b/custom_components/tapo/__init__.py @@ -29,8 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, component) ) return True - except: - raise ConfigEntryNotReady + except Exception as error: + raise ConfigEntryNotReady from error async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/custom_components/tapo/binary_sensor.py b/custom_components/tapo/binary_sensor.py index 70469cc..0fa103e 100644 --- a/custom_components/tapo/binary_sensor.py +++ b/custom_components/tapo/binary_sensor.py @@ -1,17 +1,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from custom_components.tapo.common_setup import TapoCoordinator -from custom_components.tapo.common_setup import TapoUpdateCoordinator -from custom_components.tapo.tapo_sensor_entity import ( - TapoOverheatSensor, -) from custom_components.tapo.const import ( DOMAIN, ) +from custom_components.tapo.sensor import TapoSensor +from custom_components.tapo.sensors import OverheatSensorSource async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_devices): # get tapo helper - coordinator: TapoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [TapoOverheatSensor(coordinator)] + coordinator: TapoCoordinator = hass.data[DOMAIN][entry.entry_id] + sensors = [TapoSensor(coordinator, OverheatSensorSource())] async_add_devices(sensors, True) diff --git a/custom_components/tapo/common_setup.py b/custom_components/tapo/common_setup.py index 45a6d73..7aea9df 100644 --- a/custom_components/tapo/common_setup.py +++ b/custom_components/tapo/common_setup.py @@ -1,64 +1,72 @@ -import async_timeout -import logging from typing import Dict, Any from datetime import timedelta -from dataclasses import dataclass -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +import logging +import async_timeout from plugp100 import TapoApiClient, TapoApiClientConfig, TapoDeviceState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import UpdateFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.debounce import Debouncer from custom_components.tapo.const import ( DOMAIN, - PLATFORMS, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, ) - _LOGGGER = logging.getLogger(__name__) async def setup_tapo_coordinator_from_dictionary( hass: HomeAssistant, entry: Dict[str, Any] -) -> "TapoUpdateCoordinator": +) -> "TapoCoordinator": return await setup_tapo_coordinator( hass, entry.get(CONF_HOST), entry.get(CONF_USERNAME), entry.get(CONF_PASSWORD), + "", ) async def setup_tapo_coordinator_from_config_entry( hass: HomeAssistant, entry: ConfigEntry -) -> "TapoUpdateCoordinator": +) -> "TapoCoordinator": return await setup_tapo_coordinator( hass, entry.data.get(CONF_HOST), entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), + entry.unique_id, ) async def setup_tapo_coordinator( - hass: HomeAssistant, host: str, username: str, password: str -) -> "TapoUpdateCoordinator": - session = async_get_clientsession(hass) - config = TapoApiClientConfig(host, username, password, session) - client = TapoApiClient.from_config(config) - - coordinator = TapoUpdateCoordinator(hass, client=client) - await coordinator.async_config_entry_first_refresh() - - if not coordinator.last_update_success: - raise Exception("Failed to retrieve first tapo data") + hass: HomeAssistant, host: str, username: str, password: str, unique_id: str +) -> "TapoCoordinator": + api = ( + hass.data[DOMAIN][f"{unique_id}_api"] + if f"{unique_id}_api" in hass.data[DOMAIN] + else None + ) + if api is not None: + _LOGGGER.debug("Re-using setup API to create a coordinator") + coordinator = TapoCoordinator(hass, client=api) + else: + _LOGGGER.debug("Creating new API to create a coordinator") + session = async_get_clientsession(hass) + config = TapoApiClientConfig(host, username, password, session) + client = TapoApiClient.from_config(config) + coordinator = TapoCoordinator(hass, client=client) + + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as error: + _LOGGGER.error("Failed to setup %s", str(error)) + raise error return coordinator @@ -67,7 +75,7 @@ async def setup_tapo_coordinator( DEBOUNCER_COOLDOWN = 2 -class TapoUpdateCoordinator(DataUpdateCoordinator[TapoDeviceState]): +class TapoCoordinator(DataUpdateCoordinator[TapoDeviceState]): def __init__(self, hass: HomeAssistant, client: TapoApiClient): self.api = client debouncer = Debouncer( @@ -90,12 +98,14 @@ async def _async_update_data(self): async with async_timeout.timeout(10): return await self._update_with_fallback() except Exception as exception: - raise UpdateFailed() from exception + raise UpdateFailed( + f"Error communication with API: {exception}" + ) from exception async def _update_with_fallback(self, retry=True): try: return await self.api.get_state() - except Exception as error: + except Exception: # pylint: disable=broad-except if retry: await self.api.login() return await self._update_with_fallback(False) diff --git a/custom_components/tapo/config_flow.py b/custom_components/tapo/config_flow.py index 374fa3b..14e4d8c 100755 --- a/custom_components/tapo/config_flow.py +++ b/custom_components/tapo/config_flow.py @@ -1,6 +1,6 @@ """Config flow for tapo integration.""" import logging -import re +from typing import Any import voluptuous as vol @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -# TODO adjust the data schema to the data that you need + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required( @@ -40,7 +40,9 @@ class TapoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: """Handle the initial step.""" if user_input is None: return self.async_show_form( @@ -51,54 +53,53 @@ async def async_step_user(self, user_input=None): errors = {} try: - entry_metadata = await self._validate_input(user_input) - # check if the same device has already been configured - await self.async_set_unique_id(entry_metadata["unique_id"]) + if not user_input[CONF_HOST]: + raise InvalidHost + api = await self._try_setup_api(user_input) + unique_data = await self._get_unique_data_from_api(api) + unique_id = unique_data["unique_id"] + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - except CannotConnect: + self.hass.data[DOMAIN][f"{unique_id}_api"] = api + except CannotConnect as error: errors["base"] = "cannot_connect" - except InvalidAuth: + _LOGGER.error("Failed to setup %s", str(error)) + except InvalidAuth as error: errors["base"] = "invalid_auth" - except InvalidHost: + _LOGGER.error("Failed to setup %s", str(error)) + except InvalidHost as error: errors["base"] = "invalid_hostname" + _LOGGER.error("Failed to setup %s", str(error)) except data_entry_flow.AbortFlow: return self.async_abort(reason="already_configured") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") + except Exception as error: # pylint: disable=broad-except errors["base"] = "unknown" + _LOGGER.error("Failed to setup %s", str(error)) else: - return self.async_create_entry( - title=entry_metadata["title"], data=user_input - ) + return self.async_create_entry(title=unique_data["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def _validate_input(self, data): - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - - if not data[CONF_HOST]: - raise InvalidHost - - tapo_api = await self._test_credentials( - data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] - ) - - state = await tapo_api.get_state() - if not state: - raise CannotConnect - - # Return info that you want to store in the config entry. - return {"title": state.nickname, "unique_id": state.device_id} + async def _get_unique_data_from_api(self, api: TapoApiClient) -> dict[str, Any]: + try: + state = await api.get_state() + return {"title": state.nickname, "unique_id": state.device_id} + except Exception as error: + raise CannotConnect from error - async def _test_credentials(self, address, username, password) -> TapoApiClient: + async def _try_setup_api( + self, user_input: dict[str, Any] | None = None + ) -> TapoApiClient: try: session = async_create_clientsession(self.hass) - config = TapoApiClientConfig(address, username, password, session) + config = TapoApiClientConfig( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session, + ) client = TapoApiClient.from_config(config) await client.login() return client diff --git a/custom_components/tapo/const.py b/custom_components/tapo/const.py index 4993c2d..f6e456a 100755 --- a/custom_components/tapo/const.py +++ b/custom_components/tapo/const.py @@ -1,10 +1,6 @@ """Constants for the tapo integration.""" -from homeassistant.components.light import ( - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, -) +from homeassistant.components.light import ColorMode NAME = "tapo" DOMAIN = "tapo" @@ -13,14 +9,14 @@ SUPPORTED_DEVICE_AS_SWITCH = ["p100", "p105", "p110", "p115"] SUPPORTED_DEVICE_AS_SWITCH_POWER_MONITOR = ["p110", "p115"] SUPPORTED_DEVICE_AS_LIGHT = { - "l920": SUPPORT_BRIGHTNESS | SUPPORT_COLOR, - "l930": SUPPORT_BRIGHTNESS | SUPPORT_COLOR, - "l900": SUPPORT_BRIGHTNESS | SUPPORT_COLOR, - "l630": SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR, - "l530": SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR, - "l520": SUPPORT_BRIGHTNESS, - "l510": SUPPORT_BRIGHTNESS, - "l610": SUPPORT_BRIGHTNESS, + "l920": [ColorMode.ONOFF, ColorMode.BRIGHTNESS, ColorMode.HS], + "l930": [ColorMode.ONOFF, ColorMode.BRIGHTNESS, ColorMode.HS], + "l900": [ColorMode.ONOFF, ColorMode.BRIGHTNESS, ColorMode.HS], + "l630": [ColorMode.ONOFF, ColorMode.BRIGHTNESS, ColorMode.COLOR_TEMP, ColorMode.HS], + "l530": [ColorMode.ONOFF, ColorMode.BRIGHTNESS, ColorMode.COLOR_TEMP, ColorMode.HS], + "l520": [ColorMode.ONOFF, ColorMode.BRIGHTNESS], + "l510": [ColorMode.ONOFF, ColorMode.BRIGHTNESS], + "l610": [ColorMode.ONOFF, ColorMode.BRIGHTNESS], } ISSUE_URL = "https://github.com/petretiandrea/home-assistant-tapo-p100/issues" diff --git a/custom_components/tapo/light.py b/custom_components/tapo/light.py index 4a8fb5c..c354694 100755 --- a/custom_components/tapo/light.py +++ b/custom_components/tapo/light.py @@ -1,36 +1,32 @@ import logging from typing import Dict, Any, Callable - -from plugp100 import TapoDeviceState -from custom_components.tapo.common_setup import ( - TapoUpdateCoordinator, - setup_tapo_coordinator_from_dictionary, -) -from custom_components.tapo.utils import clamp from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.components.light import ( LightEntity, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, + ColorMode, ) from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired, color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from custom_components.tapo.tapo_entity import TapoEntity +from custom_components.tapo.utils import clamp from custom_components.tapo.const import DOMAIN, SUPPORTED_DEVICE_AS_LIGHT - +from custom_components.tapo.tapo_entity import TapoEntity +from custom_components.tapo.common_setup import ( + TapoCoordinator, + setup_tapo_coordinator_from_dictionary, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_devices): # get tapo helper - coordinator: TapoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: TapoCoordinator = hass.data[DOMAIN][entry.entry_id] _setup_from_coordinator(coordinator, async_add_devices) @@ -44,20 +40,20 @@ async def async_setup_platform( _setup_from_coordinator(coordinator, async_add_entities) -def _setup_from_coordinator(coordinator: TapoUpdateCoordinator, async_add_devices): - for (model, capabilities) in SUPPORTED_DEVICE_AS_LIGHT.items(): +def _setup_from_coordinator(coordinator: TapoCoordinator, async_add_devices): + for (model, color_modes) in SUPPORTED_DEVICE_AS_LIGHT.items(): if model.lower() in coordinator.data.model.lower(): light = TapoLight( coordinator, - capabilities, + color_modes, ) async_add_devices([light], True) class TapoLight(TapoEntity, LightEntity): - def __init__(self, coordinator, features: int): + def __init__(self, coordinator, color_modes: set[ColorMode]): super().__init__(coordinator) - self.features = features + self._color_modes = color_modes self._max_kelvin = 6500 self._min_kelvin = 2500 self._max_merids = kelvin_to_mired(2500) @@ -65,27 +61,26 @@ def __init__(self, coordinator, features: int): @property def is_on(self): - return self._tapo_coordinator.data.device_on + return self.last_state.device_on @property - def supported_features(self): - """Flag supported features.""" - return self.features + def supported_color_modes(self) -> set[ColorMode] | set[str] | None: + return self._color_modes @property def brightness(self): - return round((self._tapo_coordinator.data.brightness * 255) / 100) + return round((self.last_state.brightness * 255) / 100) @property def hs_color(self): - hue = self._tapo_coordinator.data.hue - saturation = self._tapo_coordinator.data.saturation + hue = self.last_state.hue + saturation = self.last_state.saturation if hue and saturation: return hue, saturation @property def color_temp(self): - color_temp = self._tapo_coordinator.data.color_temp + color_temp = self.last_state.color_temp if color_temp and color_temp > 0: return kelvin_to_mired(color_temp) @@ -102,20 +97,18 @@ async def async_turn_on(self, **kwargs): color = kwargs.get(ATTR_HS_COLOR) color_temp = kwargs.get(ATTR_COLOR_TEMP) - _LOGGER.info(f"Setting brightness: {brightness}") - _LOGGER.info(f"Setting color: {color}") - _LOGGER.info(f"Setting color_temp: {color_temp}") + _LOGGER.info("Setting brightness: %s", str(brightness)) + _LOGGER.info("Setting color: %s", str(color)) + _LOGGER.info("Setting color_temp: %s", str(color_temp)) if brightness or color or color_temp: - if self.is_on is False: - await self._execute_with_fallback(self._tapo_coordinator.api.on) if brightness: await self._change_brightness(brightness) - if color and self.supported_features & SUPPORT_COLOR: + if color and ColorMode.HS in self.supported_color_modes: hue = int(color[0]) saturation = int(color[1]) await self._change_color([hue, saturation]) - elif color_temp and self.supported_features & SUPPORT_COLOR_TEMP: + elif color_temp and ColorMode.COLOR_TEMP in self.supported_color_modes: color_temp = int(color_temp) await self._change_color_temp(color_temp) else: @@ -129,14 +122,14 @@ async def async_turn_off(self, **kwargs): async def _change_brightness(self, new_brightness): brightness_to_set = round((new_brightness / 255) * 100) - _LOGGER.info(f"Mapped brightness: {brightness_to_set}") + _LOGGER.info("Mapped brightness: %s", str(brightness_to_set)) await self._execute_with_fallback( lambda: self._tapo_coordinator.api.set_brightness(brightness_to_set) ) async def _change_color_temp(self, color_temp): - _LOGGER.info(f"Mapped color temp: {color_temp}") + _LOGGER.info("Mapped color temp: %s", str(color_temp)) constraint_color_temp = clamp(color_temp, self._min_merids, self._max_merids) kelvin_color_temp = clamp( mired_to_kelvin(constraint_color_temp), @@ -144,7 +137,10 @@ async def _change_color_temp(self, color_temp): max_value=self._max_kelvin, ) - if self.is_hardware_v2() and self.supported_features & SUPPORT_COLOR_TEMP: + if ( + self.last_state.is_hardware_v2 + and ColorMode.COLOR_TEMP in self.supported_color_modes + ): await self._execute_with_fallback( lambda: self._tapo_coordinator.api.set_hue_saturation(0, 0) ) @@ -154,10 +150,13 @@ async def _change_color_temp(self, color_temp): ) async def _change_color(self, hs_color): - _LOGGER.info(f"Mapped colors: {hs_color}") + _LOGGER.info("Mapped colors: %s", str(hs_color)) # L530 HW 2 device need to set color_temp to 0 before set hue and saturation. # When color_temp > 0 the device will ignore any hue and saturation value - if self.is_hardware_v2() and self.supported_features & SUPPORT_COLOR_TEMP: + if ( + self.last_state.is_hardware_v2 + and ColorMode.COLOR_TEMP in self.supported_color_modes + ): await self._execute_with_fallback( lambda: self._tapo_coordinator.api.set_color_temperature(0) ) @@ -167,10 +166,3 @@ async def _change_color(self, hs_color): hs_color[0], hs_color[1] ) ) - - def is_hardware_v2(self) -> bool: - device_state: TapoDeviceState = self.coordinator.data - hw_version = ( - device_state.state["hw_ver"] if "hw_ver" in device_state.state else None - ) - return hw_version is not None and hw_version == "2.0" diff --git a/custom_components/tapo/manifest.json b/custom_components/tapo/manifest.json index c5965ee..ce177a3 100755 --- a/custom_components/tapo/manifest.json +++ b/custom_components/tapo/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/petretiandrea/home-assistant-tapo-p100", "issue_tracker": "https://github.com/petretiandrea/home-assistant-tapo-p100/issues/", "requirements": [ - "plugp100==2.1.20" + "plugp100==2.1.21" ], "dependencies": [], "codeowners": [ diff --git a/custom_components/tapo/sensor.py b/custom_components/tapo/sensor.py index 93cc4c4..a0bb9ad 100644 --- a/custom_components/tapo/sensor.py +++ b/custom_components/tapo/sensor.py @@ -1,33 +1,75 @@ +from datetime import date, datetime +from typing import Optional from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant - -from custom_components.tapo.common_setup import TapoUpdateCoordinator -from custom_components.tapo.tapo_sensor_entity import ( - TapoCurrentEnergySensor, - TapoSignalSensor, - TapoTodayEnergySensor, - TapoMonthEnergySensor, -) +from homeassistant.helpers.typing import StateType +from homeassistant.components.sensor import SensorEntity +from custom_components.tapo.common_setup import TapoCoordinator from custom_components.tapo.const import ( DOMAIN, SUPPORTED_DEVICE_AS_SWITCH_POWER_MONITOR, ) +from custom_components.tapo.sensors import ( + CurrentEnergySensorSource, + MonthEnergySensorSource, + SignalSensorSource, + TodayEnergySensorSource, +) +from custom_components.tapo.sensors.tapo_sensor_source import TapoSensorSource +from custom_components.tapo.tapo_entity import TapoEntity ### Supported sensors: Today energy and current power SUPPORTED_SENSOR = [ - TapoTodayEnergySensor, - TapoMonthEnergySensor, + CurrentEnergySensorSource, + TodayEnergySensorSource, + MonthEnergySensorSource, # TapoThisMonthEnergySensor, hotfix - TapoCurrentEnergySensor, ] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_devices): # get tapo helper - coordinator: TapoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [TapoSignalSensor(coordinator)] + coordinator: TapoCoordinator = hass.data[DOMAIN][entry.entry_id] + sensors = [TapoSensor(coordinator, SignalSensorSource())] if coordinator.data.model.lower() in SUPPORTED_DEVICE_AS_SWITCH_POWER_MONITOR: - sensors.extend([factory(coordinator) for factory in SUPPORTED_SENSOR]) + sensors.extend( + [TapoSensor(coordinator, factory()) for factory in SUPPORTED_SENSOR] + ) async_add_devices(sensors, True) + + +class TapoSensor(TapoEntity, SensorEntity): + def __init__( + self, + coordiantor: TapoCoordinator, + sensor_source: TapoSensorSource, + ): + super().__init__(coordiantor) + self._sensor_source = sensor_source + self._sensor_config = self._sensor_source.get_config() + + @property + def unique_id(self): + return super().unique_id + "_" + self._sensor_config.name.replace(" ", "_") + + @property + def name(self): + return super().name + " " + self._sensor_config.name + + @property + def device_class(self) -> Optional[str]: + return self._sensor_config.device_class + + @property + def state_class(self) -> Optional[str]: + return self._sensor_config.state_class + + @property + def native_unit_of_measurement(self) -> Optional[str]: + return self._sensor_config.unit_measure + + @property + def native_value(self) -> StateType | date | datetime: + return self._sensor_source.get_value(self.last_state) diff --git a/custom_components/tapo/sensors/__init__.py b/custom_components/tapo/sensors/__init__.py new file mode 100644 index 0000000..26636d5 --- /dev/null +++ b/custom_components/tapo/sensors/__init__.py @@ -0,0 +1,110 @@ +from plugp100 import TapoDeviceState +from homeassistant.helpers.typing import StateType +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + POWER_WATT, + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, +) + +from custom_components.tapo.sensors.sensor_config import SensorConfig +from custom_components.tapo.sensors.tapo_sensor_source import TapoSensorSource + + +class TodayEnergySensorSource(TapoSensorSource): + def get_config(self) -> SensorConfig: + return SensorConfig( + "today energy", + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ENERGY_KILO_WATT_HOUR, + ) + + def get_value(self, state: TapoDeviceState | None) -> StateType: + if state.energy_info is not None: + return state.energy_info.today_energy / 1000 + return None + + +class MonthEnergySensorSource(TapoSensorSource): + def get_config(self) -> SensorConfig: + return SensorConfig( + "month energy", + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ENERGY_KILO_WATT_HOUR, + ) + + def get_value(self, state: TapoDeviceState | None) -> StateType: + if state.energy_info is not None: + return state.energy_info.month_energy / 1000 + return None + + +class ThisMonthEnergySensorSource(TapoSensorSource): + def get_config(self) -> SensorConfig: + return SensorConfig( + "this month energy", + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ENERGY_KILO_WATT_HOUR, + ) + + def get_value(self, state: TapoDeviceState | None) -> StateType: + if state.energy_info is not None: + return state.energy_info.this_month_energy / 1000 + return None + + +class CurrentEnergySensorSource(TapoSensorSource): + def get_config(self) -> SensorConfig: + return SensorConfig( + "current power", + DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + POWER_WATT, + ) + + def get_value(self, state: TapoDeviceState | None) -> StateType: + if state.energy_info is not None: + return state.energy_info.current_power / 1000 + return None + + +class OverheatSensorSource(TapoSensorSource): + def get_config(self) -> SensorConfig: + return SensorConfig( + name="overheat", + device_class="heat", + state_class=None, + unit_measure=None, + ) + + def get_value(self, state: TapoDeviceState | None) -> StateType: + if state is not None: + return state.overheated + return None + + +class SignalSensorSource(TapoSensorSource): + def get_config(self) -> SensorConfig: + return SensorConfig( + name="signal level", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=None, + unit_measure=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ) + + def get_value(self, state: TapoDeviceState | None) -> StateType: + try: + if state is not None: + return state.rssi + return 0 + except Exception: # pylint: disable=broad-except + return 0 diff --git a/custom_components/tapo/sensors/sensor_config.py b/custom_components/tapo/sensors/sensor_config.py new file mode 100644 index 0000000..b953c44 --- /dev/null +++ b/custom_components/tapo/sensors/sensor_config.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass +class SensorConfig: + name: str + device_class: str + state_class: str + unit_measure: str diff --git a/custom_components/tapo/sensors/tapo_sensor_source.py b/custom_components/tapo/sensors/tapo_sensor_source.py new file mode 100644 index 0000000..1c8c764 --- /dev/null +++ b/custom_components/tapo/sensors/tapo_sensor_source.py @@ -0,0 +1,11 @@ +from plugp100 import TapoDeviceState +from homeassistant.helpers.typing import StateType +from custom_components.tapo.sensors.sensor_config import SensorConfig + + +class TapoSensorSource: + def get_config(self) -> SensorConfig: + pass + + def get_value(self, state: TapoDeviceState | None) -> StateType: + pass diff --git a/custom_components/tapo/switch.py b/custom_components/tapo/switch.py index e0155a7..11b742c 100755 --- a/custom_components/tapo/switch.py +++ b/custom_components/tapo/switch.py @@ -1,49 +1,54 @@ -from typing import Callable, Dict, Any -from custom_components.tapo.tapo_entity import TapoEntity -from custom_components.tapo.const import ( - DOMAIN, - SUPPORTED_DEVICE_AS_SWITCH, -) +from homeassistant.core import callback from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.core import HomeAssistant from homeassistant.components.switch import SwitchEntity from custom_components.tapo.common_setup import ( - TapoUpdateCoordinator, + TapoCoordinator, setup_tapo_coordinator_from_dictionary, ) +from custom_components.tapo.tapo_entity import TapoEntity +from custom_components.tapo.const import ( + DOMAIN, + SUPPORTED_DEVICE_AS_SWITCH, +) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_devices): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_devices: AddEntitiesCallback +): # get tapo helper - coordinator: TapoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: TapoCoordinator = hass.data[DOMAIN][entry.entry_id] _setup_from_coordinator(coordinator, async_add_devices) async def async_setup_platform( hass: HomeAssistant, - config: Dict[str, Any], - async_add_entities: Callable, - discovery_info=None, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, ) -> None: coordinator = await setup_tapo_coordinator_from_dictionary(hass, config) _setup_from_coordinator(coordinator, async_add_entities) -def _setup_from_coordinator(coordinator: TapoUpdateCoordinator, async_add_devices): +def _setup_from_coordinator( + coordinator: TapoCoordinator, async_add_devices: AddEntitiesCallback +): if coordinator.data.model.lower() in SUPPORTED_DEVICE_AS_SWITCH: - switch = P100Switch(coordinator) - async_add_devices([switch], True) + async_add_devices([TapoPlug(coordinator)], True) -class P100Switch(TapoEntity, SwitchEntity): +class TapoPlug(TapoEntity, SwitchEntity): @property - def is_on(self): - return self._tapo_coordinator.data.device_on + def is_on(self) -> bool | None: + return self.last_state and self.last_state.device_on - async def async_turn_on(self): + async def async_turn_on(self, **kwargs): await self._execute_with_fallback(self._tapo_coordinator.api.on) await self._tapo_coordinator.async_request_refresh() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs): await self._execute_with_fallback(self._tapo_coordinator.api.off) await self._tapo_coordinator.async_request_refresh() diff --git a/custom_components/tapo/tapo_entity.py b/custom_components/tapo/tapo_entity.py index e1dc3b6..c065fdb 100755 --- a/custom_components/tapo/tapo_entity.py +++ b/custom_components/tapo/tapo_entity.py @@ -1,36 +1,48 @@ -from custom_components.tapo.common_setup import TapoUpdateCoordinator -from custom_components.tapo.const import DOMAIN -from typing import Union, Callable, Awaitable, TypeVar +from typing import Callable, Awaitable, TypeVar +from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo - from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.config_entries import ConfigEntry +from plugp100 import TapoDeviceState +from custom_components.tapo.common_setup import TapoCoordinator +from custom_components.tapo.const import DOMAIN class TapoEntity(CoordinatorEntity): - def __init__(self, coordiantor: TapoUpdateCoordinator): + def __init__(self, coordiantor: TapoCoordinator): super().__init__(coordiantor) + self._data = coordiantor.data @property - def _tapo_coordinator(self) -> TapoUpdateCoordinator: + def _tapo_coordinator(self) -> TapoCoordinator: return self.coordinator + @property + def last_state(self) -> TapoDeviceState | None: + return self._data + @property def device_info(self) -> DeviceInfo: return { - "identifiers": {(DOMAIN, self.coordinator.data.device_id)}, - "name": self.coordinator.data.nickname, - "model": self.coordinator.data.model, + "identifiers": {(DOMAIN, self.last_state.device_id)}, + "name": self.last_state.nickname, + "model": self.last_state.model, "manufacturer": "TP-Link", + "sw_version": self.last_state and self.last_state.firmware_version, + "hw_version": self.last_state and self.last_state.hardware_version, } @property def unique_id(self): - return self.coordinator.data.device_id + return self.last_state and self.last_state.device_id @property def name(self): - return self.coordinator.data.nickname + return self.last_state and self.last_state.nickname + + @callback + def _handle_coordinator_update(self) -> None: + self._data = self._tapo_coordinator.data + self.async_write_ha_state() T = TypeVar("T") diff --git a/custom_components/tapo/tapo_sensor_entity.py b/custom_components/tapo/tapo_sensor_entity.py deleted file mode 100644 index 1c732b5..0000000 --- a/custom_components/tapo/tapo_sensor_entity.py +++ /dev/null @@ -1,179 +0,0 @@ -from dataclasses import dataclass -from typing import Optional -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - SensorEntity, -) -from homeassistant.components.sensor import ( - STATE_CLASS_TOTAL_INCREASING, -) -from homeassistant.const import ( - POWER_WATT, - ENERGY_KILO_WATT_HOUR, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_SIGNAL_STRENGTH, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, -) -from homeassistant.helpers.typing import StateType -from custom_components.tapo.common_setup import TapoUpdateCoordinator -from custom_components.tapo.tapo_entity import TapoEntity -from plugp100 import TapoDeviceState - - -@dataclass -class SensorConfig: - name: str - device_class: str - state_class: str - unit_measure: str - - -class TapoSensor(TapoEntity, SensorEntity): - def __init__( - self, - coordiantor: TapoUpdateCoordinator, - sensor_config: SensorConfig, - ): - super().__init__(coordiantor) - self.sensor_config = sensor_config - - @property - def unique_id(self): - return super().unique_id + "_" + self.sensor_config.name.replace(" ", "_") - - @property - def name(self): - return super().name + " " + self.sensor_config.name - - @property - def device_class(self) -> Optional[str]: - return self.sensor_config.device_class - - @property - def state_class(self) -> Optional[str]: - return self.sensor_config.state_class - - @property - def native_unit_of_measurement(self) -> Optional[str]: - return self.sensor_config.unit_measure - - -class TapoTodayEnergySensor(TapoSensor): - def __init__(self, coordiantor: TapoUpdateCoordinator): - super().__init__( - coordiantor, - SensorConfig( - "today energy", - DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL_INCREASING, - ENERGY_KILO_WATT_HOUR, - ), - ) - - @property - def native_value(self) -> StateType: - if self.coordinator.data.energy_info is not None: - return self.coordinator.data.energy_info.today_energy / 1000 - return None - - -class TapoMonthEnergySensor(TapoSensor): - def __init__(self, coordiantor: TapoUpdateCoordinator): - super().__init__( - coordiantor, - SensorConfig( - "month energy", - DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL_INCREASING, - ENERGY_KILO_WATT_HOUR, - ), - ) - - @property - def native_value(self) -> StateType: - if self.coordinator.data.energy_info is not None: - return self.coordinator.data.energy_info.month_energy / 1000 - return None - - -class TapoThisMonthEnergySensor(TapoSensor): - def __init__(self, coordiantor: TapoUpdateCoordinator): - super().__init__( - coordiantor, - SensorConfig( - "this month energy", - DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL_INCREASING, - ENERGY_KILO_WATT_HOUR, - ), - ) - - @property - def native_value(self) -> StateType: - if self.coordinator.data.energy_info is not None: - return self.coordinator.data.energy_info.this_month_energy / 1000 - return None - - -class TapoCurrentEnergySensor(TapoSensor): - def __init__(self, coordiantor: TapoUpdateCoordinator): - super().__init__( - coordiantor, - SensorConfig( - "current power", - DEVICE_CLASS_POWER, - STATE_CLASS_MEASUREMENT, - POWER_WATT, - ), - ) - - @property - def native_value(self) -> StateType: - data: TapoDeviceState = self.coordinator.data - if data.energy_info is not None: - return data.energy_info.current_power / 1000 - return None - - -class TapoOverheatSensor(TapoSensor): - def __init__(self, coordiantor: TapoUpdateCoordinator): - super().__init__( - coordiantor, - SensorConfig( - name="overheat", - device_class="heat", - state_class=None, - unit_measure=None, - ), - ) - - @property - def native_value(self) -> StateType: - data: TapoDeviceState = self.coordinator.data - if data is not None: - return data.overheated - return None - - -class TapoSignalSensor(TapoSensor): - def __init__(self, coordiantor: TapoUpdateCoordinator): - super().__init__( - coordiantor, - SensorConfig( - name="signal level", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - state_class=None, - unit_measure=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - ), - ) - - @property - def native_value(self) -> StateType: - data: TapoDeviceState = self.coordinator.data - try: - if data is not None: - return data.rssi - return 0 - except: - return 0 diff --git a/hacs.json b/hacs.json index 9ef0e13..899c6e2 100644 --- a/hacs.json +++ b/hacs.json @@ -1,12 +1,6 @@ { "name": "Tapo Controller", "hacs": "1.6.0", - "domains": [ - "tapo", - "switch", - "light" - ], - "iot_class": "Local Polling", "render_readme": true, "homeassistant": "2022.6.0" } \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index fe72b77..49a8267 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,2 +1,2 @@ homeassistant -plugp100==2.1.20 \ No newline at end of file +plugp100==2.1.21 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3abb9e9..326b1d3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,12 +8,18 @@ max-line-length = 88 # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator +# C0114: missing module docstring +# C0115: missing class docstring +# C0116: missing function docstring ignore = E501, W503, E203, D202, - W504 + W504, + C0115, + C0114, + C0116 [isort] # https://github.com/timothycrosley/isort