├── .gitignore ├── hacs.json ├── custom_components └── electrasmart │ ├── __init__.py │ ├── manifest.json │ └── climate.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .pytest_cache/ 2 | **/.venv/ 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ElectraSmart", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /custom_components/electrasmart/__init__.py: -------------------------------------------------------------------------------- 1 | """Electra-Smart AC custom climate component for Home-Assistant 2 | """ 3 | -------------------------------------------------------------------------------- /custom_components/electrasmart/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "electrasmart", 3 | "name": "Electra-Smart AC", 4 | "documentation": "https://pypi.org/project/electrasmart/#hass_component", 5 | "dependencies": [], 6 | "codeowners": ["@yonatanp"], 7 | "requirements": ["electrasmart~=0.7.0"], 8 | "version": "2022.4.25" 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Electra Smart Custom Component 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 3 | 4 | 5 | ## Description 6 | HomeAssistant custom component to support Electra Smart air conditioners. 7 | 8 | This is based on the [`electrasmart`](https://github.com/yonatanp/electrasmart) python library. Both are still in beta stage. Use at your own risk and please report back your results, preferably as issues. 9 | 10 | + Supports [HACS](https://github.com/custom-components/hacs) installation 11 | 12 | 13 | ## Installation 14 | Install through [HACS](https://hacs.xyz/): 15 | 16 | 1. Go to HACS -> Settings. 17 | 1. Enter "https://github.com/yonatanp/electrasmart-custom-component" for _ADD CUSTOM REPOSITORY_ and choose "Integration" for _Category_. 18 | 1. Click Save. 19 | 20 | Or, install manually by downloading the `custom_components/electrasmart` folder from this repo and placing it in your `config/custom_components/` folder. If the `custom_components` folder does not exist, create it empty first. If done correctly, the file `config/custom_components/electrasmart/manifest.json` should exist. 21 | 22 | 23 | ## IMEI and Token 24 | Before you configure the integration, you will need to get auth credentials (IMEI + token) and discover your AC device IDs that will later be used by the HomeAssistant configuration. 25 | 26 | Install the client library with e.g. `pip install electrasmart` on any machine. 27 | 28 | Then, run `electrasmart-auth`, and provide a phone number that has been pre-authorized in the official ElectraSmart mobile app. 29 | You will be requested to provide an OTP sent via SMS. 30 | Once this is complete, you will be provided with two strings: `imei` and `token`. Write them down for later. 31 | 32 | Next, run `electrasmart-list-devices ` to get a list of your devices. Pick the right ID of the AC unit you want to manage in HomeAssistant. Write it down for later. 33 | 34 | 35 | ## Configuration 36 | Add configuration to the `configuration.yaml` such as the following: 37 | 38 | ```yaml 39 | ... 40 | climate: 41 | - platform: electrasmart 42 | imei: "2b9500000..." 43 | token: "1fd4a2e86..." 44 | use_shared_sid: true #optional, default = false 45 | acs: 46 | - id: 12345 47 | name: MyLivingRoomAC 48 | ... 49 | ``` 50 | 51 | Note: if you want to configure multiple ACs under the same account, this is possible. Do it in the same way you configure multiple instances of the same type of platform in HomeAssistant (e.g. add another item under climate). The `imei` and `token` values should be the same for both ACs. 52 | 53 | ### Modify default scan interval 54 | 55 | The current default scan interval is 60 seconds, to change this value, add a `scan_interval` parameter to the `configuration.yaml` such as the following: 56 | 57 | 58 | ```yaml 59 | ... 60 | climate: 61 | - platform: electrasmart 62 | scan_interval: 10 63 | ... 64 | ``` 65 | 66 | See more details on the [Scan Interval](https://www.home-assistant.io/docs/configuration/platform_options/#scan-interval) in the official docs. 67 | 68 | ## Troubleshooting 69 | 70 | It might not work out of the box just yet. It is recommended that you enable detailed logging by adding the following configuration to your `configuration.yaml` while debugging the setup process: 71 | 72 | ```yaml 73 | ... 74 | logger: 75 | default: warn 76 | logs: 77 | custom_components.electrasmart: debug 78 | ... 79 | ``` 80 | 81 | See more details on the [Logger integration](https://www.home-assistant.io/integrations/logger/) in the official docs. 82 | 83 | To get detailed logging from the Electra API, enable debug logging for the `electrasmart` library: 84 | ```yaml 85 | ... 86 | logs: 87 | custom_components.electrasmart: debug 88 | electrasmart: debug 89 | ... 90 | ``` 91 | -------------------------------------------------------------------------------- /custom_components/electrasmart/climate.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from contextlib import contextmanager 4 | 5 | import homeassistant.helpers.config_validation as cv 6 | import voluptuous as vol 7 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 8 | from typing import Any, Callable, Dict, Optional 9 | 10 | from homeassistant.core import HomeAssistant 11 | 12 | from homeassistant.const import ( 13 | ATTR_TEMPERATURE, 14 | CONF_PASSWORD, 15 | CONF_USERNAME, 16 | ) 17 | from homeassistant.components.climate import ( 18 | ClimateEntity, 19 | ClimateEntityFeature, 20 | HVACAction, 21 | HVACMode, 22 | UnitOfTemperature, 23 | PLATFORM_SCHEMA, 24 | ) 25 | 26 | from homeassistant.helpers.typing import ( 27 | ConfigType, 28 | DiscoveryInfoType 29 | ) 30 | 31 | 32 | from homeassistant.components.climate.const import ( 33 | FAN_OFF, 34 | FAN_AUTO, 35 | FAN_LOW, 36 | FAN_MEDIUM, 37 | FAN_HIGH, 38 | ) 39 | 40 | from electrasmart import AC, ElectraAPI 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | 45 | CONF_IMEI = "imei" 46 | CONF_TOKEN = "token" 47 | CONF_ACS = "acs" 48 | CONF_AC_ID = "id" 49 | CONF_AC_NAME = "name" 50 | CONF_USE_SHARED_SID = "use_shared_sid" 51 | 52 | DEFAULT_NAME = "ElectraSmart" 53 | 54 | AC_SCHEMA = vol.Schema( 55 | { 56 | vol.Required(CONF_AC_ID): cv.string, 57 | vol.Required(CONF_AC_NAME, default=DEFAULT_NAME): cv.string, 58 | } 59 | ) 60 | 61 | 62 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 63 | { 64 | vol.Required(CONF_IMEI): cv.string, 65 | vol.Required(CONF_TOKEN): cv.string, 66 | vol.Optional(CONF_USE_SHARED_SID, default=False): cv.boolean, 67 | vol.Required(CONF_ACS): vol.All(cv.ensure_list, [AC_SCHEMA]), 68 | # TODO: add presets (cool, fan, night...) 69 | # vol.Optional( 70 | # CONF_AWAY_TEMPERATURE, default=DEFAULT_AWAY_TEMPERATURE 71 | # ): vol.Coerce(float), 72 | # vol.Optional( 73 | # CONF_SAVING_TEMPERATURE, default=DEFAULT_SAVING_TEMPERATURE 74 | # ): vol.Coerce(float), 75 | # vol.Optional( 76 | # CONF_COMFORT_TEMPERATURE, default=DEFAULT_COMFORT_TEMPERATURE 77 | # ): vol.Coerce(float), 78 | } 79 | ) 80 | 81 | 82 | async def async_setup_platform( 83 | hass: HomeAssistant, 84 | config: ConfigType, 85 | async_add_entities: Callable, 86 | discovery_info: Optional[DiscoveryInfoType] = None, 87 | ) -> None: 88 | # Note: since this is a global thing, if at least one entity activates it, it's on 89 | """Set up the ElectraSmartClimate platform.""" 90 | _LOGGER.debug("Setting up the ElectraSmart climate platform") 91 | session = async_get_clientsession(hass) 92 | imei = config.get(CONF_IMEI) 93 | token = config.get(CONF_TOKEN) 94 | use_shared_sid = config.get(CONF_USE_SHARED_SID) 95 | acs = [ 96 | ElectraSmartClimate(ac, imei, token, use_shared_sid) 97 | for ac in config.get(CONF_ACS) 98 | ] 99 | 100 | async_add_entities(acs, update_before_add=True) 101 | 102 | 103 | class ElectraSmartClimate(ClimateEntity): 104 | def __init__(self, ac, imei, token, use_shared_sid): 105 | self._enable_turn_on_off_backwards_compatibility = False 106 | """Initialize the thermostat.""" 107 | self._name = ac[CONF_AC_NAME] 108 | self.ac = AC(imei, token, ac[CONF_AC_ID], None, use_shared_sid) 109 | 110 | # managed properties 111 | 112 | @property 113 | def name(self): 114 | return self._name 115 | 116 | @property 117 | def unique_id(self) -> str: 118 | """Return the unique ID for this thermostat.""" 119 | return "_".join([self._name, "climate"]) 120 | 121 | @property 122 | def should_poll(self): 123 | """Return if polling is required.""" 124 | return True 125 | 126 | @property 127 | def min_temp(self): 128 | """Return the minimum temperature.""" 129 | return 16 130 | 131 | @property 132 | def max_temp(self): 133 | """Return the maximum temperature.""" 134 | return 30 135 | 136 | @property 137 | def temperature_unit(self): 138 | """Return the unit of measurement.""" 139 | return UnitOfTemperature.CELSIUS 140 | 141 | @property 142 | def current_temperature(self): 143 | """Return the current temperature.""" 144 | if self.ac.status is None: 145 | _LOGGER.debug(f"current_temperature: status is None, returning None") 146 | return None 147 | value = self.ac.status.current_temp 148 | if value is not None: 149 | value = int(value) 150 | _LOGGER.debug(f"value of current_temperature property: {value}") 151 | return value 152 | 153 | @property 154 | def target_temperature(self): 155 | """Return the temperature we try to reach.""" 156 | if self.ac.status is None: 157 | _LOGGER.debug(f"target_temperature: status is None, returning None") 158 | return None 159 | value = self.ac.status.spt 160 | if value is not None: 161 | value = int(value) 162 | _LOGGER.debug(f"value of target_temperature property: {value}") 163 | return value 164 | 165 | @property 166 | def target_temperature_low(self): 167 | return self.target_temperature() 168 | 169 | @property 170 | def target_temperature_high(self): 171 | return self.target_temperature() 172 | 173 | @property 174 | def target_temperature_step(self): 175 | return 1 176 | 177 | MODE_BY_NAME = {"IDLE": HVACAction.IDLE} 178 | 179 | HVAC_MODE_MAPPING = { 180 | "STBY": HVACMode.OFF, 181 | "COOL": HVACMode.COOL, 182 | "FAN": HVACMode.FAN_ONLY, 183 | "DRY": HVACMode.DRY, 184 | "HEAT": HVACMode.HEAT, 185 | "AUTO": HVACMode.HEAT_COOL, 186 | } 187 | 188 | HVAC_MODE_MAPPING_INV = {v: k for k, v in HVAC_MODE_MAPPING.items()} 189 | 190 | @property 191 | def hvac_mode(self): 192 | """Return hvac operation ie. heat, cool mode.""" 193 | if self.ac.status is None: 194 | _LOGGER.debug(f"hvac_mode: status is None, returning None") 195 | return None 196 | if self.ac.status.is_on: 197 | ac_mode = self.ac.status.ac_mode 198 | value = self.HVAC_MODE_MAPPING[ac_mode] 199 | _LOGGER.debug(f"hvac_mode: returning {value} (derived from {ac_mode})") 200 | return value 201 | else: 202 | _LOGGER.debug(f"hvac_mode: returning HVACMode.OFF - device is off") 203 | return HVACMode.OFF 204 | 205 | @property 206 | def hvac_modes(self): 207 | """HVAC modes.""" 208 | return [ 209 | HVACMode.OFF, 210 | HVACMode.COOL, 211 | HVACMode.FAN_ONLY, 212 | HVACMode.DRY, 213 | HVACMode.HEAT, 214 | HVACMode.HEAT_COOL, 215 | ] 216 | 217 | # TODO:! 218 | # @property 219 | # def hvac_action(self): 220 | # """Return the current running hvac operation.""" 221 | # # if self._target_temperature < self._current_temperature: 222 | # # return HVACAction.IDLE 223 | # # return HVACAction.HEAT 224 | # return HVACAction.IDLE 225 | 226 | FAN_MODE_MAPPING = { 227 | "LOW": FAN_LOW, 228 | "MED": FAN_MEDIUM, 229 | "HIGH": FAN_HIGH, 230 | "AUTO": FAN_AUTO, 231 | } 232 | 233 | FAN_MODE_MAPPING_INV = {v: k for k, v in FAN_MODE_MAPPING.items()} 234 | 235 | @property 236 | def fan_mode(self): 237 | """Returns the current fan mode (low, high, auto etc)""" 238 | if self.ac.status is None: 239 | _LOGGER.debug(f"fan_mode: status is None, returning None") 240 | return None 241 | if self.ac.status.is_on: 242 | fan_speed = self.ac.status.fan_speed 243 | value = self.FAN_MODE_MAPPING[fan_speed] 244 | _LOGGER.debug(f"fan_mode: returning {value} (derived from {fan_speed})") 245 | return value 246 | else: 247 | _LOGGER.debug(f"fan_mode: returning FAN_OFF - device is off") 248 | return FAN_OFF 249 | 250 | @property 251 | def fan_modes(self): 252 | """Fan modes.""" 253 | return [FAN_OFF, FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] 254 | 255 | @property 256 | def supported_features(self): 257 | """Return the list of supported features.""" 258 | return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF 259 | 260 | # actions 261 | 262 | def set_temperature(self, **kwargs): 263 | """Set new target temperature.""" 264 | temperature = kwargs.get(ATTR_TEMPERATURE) 265 | _LOGGER.debug(f"setting new temperature to {temperature}") 266 | if temperature is None: 267 | return 268 | temperature = int(temperature) 269 | with self._act_and_update(): 270 | self.ac.modify_oper(temperature=temperature) 271 | _LOGGER.debug(f"new temperature was set to {temperature}") 272 | 273 | def set_hvac_mode(self, hvac_mode): 274 | _LOGGER.debug(f"setting hvac mode to {hvac_mode}") 275 | if hvac_mode == HVACMode.OFF: 276 | _LOGGER.debug(f"turning off ac due to hvac_mode being set to {hvac_mode}") 277 | with self._act_and_update(): 278 | self.ac.turn_off() 279 | _LOGGER.debug( 280 | f"ac has been turned off due hvac_mode being set to {hvac_mode}" 281 | ) 282 | else: 283 | ac_mode = self.HVAC_MODE_MAPPING_INV[hvac_mode] 284 | _LOGGER.debug(f"setting hvac mode to {hvac_mode} (ac_mode {ac_mode})") 285 | with self._act_and_update(): 286 | self.ac.modify_oper(ac_mode=ac_mode) 287 | _LOGGER.debug(f"hvac mode was set to {hvac_mode} (ac_mode {ac_mode})") 288 | 289 | def set_fan_mode(self, fan_mode): 290 | _LOGGER.debug(f"setting fan mode to {fan_mode}") 291 | fan_speed = self.FAN_MODE_MAPPING_INV[fan_mode] 292 | _LOGGER.debug(f"setting fan mode to {fan_mode} (fan_speed {fan_speed})") 293 | with self._act_and_update(): 294 | self.ac.modify_oper(fan_speed=fan_speed) 295 | _LOGGER.debug(f"fan mode was set to {fan_mode} (fan_speed {fan_speed})") 296 | 297 | @contextmanager 298 | def _act_and_update(self): 299 | yield 300 | time.sleep(3) 301 | self.update() 302 | time.sleep(1) 303 | self.update() 304 | 305 | # data fetch mechanism 306 | def update(self): 307 | """Get the latest data.""" 308 | _LOGGER.debug("Updating status using the client AC instance...") 309 | self.ac.update_status() 310 | _LOGGER.debug("Status updated using the client AC instance") 311 | --------------------------------------------------------------------------------