├── .gitignore ├── hacs.json ├── custom_components └── frigidaire │ ├── const.py │ ├── services.yaml │ ├── manifest.json │ ├── translations │ └── en.json │ ├── strings.json │ ├── __init__.py │ ├── config_flow.py │ ├── humidifier.py │ └── climate.py ├── .github └── workflows │ ├── hassfest.yml │ └── validate.yml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | .idea/ 4 | *.iml 5 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Frigidaire", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "homeassistant": "0.96.0" 6 | } 7 | -------------------------------------------------------------------------------- /custom_components/frigidaire/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the frigidaire integration.""" 2 | 3 | DOMAIN = "frigidaire" 4 | PLATFORMS = ["climate", "humidifier"] 5 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - uses: "home-assistant/actions/hassfest@master" 16 | -------------------------------------------------------------------------------- /custom_components/frigidaire/services.yaml: -------------------------------------------------------------------------------- 1 | set_fan_mode: 2 | name: Set fan mode 3 | description: Set fan operation for frigidaire appliance. 4 | target: 5 | entity: 6 | integration: frigidaire 7 | fields: 8 | fan_mode: 9 | name: Fan mode 10 | description: New value of fan mode. 11 | required: true 12 | example: "low" 13 | selector: 14 | text: -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /custom_components/frigidaire/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "frigidaire", 3 | "name": "frigidaire", 4 | "codeowners": [ 5 | "@bm1549" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/bm1549/home-assistant-frigidaire", 10 | "iot_class": "cloud_polling", 11 | "issue_tracker": "https://github.com/bm1549/home-assistant-frigidaire/issues", 12 | "requirements": [ 13 | "frigidaire==0.18.28" 14 | ], 15 | "version": "0.1.0" 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/frigidaire/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "password": "Password", 15 | "username": "Username" 16 | } 17 | } 18 | } 19 | }, 20 | "title": "frigidaire" 21 | } -------------------------------------------------------------------------------- /custom_components/frigidaire/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "[%key:common::config_flow::data::username%]", 7 | "password": "[%key:common::config_flow::data::password%]" 8 | } 9 | } 10 | }, 11 | "error": { 12 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 13 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 14 | "unknown": "[%key:common::config_flow::error::unknown%]" 15 | }, 16 | "abort": { 17 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Brian Marks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/github/release/bm1549/home-assistant-frigidaire/all.svg?style=for-the-badge)](https://github.com/bm1549/home-assistant-frigidaire/releases) 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 3 | [![](https://img.shields.io/github/license/bm1549/home-assistant-frigidaire?style=for-the-badge)](LICENSE) 4 | [![](https://img.shields.io/badge/MAINTAINER-%40bm154969-red?style=for-the-badge)](https://github.com/bm1549) 5 | [![](https://img.shields.io/badge/COMMUNITY-FORUM-success?style=for-the-badge)](https://community.home-assistant.io) 6 | 7 | # Home Assistant Custom Component for Frigidaire 8 | 9 | ## Installing 10 | 1. Clone this repo. 11 | 2. Copy the contents of `custom_components/` to your custom components folder (e.g. `/config/custom_components/`). 12 | 3. Go to Configuration and restart HA 13 | 4. Go to Configuration > Integrations and add the Frigidaire integration (should be in the list now) 14 | 5. It should prompt you for your username (email) and password. Fill those in and hit Submit 15 | 6. It should say it successfully added N number of climate entities. 16 | 17 | ## HACS Integration 18 | Pending merge of https://github.com/hacs/default/pull/2739 19 | -------------------------------------------------------------------------------- /custom_components/frigidaire/__init__.py: -------------------------------------------------------------------------------- 1 | """The frigidaire integration.""" 2 | from __future__ import annotations 3 | 4 | import os 5 | import traceback 6 | 7 | import frigidaire 8 | 9 | from homeassistant import data_entry_flow 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.exceptions import ConfigEntryNotReady 13 | 14 | from .config_flow import load_auth, save_auth, AUTH_FILE 15 | from .const import DOMAIN, PLATFORMS 16 | 17 | 18 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 19 | """Set up frigidaire from a config entry.""" 20 | hass.data.setdefault(DOMAIN, {}) 21 | 22 | def setup(username: str, password: str) -> None: 23 | auth_path: str = os.path.join(hass.config.path(), AUTH_FILE) 24 | 25 | try: 26 | session_key, regional_base_url = load_auth(auth_path) 27 | client = frigidaire.Frigidaire( 28 | username=username, 29 | password=password, 30 | timeout=60, 31 | session_key=session_key, 32 | regional_base_url=regional_base_url 33 | ) 34 | save_auth(auth_path, client.session_key, client.regional_base_url) 35 | 36 | hass.data[DOMAIN][entry.entry_id] = client 37 | except ConnectionError as err: 38 | raise ConfigEntryNotReady("Cannot connect to Frigidaire") from err 39 | except frigidaire.FrigidaireException as err: 40 | # Handle frigidaire 429 gracefully 41 | if "cas_3403" in traceback.format_exc(): 42 | raise data_entry_flow.AbortFlow("You have exceeded Frigidaire's maximum number of active sessions. Please log out of another device or wait until an existing session expires.") from err 43 | raise data_entry_flow.AbortFlow("Frigidaire backend exception") from err 44 | 45 | await hass.async_add_executor_job( 46 | setup, entry.data["username"], entry.data["password"] 47 | ) 48 | 49 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 50 | 51 | return True 52 | 53 | 54 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 55 | """Unload a config entry.""" 56 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 57 | if unload_ok: 58 | hass.data[DOMAIN].pop(entry.entry_id) 59 | 60 | return unload_ok 61 | -------------------------------------------------------------------------------- /custom_components/frigidaire/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for frigidaire integration.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | import logging 6 | import os 7 | from typing import Any, Optional 8 | 9 | import frigidaire 10 | import voluptuous as vol 11 | 12 | from homeassistant import config_entries 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.data_entry_flow import FlowResult 15 | from homeassistant.exceptions import HomeAssistantError 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | STEP_USER_DATA_SCHEMA = vol.Schema({"username": str, "password": str}) 22 | 23 | AUTH_FILE = 'frigidaire.json' 24 | 25 | 26 | def load_auth(auth_path: str) -> tuple[Optional[str], Optional[str]]: 27 | if not os.path.exists(auth_path): 28 | with open(auth_path, 'w'): 29 | pass 30 | 31 | if os.path.getsize(auth_path) > 0: 32 | with open(auth_path, 'r') as f: 33 | obj: dict = json.loads(f.read()) 34 | return obj.get('session_key'), obj.get('regional_base_url') 35 | return None, None 36 | 37 | 38 | def save_auth(auth_path: str, session_key: str, regional_base_url: str) -> None: 39 | with open(auth_path, 'w') as f: 40 | json.dump({'session_key': session_key, 'regional_base_url': regional_base_url}, f, ensure_ascii=False, indent=4) 41 | 42 | 43 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]): 44 | """Validate the user input allows us to connect. 45 | 46 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 47 | """ 48 | 49 | def setup(username: str, password: str) -> list[frigidaire.Appliance]: 50 | auth_path = os.path.join(hass.config.path(), AUTH_FILE) 51 | 52 | try: 53 | session_key, regional_base_url = load_auth(auth_path) 54 | client = frigidaire.Frigidaire( 55 | username=username, 56 | password=password, 57 | timeout=60, 58 | session_key=session_key, 59 | regional_base_url=regional_base_url 60 | ) 61 | save_auth(auth_path, client.session_key, client.regional_base_url) 62 | 63 | return client.get_appliances() 64 | except frigidaire.FrigidaireException as err: 65 | if "Failed to authenticate" in str(err): 66 | raise InvalidAuth from err 67 | 68 | raise CannotConnect from err 69 | 70 | appliances = await hass.async_add_executor_job( 71 | setup, data["username"], data["password"] 72 | ) 73 | 74 | if len(appliances) == 0: 75 | raise NoAppliances 76 | 77 | # Validation Succeeded 78 | return True 79 | 80 | 81 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 82 | """Handle a config flow for frigidaire.""" 83 | 84 | VERSION = 1 85 | 86 | async def async_step_user( 87 | self, user_input: dict[str, Any] | None = None 88 | ) -> FlowResult: 89 | """Handle the initial step.""" 90 | if user_input is None: 91 | return self.async_show_form( 92 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA 93 | ) 94 | 95 | errors = {} 96 | 97 | try: 98 | await validate_input(self.hass, user_input) 99 | except CannotConnect: 100 | errors["base"] = "cannot_connect" 101 | except InvalidAuth: 102 | errors["base"] = "invalid_auth" 103 | except NoAppliances: 104 | errors["base"] = "no_appliances" 105 | except Exception: # pylint: disable=broad-except 106 | _LOGGER.exception("Unexpected exception") 107 | errors["base"] = "unknown" 108 | else: 109 | return self.async_create_entry(title=DOMAIN, data=user_input) 110 | 111 | return self.async_show_form( 112 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 113 | ) 114 | 115 | 116 | class NoAppliances(HomeAssistantError): 117 | """Error to indicate there are no appliances.""" 118 | 119 | 120 | class CannotConnect(HomeAssistantError): 121 | """Error to indicate we cannot connect.""" 122 | 123 | 124 | class InvalidAuth(HomeAssistantError): 125 | """Error to indicate there is invalid auth.""" 126 | -------------------------------------------------------------------------------- /custom_components/frigidaire/humidifier.py: -------------------------------------------------------------------------------- 1 | """ClimateEntity for frigidaire integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import List, Any, Mapping, Optional, Dict 6 | 7 | import frigidaire 8 | import voluptuous as vol 9 | 10 | from homeassistant.components.humidifier import HumidifierEntity, HumidifierDeviceClass 11 | from homeassistant.components.humidifier.const import ( 12 | MODE_BOOST, 13 | MODE_SLEEP, 14 | MODE_AUTO, 15 | MODE_NORMAL, 16 | HumidifierEntityFeature, 17 | ) 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.core import HomeAssistant 20 | from homeassistant.helpers import config_validation as cv, entity_platform 21 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 22 | 23 | from .const import DOMAIN 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | FAN_LOW = "low" 29 | FAN_MEDIUM = "medium" 30 | FAN_HIGH = "high" 31 | 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 35 | ) -> None: 36 | """Set up frigidaire from a config entry.""" 37 | platform = entity_platform.async_get_current_platform() 38 | platform.async_register_entity_service( 39 | "set_fan_mode", 40 | {vol.Required("fan_mode"): cv.string}, 41 | "set_fan_mode", 42 | ) 43 | 44 | client = hass.data[DOMAIN][entry.entry_id] 45 | 46 | def get_entities(username: str, password: str) -> List[frigidaire.Appliance]: 47 | return client.get_appliances() 48 | 49 | appliances = await hass.async_add_executor_job( 50 | get_entities, entry.data["username"], entry.data["password"] 51 | ) 52 | 53 | async_add_entities( 54 | [ 55 | FrigidaireDehumidifier(client, appliance) 56 | for appliance in appliances 57 | if appliance.destination == frigidaire.Destination.DEHUMIDIFIER 58 | ], 59 | update_before_add=True, 60 | ) 61 | 62 | 63 | FRIGIDAIRE_TO_HA_MODE = { 64 | frigidaire.Mode.DRY: MODE_NORMAL, 65 | frigidaire.Mode.CONTINUOUS: MODE_BOOST, 66 | frigidaire.Mode.QUIET: MODE_SLEEP, 67 | frigidaire.Mode.AUTO: MODE_AUTO, 68 | } 69 | 70 | HA_TO_FRIGIDAIRE_MODE = {v: k for k, v in FRIGIDAIRE_TO_HA_MODE.items()} 71 | 72 | FRIGIDAIRE_TO_HA_FAN_MODE = { 73 | frigidaire.FanSpeed.LOW: FAN_LOW, 74 | frigidaire.FanSpeed.MEDIUM: FAN_MEDIUM, 75 | frigidaire.FanSpeed.HIGH: FAN_HIGH, 76 | } 77 | 78 | HA_TO_FRIGIDAIRE_FAN_MODE = {v: k for k, v in FRIGIDAIRE_TO_HA_FAN_MODE.items()} 79 | 80 | 81 | class FrigidaireDehumidifier(HumidifierEntity): 82 | """Representation of a Frigidaire dehumidifier.""" 83 | 84 | def __init__(self, client, appliance): 85 | """Build FrigidaireClimate. 86 | 87 | client: the client used to contact the frigidaire API 88 | appliance: the basic information about the frigidaire appliance, used to contact 89 | the API 90 | """ 91 | 92 | self._client: frigidaire.Frigidaire = client 93 | self._appliance: frigidaire.Appliance = appliance 94 | self._details: Optional[Dict] = None 95 | 96 | # Entity Class Attributes 97 | self._attr_unique_id = self._appliance.appliance_id 98 | self._attr_name = self._appliance.nickname 99 | self._attr_supported_features = HumidifierEntityFeature.MODES 100 | 101 | # Although we can access the Frigidaire API to get updates, they are 102 | # not reflected immediately after making a request. To improve the UX 103 | # around this, we set assume_state to True 104 | self._attr_assumed_state = True 105 | 106 | # self._attr_fan_modes = [ 107 | # FAN_LOW, 108 | # FAN_HIGH, 109 | # ] 110 | 111 | self._attr_modes = [ 112 | MODE_NORMAL, 113 | MODE_BOOST, 114 | MODE_AUTO, 115 | MODE_SLEEP, 116 | ] 117 | 118 | @property 119 | def assumed_state(self): 120 | """Return True if unable to access real state of the entity.""" 121 | return self._attr_assumed_state 122 | 123 | @property 124 | def unique_id(self): 125 | """Return unique ID based on Frigidaire ID.""" 126 | return self._attr_unique_id 127 | 128 | @property 129 | def name(self): 130 | """Return the name of the entity.""" 131 | return self._attr_name 132 | 133 | @property 134 | def device_class(self): 135 | return HumidifierDeviceClass.DEHUMIDIFIER 136 | 137 | @property 138 | def is_on(self): 139 | return self._details.get(frigidaire.Detail.APPLIANCE_STATE) == frigidaire.ApplianceState.RUNNING 140 | 141 | @property 142 | def supported_features(self): 143 | """Return the list of supported features.""" 144 | return self._attr_supported_features 145 | 146 | @property 147 | def available_modes(self): 148 | """List of available operation modes.""" 149 | return self._attr_modes 150 | 151 | @property 152 | def target_humidity(self): 153 | """Return the humidity we try to reach.""" 154 | return self._details.get(frigidaire.Detail.TARGET_HUMIDITY) 155 | 156 | @property 157 | def mode(self): 158 | """Return current operation i.e. dry, continuous.""" 159 | frigidaire_mode = self._details.get(frigidaire.Detail.MODE) 160 | 161 | if frigidaire_mode == frigidaire.Mode.OFF: 162 | return MODE_NORMAL 163 | 164 | return FRIGIDAIRE_TO_HA_MODE[frigidaire_mode] 165 | 166 | @property 167 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 168 | """Add extra state attributes specific to Frigidaire dehumidifiers""" 169 | fan_speed = self._details.get(frigidaire.Detail.FAN_SPEED) 170 | 171 | attrib = { 172 | "current_humidity": self._details.get(frigidaire.Detail.SENSOR_HUMIDITY), 173 | "check_filter": bool( 174 | self._details.get(frigidaire.Detail.FILTER_STATE) != frigidaire.FilterState.GOOD 175 | ), 176 | "fan_mode": FRIGIDAIRE_TO_HA_FAN_MODE[fan_speed], 177 | } 178 | 179 | # The following attributes only exist on some models of dehumidifier 180 | bin_full = False 181 | alerts = self._details.get(frigidaire.Detail.ALERTS) 182 | if alerts is not None: 183 | # 1) Old approach 184 | if frigidaire.Alert.BUCKET_FULL in alerts: 185 | bin_full = True 186 | 187 | # 2) New approach 188 | if any(alert.get("code") == "BUCKET_FULL" for alert in alerts): 189 | bin_full = True 190 | 191 | # Fallback to waterBucketLevel if alert is not set 192 | if not bin_full: 193 | water_bucket_level = self._details.get(frigidaire.Detail.WATER_BUCKET_LEVEL) 194 | if water_bucket_level == 1: 195 | bin_full = True 196 | 197 | attrib["bin_full"] = bin_full 198 | 199 | return attrib 200 | 201 | @property 202 | def min_humidity(self): 203 | """Return the minimum humidity.""" 204 | return 35 205 | 206 | @property 207 | def max_humidity(self): 208 | """Return the maximum humidity.""" 209 | return 85 210 | 211 | def turn_on(self, **kwargs: Any) -> None: 212 | self._client.execute_action( 213 | self._appliance, frigidaire.Action.set_power(frigidaire.Power.ON) 214 | ) 215 | 216 | def turn_off(self, **kwargs: Any) -> None: 217 | self._client.execute_action( 218 | self._appliance, frigidaire.Action.set_power(frigidaire.Power.OFF) 219 | ) 220 | 221 | def set_humidity(self, humidity: int): 222 | """Set new target humidity.""" 223 | if humidity is None: 224 | return 225 | # Only supports 5% steps 226 | humidity = 5 * round(humidity / 5) 227 | # We have to be in dry mode to set a target humidity 228 | self.set_mode(MODE_NORMAL) 229 | self._client.execute_action( 230 | self._appliance, frigidaire.Action.set_humidity(humidity) 231 | ) 232 | 233 | def set_fan_mode(self, fan_mode): 234 | """Set new target fan mode.""" 235 | # Guard against unexpected fan modes 236 | if fan_mode not in HA_TO_FRIGIDAIRE_FAN_MODE: 237 | return 238 | 239 | action = frigidaire.Action.set_fan_speed(HA_TO_FRIGIDAIRE_FAN_MODE[fan_mode]) 240 | self._client.execute_action(self._appliance, action) 241 | 242 | def set_mode(self, mode): 243 | """Set new target operation mode.""" 244 | 245 | # Guard against unexpected modes 246 | if mode not in HA_TO_FRIGIDAIRE_MODE: 247 | return 248 | 249 | # Turn on if not currently on. 250 | if self._details.get(frigidaire.Detail.APPLIANCE_STATE) == frigidaire.ApplianceState.OFF: 251 | self.turn_on() 252 | 253 | self._client.execute_action( 254 | self._appliance, frigidaire.Action.set_mode(HA_TO_FRIGIDAIRE_MODE[mode]) 255 | ) 256 | 257 | def update(self): 258 | """Retrieve latest state and updates the details.""" 259 | try: 260 | details = self._client.get_appliance_details(self._appliance) 261 | self._details = details 262 | except frigidaire.FrigidaireException: 263 | if self.available: 264 | _LOGGER.error("Failed to connect to Frigidaire servers") 265 | self._attr_available = False 266 | else: 267 | self._attr_available = ( 268 | self._details.get("connectivityState") == "connected" 269 | ) 270 | -------------------------------------------------------------------------------- /custom_components/frigidaire/climate.py: -------------------------------------------------------------------------------- 1 | """ClimateEntity for frigidaire integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Optional, List, Mapping, Any, Dict 6 | 7 | import frigidaire 8 | 9 | from homeassistant.components.climate import ClimateEntity 10 | from homeassistant.components.climate.const import ( 11 | FAN_AUTO, 12 | FAN_HIGH, 13 | FAN_LOW, 14 | FAN_MEDIUM, 15 | FAN_OFF, 16 | HVACMode, 17 | ClimateEntityFeature, 18 | ) 19 | from homeassistant.config_entries import ConfigEntry 20 | from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 21 | from homeassistant.core import HomeAssistant 22 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 23 | 24 | from .const import DOMAIN 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def async_setup_entry( 30 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 31 | ) -> None: 32 | """Set up frigidaire from a config entry.""" 33 | client = hass.data[DOMAIN][entry.entry_id] 34 | 35 | def get_entities(username: str, password: str) -> List[frigidaire.Appliance]: 36 | return client.get_appliances() 37 | 38 | appliances = await hass.async_add_executor_job( 39 | get_entities, entry.data["username"], entry.data["password"] 40 | ) 41 | 42 | async_add_entities( 43 | [ 44 | FrigidaireClimate(client, appliance) 45 | for appliance in appliances 46 | if appliance.destination == frigidaire.Destination.AIR_CONDITIONER 47 | ], 48 | update_before_add=True, 49 | ) 50 | 51 | 52 | FRIGIDAIRE_TO_HA_UNIT = { 53 | frigidaire.Unit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, 54 | frigidaire.Unit.CELSIUS: UnitOfTemperature.CELSIUS, 55 | } 56 | 57 | FRIGIDAIRE_TO_HA_MODE = { 58 | frigidaire.Mode.OFF: HVACMode.OFF, 59 | frigidaire.Mode.COOL: HVACMode.COOL, 60 | frigidaire.Mode.FAN: HVACMode.FAN_ONLY, 61 | frigidaire.Mode.ECO: HVACMode.AUTO, 62 | } 63 | 64 | FRIGIDAIRE_TO_HA_FAN_SPEED = { 65 | frigidaire.FanSpeed.AUTO: FAN_AUTO, 66 | frigidaire.FanSpeed.LOW: FAN_LOW, 67 | frigidaire.FanSpeed.MEDIUM: FAN_MEDIUM, 68 | frigidaire.FanSpeed.HIGH: FAN_HIGH, 69 | } 70 | 71 | HA_TO_FRIGIDAIRE_UNIT = { 72 | UnitOfTemperature.FAHRENHEIT: frigidaire.Unit.FAHRENHEIT, 73 | UnitOfTemperature.CELSIUS: frigidaire.Unit.CELSIUS, 74 | } 75 | 76 | HA_TO_FRIGIDAIRE_FAN_MODE = { 77 | FAN_AUTO: frigidaire.FanSpeed.AUTO, 78 | FAN_LOW: frigidaire.FanSpeed.LOW, 79 | FAN_MEDIUM: frigidaire.FanSpeed.MEDIUM, 80 | FAN_HIGH: frigidaire.FanSpeed.HIGH, 81 | } 82 | 83 | HA_TO_FRIGIDAIRE_HVAC_MODE = { 84 | HVACMode.AUTO: frigidaire.Mode.ECO, 85 | HVACMode.FAN_ONLY: frigidaire.Mode.FAN, 86 | HVACMode.COOL: frigidaire.Mode.COOL, 87 | HVACMode.OFF: frigidaire.Mode.OFF, 88 | } 89 | 90 | 91 | class FrigidaireClimate(ClimateEntity): 92 | """Representation of a Frigidaire appliance.""" 93 | 94 | def __init__(self, client, appliance): 95 | """Build FrigidaireClimate. 96 | 97 | client: the client used to contact the frigidaire API 98 | appliance: the basic information about the frigidaire appliance, used to contact 99 | the API 100 | """ 101 | 102 | self._client: frigidaire.Frigidaire = client 103 | self._appliance: frigidaire.Appliance = appliance 104 | self._details: Optional[Dict] = None 105 | 106 | # Entity Class Attributes 107 | self._attr_unique_id = self._appliance.appliance_id 108 | self._attr_name = self._appliance.nickname 109 | self._attr_supported_features = (ClimateEntityFeature.TARGET_TEMPERATURE | 110 | ClimateEntityFeature.FAN_MODE | 111 | ClimateEntityFeature.TURN_OFF | 112 | ClimateEntityFeature.TURN_ON) 113 | self._attr_target_temperature_step = 1 114 | 115 | # Although we can access the Frigidaire API to get updates, they are 116 | # not reflected immediately after making a request. To improve the UX 117 | # around this, we set assume_state to True 118 | self._attr_assumed_state = True 119 | 120 | self._attr_fan_modes = [ 121 | FAN_AUTO, 122 | FAN_LOW, 123 | FAN_MEDIUM, 124 | FAN_HIGH, 125 | ] 126 | 127 | self._attr_hvac_modes = [ 128 | HVACMode.OFF, 129 | HVACMode.COOL, 130 | HVACMode.AUTO, 131 | HVACMode.FAN_ONLY, 132 | ] 133 | 134 | @property 135 | def assumed_state(self): 136 | """Return True if unable to access real state of the entity.""" 137 | return self._attr_assumed_state 138 | 139 | @property 140 | def unique_id(self): 141 | """Return unique ID based on Frigidaire ID.""" 142 | return self._attr_unique_id 143 | 144 | @property 145 | def name(self): 146 | """Return the name of the entity.""" 147 | return self._attr_name 148 | 149 | @property 150 | def supported_features(self): 151 | """Return the list of supported features.""" 152 | return self._attr_supported_features 153 | 154 | @property 155 | def hvac_modes(self): 156 | """List of available operation modes.""" 157 | return self._attr_hvac_modes 158 | 159 | @property 160 | def target_temperature_step(self): 161 | """Return the supported step of target temperature.""" 162 | return self._attr_target_temperature_step 163 | 164 | @property 165 | def fan_modes(self): 166 | """List of available fan modes.""" 167 | return self._attr_fan_modes 168 | 169 | @property 170 | def temperature_unit(self): 171 | """Return the unit of measurement which this thermostat uses.""" 172 | unit = self._details.get( 173 | frigidaire.Detail.TEMPERATURE_REPRESENTATION 174 | ) 175 | 176 | return FRIGIDAIRE_TO_HA_UNIT[unit] 177 | 178 | @property 179 | def target_temperature(self): 180 | """Return the temperature we try to reach.""" 181 | if self.temperature_unit == UnitOfTemperature.FAHRENHEIT: 182 | return self._details.get(frigidaire.Detail.TARGET_TEMPERATURE_F) 183 | else: 184 | return self._details.get(frigidaire.Detail.TARGET_TEMPERATURE_C) 185 | 186 | @property 187 | def hvac_mode(self): 188 | """Return current operation i.e. heat, cool, idle.""" 189 | frigidaire_mode = self._details.get(frigidaire.Detail.MODE) 190 | 191 | return FRIGIDAIRE_TO_HA_MODE[frigidaire_mode] 192 | 193 | @property 194 | def current_temperature(self): 195 | """Return the current temperature.""" 196 | if self.temperature_unit == UnitOfTemperature.FAHRENHEIT: 197 | return self._details.get(frigidaire.Detail.AMBIENT_TEMPERATURE_F) 198 | else: 199 | return self._details.get(frigidaire.Detail.AMBIENT_TEMPERATURE_C) 200 | 201 | @property 202 | def fan_mode(self): 203 | """Return the fan setting.""" 204 | fan_speed = self._details.get(frigidaire.Detail.FAN_SPEED) 205 | 206 | if not fan_speed: 207 | return FAN_OFF 208 | 209 | return FRIGIDAIRE_TO_HA_FAN_SPEED[fan_speed] 210 | 211 | @property 212 | def min_temp(self): 213 | """Return the minimum temperature.""" 214 | if self.temperature_unit == UnitOfTemperature.FAHRENHEIT: 215 | return 60 216 | 217 | return 16 218 | 219 | @property 220 | def max_temp(self): 221 | """Return the maximum temperature.""" 222 | if self.temperature_unit == UnitOfTemperature.FAHRENHEIT: 223 | return 90 224 | 225 | return 32 226 | 227 | @property 228 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 229 | return { 230 | "check_filter": bool( 231 | self._details.get(frigidaire.Detail.FILTER_STATE) == "CHANGE" 232 | ), 233 | } 234 | 235 | def set_temperature(self, **kwargs): 236 | """Set new target temperature.""" 237 | temperature = kwargs.get(ATTR_TEMPERATURE) 238 | if temperature is None: 239 | return 240 | temperature = int(temperature) 241 | temperature_unit = HA_TO_FRIGIDAIRE_UNIT[self.temperature_unit] 242 | 243 | _LOGGER.debug("Setting temperature to int({}) {}".format(temperature, self.temperature_unit)) 244 | self._client.execute_action( 245 | self._appliance, frigidaire.Action.set_temperature(temperature, temperature_unit) 246 | ) 247 | 248 | def set_fan_mode(self, fan_mode): 249 | """Set new target fan mode.""" 250 | # Guard against unexpected fan modes 251 | if fan_mode not in HA_TO_FRIGIDAIRE_FAN_MODE: 252 | return 253 | 254 | action = frigidaire.Action.set_fan_speed(HA_TO_FRIGIDAIRE_FAN_MODE[fan_mode]) 255 | self._client.execute_action(self._appliance, action) 256 | 257 | def set_hvac_mode(self, hvac_mode): 258 | """Set new target operation mode.""" 259 | if hvac_mode == HVACMode.OFF: 260 | self._client.execute_action( 261 | self._appliance, frigidaire.Action.set_power(frigidaire.Power.OFF) 262 | ) 263 | return 264 | 265 | # Guard against unexpected hvac modes 266 | if hvac_mode not in HA_TO_FRIGIDAIRE_HVAC_MODE: 267 | return 268 | 269 | # Turn on if not currently on. 270 | if self._details.get(frigidaire.Detail.MODE) == frigidaire.Mode.OFF: 271 | self._client.execute_action( 272 | self._appliance, frigidaire.Action.set_power(frigidaire.Power.ON) 273 | ) 274 | 275 | # temperature reverts to default when the device is turned on 276 | self._client.execute_action( 277 | self._appliance, 278 | frigidaire.Action.set_temperature(int(self.target_temperature)) 279 | ) 280 | 281 | self._client.execute_action( 282 | self._appliance, 283 | frigidaire.Action.set_mode(HA_TO_FRIGIDAIRE_HVAC_MODE[hvac_mode]), 284 | ) 285 | 286 | def update(self): 287 | """Retrieve latest state and updates the details.""" 288 | try: 289 | details = self._client.get_appliance_details(self._appliance) 290 | self._details = details 291 | except frigidaire.FrigidaireException: 292 | if self.available: 293 | _LOGGER.error("Failed to connect to Frigidaire servers") 294 | self._attr_available = False 295 | else: 296 | self._attr_available = ( 297 | self._details.get("connectivityState") 298 | == "connected" 299 | ) 300 | --------------------------------------------------------------------------------