diff --git a/README.md b/README.md index 55f4195..bbff516 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ Mitubishi Kumo Cloud (Kumo for short) is a custom component for Home Assistant t - Implements standard Home Assistant [`climate`](https://www.home-assistant.io/integrations/climate/) entities. - Supports reading and setting the mode (heat/cool/etc.), setpoint, fan speed, and vane swing. - Supports fully local control, except for initial setup. (See `prefer_cache` in Configuration for details.) +- Supports displaying Outdoor Temperature for Kumo Station +- Supports displaying WiFi RSSI of each unit(disabled by default) ## Installation -You can install Kumo in one of two ways. +You can install Kumo in one of two ways. -- **Automatic Installation.** Kumo is available in the HACS default store. Search for "Kumo" in the Integrations panel, and then click the **Mitsubishi Kumo Cloud** item in the results. Click the Install link, and then restart Home Assistant. +- **Automatic Installation.** Kumo is available in the HACS default store. Search for "Kumo" in the Integrations panel, and then click the **Mitsubishi Kumo Cloud** item in the results. Click the Install link, and then restart Home Assistant. - **Manual Installation.** To control your installation yourself, download the hass-kumo repo, and then copy the `custom_components/kumo` directory into a corresponding `custom_components/kumo` within your Home Assistant configuration directory. Then restart Home Assistant. We recommend using the HACS installation method, which makes future updates to Kumo easy to track and install. Click the HACS badge above for details on installing and using HACS. @@ -39,7 +41,7 @@ kumo: Add the referenced secrets to your secrets.yaml. -- `prefer_cache`, if present, controls whether to contact the KumoCloud servers on startup, or to prefer locally cached info on how to communicate with the indoor units. Default is `false`, to accommodate changing unit availability or DHCP leases. If your configuration is static (including the units' IP addresses on your LAN), it's safe to set this to `true`. This will allow you to control your system even if KumoCloud or your Internet connection suffer an outage. +- `prefer_cache`, if present, controls whether to contact the KumoCloud servers on startup, or to prefer locally cached info on how to communicate with the indoor units. Default is `false`, to accommodate changing unit availability or DHCP leases. If your configuration is static (including the units' IP addresses on your LAN), it's safe to set this to `true`. This will allow you to control your system even if KumoCloud or your Internet connection suffer an outage. - `connect_timeout` and `response_timeout`, if present, control network timeouts for each command or status poll from the indoor unit(s). Increase these numbers if you see frequent log messages about timeouts. Decrease these numbers to improve overall HA responsivness if you anticipate your units being offline. ### IP Addresses @@ -51,14 +53,14 @@ In some cases, Kumo is unable to retrieve the indoor units' addresses from the K ## Home Assistant Entities and Control -Each indoor unit appears as a separate [`climate`](https://www.home-assistant.io/integrations/climate/) entity in Home Assistant. Entity names are derived from the name you created for the unit in KumoCloud. For example, `climate.bedroom` or `climate.living_room`. +Each indoor unit appears as a separate [`climate`](https://www.home-assistant.io/integrations/climate/) entity in Home Assistant. Entity names are derived from the name you created for the unit in KumoCloud. For example, `climate.bedroom` or `climate.living_room`. Entity attributes can tell you more about the current state of the indoor unit, as well as the unit's capabilities. Attributes may include the following: - `hvac_modes`: The different modes of operation supported by the unit. For example: `off, cool, dry, heat, fan_only`. - `min_temp`: The minimum temperature the unit can be set to. For example, `45`. - `max_temp`: The maximum temperature the unit can be set to: For example, `95`. -- `fan_modes`: The different modes supported for the fan. This corresponds to fan speed, and noise. For example: `superQuiet, quiet, low, powerful, superPowerful, auto`. +- `fan_modes`: The different modes supported for the fan. This corresponds to fan speed, and noise. For example: `superQuiet, quiet, low, powerful, superPowerful, auto`. - `swing_modes`: The different modes supported for the fan vanes. For example: `horizontal, midhorizontal, midpoint, midvertical, auto, swing`. - `current_temperature`: The current ambient temperature, as sensed by the indoor unit. For example, `73`. - `temperature`: The target temperature. For example, `77`. diff --git a/custom_components/kumo/__init__.py b/custom_components/kumo/__init__.py index 4946b13..da38240 100644 --- a/custom_components/kumo/__init__.py +++ b/custom_components/kumo/__init__.py @@ -1,16 +1,16 @@ """Support for Mitsubishi KumoCloud devices.""" -import asyncio import logging +from typing import Optional import homeassistant.helpers.config_validation as cv import pykumo import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.json import load_json, save_json +from .coordinator import KumoDataUpdateCoordinator from .const import ( CONF_CONNECT_TIMEOUT, CONF_PREFER_CACHE, @@ -18,6 +18,8 @@ DOMAIN, KUMO_CONFIG_CACHE, KUMO_DATA, + KUMO_DATA_COORDINATORS, + PLATFORMS, ) _LOGGER = logging.getLogger(__name__) @@ -39,7 +41,7 @@ ) -class KumoData: +class KumoCloudSettings: """Hold object representing KumoCloud account.""" def __init__(self, account, domain_config, domain_options): @@ -65,151 +67,70 @@ def get_raw_json(self): """Retrieve raw JSON config from account.""" return self._account.get_raw_json() +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Setup Kumo Entry""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(entry.entry_id, {}) + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + prefer_cache = entry.data.get(CONF_PREFER_CACHE) -def setup_kumo(hass, config): - """Set up the Kumo indoor units.""" - hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) - hass.async_add_job(hass.config_entries.async_forward_entry_setup(config, "climate")) + account = await async_kumo_setup(hass, prefer_cache, username, password) + if not account: + # Attempt setup again, but flip the prefer_cache flag + account = await async_kumo_setup(hass, not prefer_cache, username, password) -async def async_setup(hass, config): - """Set up the Kumo Cloud devices. Will create climate and sensor components to support devices listed on the provided Kumo Cloud account.""" - if DOMAIN not in config: - return True - # pylint: disable=C0415 - username = config[DOMAIN].get(CONF_USERNAME) - password = config[DOMAIN].get(CONF_PASSWORD) - prefer_cache = config[DOMAIN].get(CONF_PREFER_CACHE) - domain_options = { - "connect_timeout": config[DOMAIN].get(CONF_CONNECT_TIMEOUT), - "response_timeout": config[DOMAIN].get(CONF_RESPONSE_TIMEOUT), - } - - # Read config from either remote KumoCloud server or - # cached JSON. - cached_json = {} - success = False - if prefer_cache: - # Try to load from cache - cached_json = await hass.async_add_executor_job( - load_json, hass.config.path(KUMO_CONFIG_CACHE) - ) or {"fetched": False} - account = pykumo.KumoCloudAccount(username, password, kumo_dict=cached_json) - else: - # Try to load from server - account = pykumo.KumoCloudAccount(username, password) - setup_success = await hass.async_add_executor_job(account.try_setup) - if setup_success: - if prefer_cache: - _LOGGER.info("Loaded config from local cache") - success = True - else: - await hass.async_add_executor_job( - save_json, hass.config.path(KUMO_CONFIG_CACHE), account.get_raw_json() - ) - _LOGGER.info("Loaded config from KumoCloud server") - success = True - else: - # Fall back - if prefer_cache: - # Try to load from server - account = pykumo.KumoCloudAccount(username, password) - else: - # Try to load from cache - cached_json = await hass.async_add_executor_job( - load_json, hass.config.path(KUMO_CONFIG_CACHE) - ) or {"fetched": False} - account = pykumo.KumoCloudAccount(username, password, kumo_dict=cached_json) - setup_success = await hass.async_add_executor_job(account.try_setup) - if setup_success: - if prefer_cache: - await hass.async_add_executor_job( - save_json, - hass.config.path(KUMO_CONFIG_CACHE), - account.get_raw_json(), - ) - _LOGGER.info("Loaded config from KumoCloud server as fallback") - success = True - else: - _LOGGER.info("Loaded config from local cache as fallback") - success = True - - if success: - hass.data[KUMO_DATA] = KumoData(account, config[DOMAIN], domain_options) - setup_kumo(hass, config) + if account: + hass.data[DOMAIN][entry.entry_id][KUMO_DATA] = KumoCloudSettings(account, entry.data, entry.options) + + # Create a data coordinator for each Kumo device + hass.data[DOMAIN][entry.entry_id].setdefault(KUMO_DATA_COORDINATORS, {}) + coordinators = hass.data[DOMAIN][entry.entry_id][KUMO_DATA_COORDINATORS] + connect_timeout = float( + entry.options.get(CONF_CONNECT_TIMEOUT, "1.2") + ) + response_timeout = float( + entry.options.get(CONF_RESPONSE_TIMEOUT, "8") + ) + timeouts = (connect_timeout, response_timeout) + pykumos = await hass.async_add_executor_job(account.make_pykumos, timeouts, True) + for device in pykumos.values(): + if device.get_serial() not in coordinators: + coordinators[device.get_serial()] = KumoDataUpdateCoordinator(hass, device) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True _LOGGER.warning("Could not load config from KumoCloud server or cache") return False - -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): - """Setup Entry""" - username = entry.data.get(CONF_USERNAME) - password = entry.data.get(CONF_PASSWORD) - prefer_cache = entry.data.get(CONF_PREFER_CACHE) - # Read config from either remote KumoCloud server or - # cached JSON. - cached_json = {} - success = False +async def async_kumo_setup(hass: HomeAssistantType, prefer_cache: bool, username: str, password: str) -> Optional[pykumo.KumoCloudAccount]: + """Attempt to load data from cache or Kumo Cloud""" if prefer_cache: - # Try to load from cache cached_json = await hass.async_add_executor_job( load_json, hass.config.path(KUMO_CONFIG_CACHE) ) or {"fetched": False} account = pykumo.KumoCloudAccount(username, password, kumo_dict=cached_json) else: - # Try to load from server account = pykumo.KumoCloudAccount(username, password) + setup_success = await hass.async_add_executor_job(account.try_setup) + if setup_success: if prefer_cache: _LOGGER.info("Loaded config from local cache") - success = True else: await hass.async_add_executor_job( save_json, hass.config.path(KUMO_CONFIG_CACHE), account.get_raw_json() ) _LOGGER.info("Loaded config from KumoCloud server") - success = True - else: - # Fall back - if prefer_cache: - # Try to load from server - account = pykumo.KumoCloudAccount(username, password) - else: - # Try to load from cache - cached_json = await hass.async_add_executor_job( - load_json, hass.config.path(KUMO_CONFIG_CACHE) - ) or {"fetched": False} - account = pykumo.KumoCloudAccount(username, password, kumo_dict=cached_json) - setup_success = await hass.async_add_executor_job(account.try_setup) - if setup_success: - if prefer_cache: - await hass.async_add_executor_job( - save_json, - hass.config.path(KUMO_CONFIG_CACHE), - account.get_raw_json(), - ) - _LOGGER.info("Loaded config from KumoCloud server as fallback") - success = True - else: - _LOGGER.info("Loaded config from local cache as fallback") - success = True - if success: - data = KumoData(account, entry.data, entry.options) - hass.data[DOMAIN] = data - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "climate") - ) - return True - _LOGGER.warning("Could not load config from KumoCloud server or cache") - return False + return account async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload Entry""" - hass.data.pop(DOMAIN) - tasks = [] - tasks.append(hass.config_entries.async_forward_entry_unload(entry, "climate")) - return all(await asyncio.gather(*tasks)) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/custom_components/kumo/climate.py b/custom_components/kumo/climate.py index 09b0d11..21b2e9a 100644 --- a/custom_components/kumo/climate.py +++ b/custom_components/kumo/climate.py @@ -2,12 +2,13 @@ import logging import pprint -import pykumo import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN +from .coordinator import KumoDataUpdateCoordinator +from .entity import CoordinatedKumoEntitty try: from homeassistant.components.climate import ClimateEntity @@ -16,32 +17,20 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.climate.const import ( - ATTR_HVAC_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - CURRENT_HVAC_COOL, - CURRENT_HVAC_DRY, - CURRENT_HVAC_FAN, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, - HVAC_MODE_COOL, - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - SUPPORT_FAN_MODE, - SUPPORT_SWING_MODE, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, TEMP_CELSIUS - -from . import CONF_CONNECT_TIMEOUT, CONF_RESPONSE_TIMEOUT, KUMO_DATA + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, CURRENT_HVAC_DRY, CURRENT_HVAC_FAN, CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, HVAC_MODE_COOL, HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import (ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, + TEMP_CELSIUS) +from homeassistant.helpers.typing import HomeAssistantType + +from .const import KUMO_DATA, KUMO_DATA_COORDINATORS _LOGGER = logging.getLogger(__name__) -__PLATFORM_IS_SET_UP = False CONF_NAME = "name" CONF_ADDRESS = "address" @@ -98,102 +87,24 @@ KUMO_STATE_VENT: CURRENT_HVAC_FAN, KUMO_STATE_OFF: CURRENT_HVAC_OFF, } -MAX_SETUP_TRIES = 10 -MAX_AVAILABILITY_TRIES = 3 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities): """Set up the Kumo thermostats.""" - data = hass.data[DOMAIN] - data._setup_tries += 1 - if data._setup_tries > MAX_SETUP_TRIES: - raise HomeAssistantError("Giving up trying to set up Kumo") - - devices = [] - units = await hass.async_add_executor_job(data.get_account().get_indoor_units) - for unit in units: - name = data.get_account().get_name(unit) - address = data.get_account().get_address(unit) - credentials = data.get_account().get_credentials(unit) - connect_timeout = float( - data.get_domain_options().get(CONF_CONNECT_TIMEOUT, "1.2") - ) - response_timeout = float( - data.get_domain_options().get(CONF_RESPONSE_TIMEOUT, "8") - ) - kumo_api = pykumo.PyKumo( - name, address, credentials, (connect_timeout, response_timeout) - ) - success = await hass.async_add_executor_job(kumo_api.update_status) - if not success: - _LOGGER.warning("Kumo %s could not be set up", name) - continue - kumo_thermostat = KumoThermostat(kumo_api, unit) - await hass.async_add_executor_job(kumo_thermostat.update) - devices.append(kumo_thermostat) - _LOGGER.debug("Kumo adding entity: %s", name) - if not devices: - _LOGGER.warning( - "Kumo could not set up any indoor units (try %d of %d)", - data._setup_tries, - MAX_SETUP_TRIES, - ) - raise PlatformNotReady - async_add_entities(devices, True) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up Kumo thermostats. Run Once""" - global __PLATFORM_IS_SET_UP - if __PLATFORM_IS_SET_UP: - return - __PLATFORM_IS_SET_UP = True - - data = hass.data[KUMO_DATA] - data._setup_tries += 1 - if data._setup_tries > MAX_SETUP_TRIES: - raise HomeAssistantError("Giving up trying to set up Kumo") - - devices = [] - units = data.get_account().get_indoor_units() - for unit in units: - name = data.get_account().get_name(unit) - address = data.get_account().get_address(unit) - credentials = data.get_account().get_credentials(unit) - if data.get_domain_options().get(CONF_CONNECT_TIMEOUT) is None: - connect_timeout = 1.2 - else: - connect_timeout = float( - data.get_domain_options().get(CONF_CONNECT_TIMEOUT, "1.2") - ) - if data.get_domain_options().get(CONF_RESPONSE_TIMEOUT) is None: - response_timeout = 8.0 - else: - response_timeout = float( - data.get_domain_options().get(CONF_RESPONSE_TIMEOUT, "8") - ) - kumo_api = pykumo.PyKumo( - name, address, credentials, (connect_timeout, response_timeout) - ) - success = await hass.async_add_executor_job(kumo_api.update_status) - if not success: - _LOGGER.warning("Kumo %s could not be set up", name) - continue - kumo_thermostat = KumoThermostat(kumo_api, unit) - await hass.async_add_executor_job(kumo_thermostat.update) - devices.append(kumo_thermostat) - _LOGGER.debug("Kumo adding entity: %s", name) - if not devices: - _LOGGER.warning( - "Kumo could not set up any indoor units (try %d of %d)", - data._setup_tries, - MAX_SETUP_TRIES, - ) - raise PlatformNotReady - async_add_entities(devices) - - -class KumoThermostat(ClimateEntity): + account = hass.data[DOMAIN][entry.entry_id][KUMO_DATA].get_account() + coordinators = hass.data[DOMAIN][entry.entry_id][KUMO_DATA_COORDINATORS] + + entities = [] + indor_unit_serials = await hass.async_add_executor_job(account.get_indoor_units) + for serial in indor_unit_serials: + coordinator = coordinators[serial] + entities.append(KumoThermostat(coordinator)) + _LOGGER.debug("Adding entity: %s", coordinator.get_device().get_name()) + if not entities: + raise ConfigEntryNotReady("Kumo integration found no indoor units") + async_add_entities(entities, True) + +class KumoThermostat(CoordinatedKumoEntitty, ClimateEntity): """Representation of a Kumo Thermostat device.""" _update_properties = [ @@ -214,11 +125,12 @@ class KumoThermostat(ClimateEntity): "runstate", ] - def __init__(self, kumo_api, unit): + def __init__(self, coordinator: KumoDataUpdateCoordinator): """Initialize the thermostat.""" - self._name = kumo_api.get_name() - self._identifier = unit + super().__init__(coordinator) + coordinator.add_update_method(self.update) + self._name = self._pykumo.get_name() self._target_temperature = None self._target_temperature_low = None self._target_temperature_high = None @@ -234,7 +146,6 @@ def __init__(self, kumo_api, unit): self._rssi = None self._sensor_rssi = None self._runstate = None - self._pykumo = kumo_api self._fan_modes = self._pykumo.get_fan_speeds() self._swing_modes = self._pykumo.get_vane_directions() self._hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_COOL] @@ -252,7 +163,7 @@ def __init__(self, kumo_api, unit): self._supported_features |= SUPPORT_SWING_MODE for prop in KumoThermostat._update_properties: try: - setattr(self, "_%s" % prop, None) + setattr(self, f"_{prop}", None) except AttributeError as err: _LOGGER.debug( "Kumo %s: Initializing attr %s error: %s", @@ -260,56 +171,40 @@ def __init__(self, kumo_api, unit): prop, str(err), ) - self._unavailable_count = 0 - self._available = False - def update(self): + @property + def unique_id(self): + """Return unique id""" + # For backwards compatibility, this ID is considered the primary + return self._identifier + + async def update(self): """Call from HA to trigger a refresh of cached state.""" for prop in KumoThermostat._update_properties: self._update_property(prop) - if self._unavailable_count > 1: + if not self.available: # Get out early if it's failing break - def _update_availability(self, success): - if success: - self._available = True - self._unavailable_count = 0 - else: - self._unavailable_count += 1 - if self._unavailable_count >= MAX_AVAILABILITY_TRIES: - self._available = False - def _update_property(self, prop): """Call to refresh the value of a property -- may block on I/O.""" try: - do_update = getattr(self, "_update_%s" % prop) + do_update = getattr(self, f"_update_{prop}") except AttributeError: _LOGGER.debug( "Kumo %s: %s property updater not implemented", self._name, prop ) return success = self._pykumo.update_status() - self._update_availability(success) if not success: return do_update() - @property - def available(self): - """Return whether Home Assistant is able to read the state and control the underlying device.""" - return self._available - @property def supported_features(self): """Return the list of supported features.""" return self._supported_features - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self._name - @property def temperature_unit(self): """Return the unit of measurement which this thermostat uses.""" @@ -524,16 +419,6 @@ def extra_state_attributes(self): return attr - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def unique_id(self): - """Return unique id""" - return self._identifier - @property def device_info(self): """Return device information for this Kumo Thermostat""" diff --git a/custom_components/kumo/const.py b/custom_components/kumo/const.py index 12b3f6d..4df819b 100755 --- a/custom_components/kumo/const.py +++ b/custom_components/kumo/const.py @@ -1,8 +1,25 @@ """Constants for the Kumo integration.""" + +from datetime import timedelta +from typing import Final + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + DEFAULT_NAME = "Kumo" DOMAIN = "kumo" -KUMO_DATA = "kumo_data" +KUMO_DATA = "data" +KUMO_DATA_COORDINATORS = "coordinators" KUMO_CONFIG_CACHE = "kumo_cache.json" CONF_PREFER_CACHE = "prefer_cache" CONF_CONNECT_TIMEOUT = "connect_timeout" CONF_RESPONSE_TIMEOUT = "response_timeout" +MAX_AVAILABILITY_TRIES = 3 # How many times we will attempt to update from a kumo before marking it unavailable + +PLATFORMS: Final = [CLIMATE_DOMAIN, SENSOR_DOMAIN] + +# This is the new way of important platforms, but isn't public yet +# from homeassistant.const import Platform +# PLATFORMS: Final = [Platform.CLIMATE, Platform.SENSOR] + +SCAN_INTERVAL = timedelta(seconds=60) diff --git a/custom_components/kumo/coordinator.py b/custom_components/kumo/coordinator.py new file mode 100644 index 0000000..ece8a57 --- /dev/null +++ b/custom_components/kumo/coordinator.py @@ -0,0 +1,67 @@ +"""Coordinator to gather data for the Kumo integration""" + +import logging +from collections.abc import Awaitable, Callable +from typing import TypeVar + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, + UpdateFailed) +from pykumo import PyKumoBase + +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) +MAX_AVAILABILITY_TRIES = 3 + +T = TypeVar("T") + + +class KumoDataUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for a specific Kumo device.""" + + def __init__( + self, + hass: HomeAssistant, + device: PyKumoBase, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific Kumo device.""" + self.device = device + self._available = False + self._unavailable_count = 0 + self._additional_update_methods = [] + super().__init__( + hass, + _LOGGER, + name=f"kumo_{device.get_serial()}", + update_interval=SCAN_INTERVAL, + ) + + def get_device(self) -> PyKumoBase: + return self.device + + def get_available(self) -> bool: + return self._available + + def add_update_method(self, update_method: Callable[[], Awaitable[T]]) -> None: + """Register update methods that will be called after updating status""" + self._additional_update_methods.append(update_method) + + async def _async_update_data(self) -> None: + """Fetch data from Kumo device.""" + success = await self.hass.async_add_executor_job(self.device.update_status) + self._update_availability(success) + if success: + for update_method in self._additional_update_methods: + await update_method() + else: + raise UpdateFailed(f"Failed to update Kumo device: {self.device.get_name()}") + + def _update_availability(self, success: bool) -> None: + if success: + self._available = True + self._unavailable_count = 0 + else: + self._unavailable_count += 1 + if self._unavailable_count >= MAX_AVAILABILITY_TRIES: + self._available = False diff --git a/custom_components/kumo/entity.py b/custom_components/kumo/entity.py new file mode 100644 index 0000000..58b57da --- /dev/null +++ b/custom_components/kumo/entity.py @@ -0,0 +1,49 @@ +"""Entities for The Internet Printing Protocol (IPP) integration.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import KumoDataUpdateCoordinator + + +class CoordinatedKumoEntitty(CoordinatorEntity): + """Defines a base Kumo entity.""" + + def __init__( + self, + coordinator: KumoDataUpdateCoordinator + ) -> None: + """Initialize the Kumo entity.""" + super().__init__(coordinator) + self._coordinator = coordinator + self._pykumo = coordinator.get_device() + self._identifier = self._pykumo.get_serial() + + @property + def device_info(self) -> DeviceInfo | None: + """Return device information about this IPP device.""" + if self._identifier is None: + return None + + return DeviceInfo( + identifiers={(DOMAIN, self._identifier)}, + manufacturer="Mitsubishi", + name=self._pykumo.get_name(), + ) + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def available(self): + """Return whether Home Assistant is able to read the state and control the underlying device.""" + return self._coordinator.get_available() + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._name diff --git a/custom_components/kumo/manifest.json b/custom_components/kumo/manifest.json index 80ffa1b..0d38722 100755 --- a/custom_components/kumo/manifest.json +++ b/custom_components/kumo/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://github.com/dlarrick/hass-kumo", "dependencies": [], "codeowners": [ "@dlarrick" ], - "requirements": ["pykumo==0.1.12"], + "requirements": ["pykumo==0.2.1"], "version": "0.2.7", "homeassistant": "0.96.0" } diff --git a/custom_components/kumo/sensor.py b/custom_components/kumo/sensor.py new file mode 100644 index 0000000..f6bbd8f --- /dev/null +++ b/custom_components/kumo/sensor.py @@ -0,0 +1,120 @@ +"""HomeAssistant sensor component for Kumo Station Device.""" +import logging + +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA + +from .const import DOMAIN, KUMO_DATA_COORDINATORS +from .coordinator import KumoDataUpdateCoordinator +from .entity import CoordinatedKumoEntitty + +try: + from homeassistant.components.sensor import SensorEntity +except ImportError: + from homeassistant.components.sensor import SensorDevice as SensorEntity + +import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import (DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + SIGNAL_STRENGTH_DECIBELS, TEMP_CELSIUS) +from homeassistant.helpers.typing import HomeAssistantType + +from . import KUMO_DATA + +_LOGGER = logging.getLogger(__name__) + +CONF_NAME = "name" +CONF_ADDRESS = "address" +CONF_CONFIG = "config" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_CONFIG): cv.string, + } +) + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities): + """Set up the Kumo thermostats.""" + account = hass.data[DOMAIN][entry.entry_id][KUMO_DATA].get_account() + coordinators = hass.data[DOMAIN][entry.entry_id][KUMO_DATA_COORDINATORS] + + entities = [] + all_serials = await hass.async_add_executor_job(account.get_all_units) + for serial in all_serials: + coordinator = coordinators[serial] + entities.append(KumoWifiSignal(coordinator)) + _LOGGER.debug("Adding entity: wifi_signal for %s", coordinator.get_device().get_name()) + + kumo_station_serials = await hass.async_add_executor_job(account.get_kumo_stations) + for serial in kumo_station_serials: + coordinator = coordinators[serial] + entities.append(KumoStationOutdoorTemperature(coordinator)) + _LOGGER.debug("Adding entity: outdoor_temperature for %s", coordinator.get_device().get_name()) + + if entities: + async_add_entities(entities, True) + +class KumoStationOutdoorTemperature(CoordinatedKumoEntitty, SensorEntity): + """Representation of a Kumo Station Outdoor Temperature Sensor.""" + + def __init__(self, coordinator: KumoDataUpdateCoordinator): + """Initialize the kumo station.""" + super().__init__(coordinator) + self._name = self._pykumo.get_name() + " Outdoor Temperature" + + @property + def unique_id(self): + """Return unique id""" + return f"{self._identifier}-outdoor-temperature" + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def native_value(self): + """Return the high dual setpoint temperature.""" + return self._pykumo.get_outdoor_temperature() + + @property + def device_class(self): + return DEVICE_CLASS_TEMPERATURE + # return SensorDeviceClass.TEMPERATURE # Not yet available + +class KumoWifiSignal(CoordinatedKumoEntitty, SensorEntity): + """Representation of a Kumo's WiFi Signal Strength.""" + + def __init__(self, coordinator: KumoDataUpdateCoordinator): + """Initialize the kumo station.""" + super().__init__(coordinator) + self._name = self._pykumo.get_name() + " Signal Strength" + + @property + def unique_id(self): + """Return unique id""" + return f"{self._identifier}-signal-strength" + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement which this thermostat uses.""" + return SIGNAL_STRENGTH_DECIBELS + + @property + def native_value(self): + """Return the WiFi signal rssi.""" + return self._pykumo.get_wifi_rssi() + + @property + def device_class(self): + return DEVICE_CLASS_SIGNAL_STRENGTH + # return SensorDeviceClass.SIGNAL_STRENGTH # Not yet available + + @property + def entity_registry_enabled_default(self) -> bool: + """Disable entity by default.""" + return False + diff --git a/custom_components/kumo/strings.json b/custom_components/kumo/strings.json index d21fb9e..739027c 100755 --- a/custom_components/kumo/strings.json +++ b/custom_components/kumo/strings.json @@ -1,50 +1,51 @@ { "title": "Kumo", "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "prefer_cache": "Prefer Cached JSON", - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, - "request_ips": {"title": "IP Assignment", "description": "Kumo failed to return an IP for the following units:"} - } -}, -"options": { - "step": { - "init": { - "title": "Configure Kumo", - "description": "What would you like to edit?", - "data": { - "edit_select": "Edit" - } + "error": { + "cannot_connect": "Cannot Connect to KumoCloud, check internet connection", + "invalid_auth": "Invalid Credentials, Wrong user or password", + "unknown": "[%key:common::config_flow::error::unknown%]" }, - "timeout_settings": { - "data": { - "connect_timeout": "Connect Timeout", - "response_timeout": "Response Timeout", + "step": { + "user": { + "data": { + "username": "KumoCloud Username (Email Address)", + "password": "KumoCloud Password", + "prefer_cache": "Prefer Local Cache" + } + }, + "request_ips": { + "title": "IP Assignment", + "description": "Kumo failed to return an IP address. For the following units, please replace the text with their local IP addresses" } - }, - "unit_select": { - "title": "You can set the local IP of your unit in the cache file here", - "description": "You must reload the integration after setting IP addresses", - "data": { - "unit_label": "Unit Name", - "ip_address": "IP Address" - + } + }, + "options": { + "step": { + "init": { + "title": "Configure Kumo", + "description": "What would you like to edit?", + "data": { + "edit_select": "Edit" + } + }, + "timeout_settings": { + "data": { + "connect_timeout": "Connection Timout", + "response_timeout": "Response Timeout" + } + }, + "unit_select": { + "title": "You can set the local IP of your unit in the cache file here", + "description": "You must reload the integration after setting IP addresses", + "data": { + "unit_label": "Unit Label", + "ip_address": "IP Address" + } } } } -} -} +} \ No newline at end of file diff --git a/custom_components/kumo/translations/en.json b/custom_components/kumo/translations/en.json index b3dfd8b..739027c 100755 --- a/custom_components/kumo/translations/en.json +++ b/custom_components/kumo/translations/en.json @@ -1,4 +1,5 @@ { + "title": "Kumo", "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" @@ -46,6 +47,5 @@ } } } - }, - "title": "Kumo" -} + } +} \ No newline at end of file