├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── release.yml │ └── validate.yaml ├── .gitignore ├── .releaserc ├── Dockerfile ├── LICENSE ├── README.md ├── custom_components ├── __init__.py └── winix │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── device_wrapper.py │ ├── driver.py │ ├── fan.py │ ├── helpers.py │ ├── manager.py │ ├── manifest.json │ ├── select.py │ ├── sensor.py │ ├── services.yaml │ ├── strings.json │ ├── switch.py │ └── translations │ └── en.json ├── hacs.json ├── images └── entity.png ├── requirements_component.txt └── tests ├── __init__.py ├── common.py ├── conftest.py ├── test_config_flow.py ├── test_device_wrapper.py ├── test_driver.py ├── test_fan.py ├── test_init.py └── test_sensor.py /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | #Limited configuration instead of default_config 2 | #https://github.com/home-assistant/core/tree/dev/homeassistant/components/default_config 3 | automation: 4 | frontend: 5 | history: 6 | logbook: 7 | 8 | homeassistant: 9 | name: Home 10 | country: US 11 | 12 | logger: 13 | default: warn 14 | logs: 15 | custom_components.winix: debug 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Home Assistant Custom Component Dev", 3 | "context": "..", 4 | "dockerFile": "../Dockerfile", 5 | "appPort": "9123:8123", 6 | "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && /workspaces/container_content/scripts/setup.sh", 7 | "containerEnv": { 8 | "TZ": "America/Chicago" 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "charliermarsh.ruff", 14 | "ms-python.pylint", 15 | "ms-python.vscode-pylance" 16 | ], 17 | "settings": { 18 | "python.pythonPath": "/usr/local/bin/python", 19 | "python.testing.pytestArgs": [ 20 | "--no-cov" 21 | ], 22 | "editor.formatOnPaste": false, 23 | "editor.formatOnSave": true, 24 | "editor.formatOnType": true, 25 | "files.trimTrailingWhitespace": true, 26 | "[python]": { 27 | "editor.defaultFormatter": "charliermarsh.ruff" 28 | } 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | config 3 | .devcontainer 4 | .vscode 5 | **/__pycache__ 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Ensure Docker script files uses LF to support Docker for Windows. 2 | # Ensure "git config --global core.autocrlf input" before you clone 3 | * text eol=lf 4 | *.py whitespace=error 5 | 6 | *.ico binary 7 | *.jpg binary 8 | *.png binary 9 | *.zip binary 10 | *.mp3 binary 11 | *.pcm binary 12 | 13 | Dockerfile.dev linguist-language=Dockerfile 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Gets semantic release info 12 | id: semantic_release_info 13 | uses: jossef/action-semantic-release-info@v2.1.0 14 | env: 15 | GITHUB_TOKEN: ${{ github.token }} 16 | 17 | - name: Update Version and Commit 18 | if: ${{steps.semantic_release_info.outputs.version != ''}} 19 | run: | 20 | echo "Version: ${{steps.semantic_release_info.outputs.version}}" 21 | sed -i "s/\"version\": \".*\"/\"version\": \"${{steps.semantic_release_info.outputs.version}}\"/g" custom_components/winix/manifest.json 22 | git config --local user.email "action@github.com" 23 | git config --local user.name "GitHub Action" 24 | git add -A 25 | git commit -m "chore: bumping version to ${{steps.semantic_release_info.outputs.version}}" 26 | git tag ${{ steps.semantic_release_info.outputs.git_tag }} 27 | 28 | - name: Push changes 29 | if: ${{steps.semantic_release_info.outputs.version != ''}} 30 | uses: ad-m/github-push-action@v0.8.0 31 | with: 32 | github_token: ${{ github.token }} 33 | tags: true 34 | 35 | - name: Create GitHub Release 36 | if: ${{steps.semantic_release_info.outputs.version != ''}} 37 | uses: actions/create-release@v1 38 | env: 39 | GITHUB_TOKEN: ${{ github.token }} 40 | with: 41 | tag_name: ${{ steps.semantic_release_info.outputs.git_tag }} 42 | release_name: ${{ steps.semantic_release_info.outputs.git_tag }} 43 | body: ${{ steps.semantic_release_info.outputs.notes }} 44 | draft: false 45 | prerelease: false 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.py.b* 3 | secrets.yaml 4 | 5 | # Logs 6 | *.log 7 | pip-log.txt 8 | 9 | # Unit test / coverage reports 10 | .coverage 11 | coverage.xml 12 | nosetests.xml 13 | htmlcov/ 14 | test-reports/ 15 | test-results.xml 16 | test-output.xml 17 | pytest-*.txt 18 | 19 | # venv stuff 20 | pyvenv.cfg 21 | pip-selfcheck.json 22 | venv 23 | .venv 24 | Pipfile* 25 | share/* 26 | 27 | # Visual Studio Code 28 | .vscode/* 29 | .env 30 | 31 | # mypy 32 | /.mypy_cache/* 33 | /.dmypy.json 34 | 35 | # These come from the image 36 | pyproject.toml 37 | .pre-commit-config.yaml 38 | pylint/ 39 | config/ 40 | .vscode/ -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/iprak/custom-integration-image:latest -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Indu Prakash @iprak 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![GitHub Release](https://img.shields.io/github/v/release/iprak/winix) 3 | [![License](https://img.shields.io/packagist/l/phplicengine/bitly)](https://packagist.org/packages/phplicengine/bitly) 4 | 5 | 6 | ## Summary 7 | 8 | A custom component to interact with Winix [C545](https://www.winixamerica.com/product/certified-refurbished-c545-air-purifier/) and [C610](https://www.winixamerica.com/product/c610/) air purifiers. 9 | 10 | This has also been reported to work with these models: [AM90](https://www.winixamerica.com/product/am90/), [HR1000](https://www.amazon.com/Winix-HR1000-5-Stage-Enabled-Cleaner/dp/B01FWS0HSY), [C909](https://www.costco.com/winix-c909-4-stage-air-purifier-with-wi-fi-%2526-plasmawave-technology.product.100842491.html), [T800](https://winixeurope.eu/air-purifiers/winix-t800-wifi/), [9800](https://www.winixamerica.com/product/9800/). There could however be some difference in functionality. 11 | 12 | ## Installation 13 | 14 | This can be installed by copying all the files from `custom_components/winix/` to `/custom_components/winix/`. Next add Winix integration from `Add Integration` and use your credentials from Winix mobile app. 15 | 16 | - C545 will generate 4 entities. 17 | - C610 will generate 6 entities. 18 | 19 | ![image](https://user-images.githubusercontent.com/6459774/212468308-e6e855ac-ad26-4405-b683-246ccf4c8ccc.png) 20 | 21 | - The `Air QValue` sensor reports the qValue reported by Winix purifier. This value is related to air quality although I am not exactly sure what it represents. 22 | - The `AQI` sensor matches the led light on the purifier. 23 | - Good (Blue) = 1 24 | - Fair (Amber) = 2 25 | - Poor (Red) = 3 26 | - The `Filter Life` sensor represents the left filter life and is based on an initial life of 9 months. 27 | 28 | - The fan entity supports speed and preset modes 29 | 30 | ![image](https://user-images.githubusercontent.com/6459774/212468432-0b37cd09-af5b-418c-855d-a12c8b21efc3.png) 31 | 32 | - The device data is fetched every 30 seconds. 33 | - There are 4 services `winix.plasmawave_off, winix.plasmawave_on, plasmawave_toggle and remove_stale_entities` in addition to the default fan services `fan.speed, fan.toggle, fan.turn_off, fan.turn_on, fan.set_preset_mode`. 34 | - `remove_stale_entities` can be used to remove entities which appear unavaialble when the associated device is removed from the account. 35 | 36 | ### Note 37 | 38 | - If purifiers are added/removed, then you would have to reload the integration. 39 | 40 | - Winix **does not support** simultaneous login from multiple devices. If you logged into the mobile app after configuring HomeAssistant, then the HomeAssistant session gets flagged as invalid and vice-versa. 41 | 42 | ## Breaking Changes 43 | 44 | - [1.1.0](https://github.com/iprak/winix/releases) changed the sensor implementation. The aqi sensor id might be different now. 45 | 46 | - [1.0.0](https://github.com/iprak/winix/releases) introduces config flow and previous yaml based setup is no longer supported. You would want to delete that setup and proceed to setup the intgeration as mentioned in `Installation` section. 47 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy __init__.py to make imports with pytest-homeassistant-custom-component work.""" 2 | -------------------------------------------------------------------------------- /custom_components/winix/__init__.py: -------------------------------------------------------------------------------- 1 | """The Winix Air Purifier component.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Iterable 6 | from typing import Final 7 | 8 | from awesomeversion import AwesomeVersion 9 | from winix import auth 10 | 11 | from homeassistant.components import persistent_notification 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import ( 14 | CONF_PASSWORD, 15 | CONF_USERNAME, 16 | STATE_UNAVAILABLE, 17 | Platform, 18 | __version__, 19 | ) 20 | from homeassistant.core import HomeAssistant, ServiceCall, callback 21 | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 22 | from homeassistant.helpers import device_registry as dr, entity_registry as er 23 | 24 | from .const import ( 25 | FAN_SERVICES, 26 | LOGGER, 27 | SERVICE_REMOVE_STALE_ENTITIES, 28 | WINIX_AUTH_RESPONSE, 29 | WINIX_DATA_COORDINATOR, 30 | WINIX_DOMAIN, 31 | WINIX_NAME, 32 | __min_ha_version__, 33 | ) 34 | from .helpers import Helpers, WinixException 35 | from .manager import WinixManager 36 | 37 | SUPPORTED_PLATFORMS = [Platform.FAN, Platform.SENSOR, Platform.SELECT, Platform.SWITCH] 38 | DEFAULT_SCAN_INTERVAL: Final = 30 39 | 40 | 41 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 42 | """Set up the Winix component.""" 43 | 44 | if not is_valid_ha_version(): 45 | msg = ( 46 | "This integration require at least HomeAssistant version " 47 | f" {__min_ha_version__}, you are running version {__version__}." 48 | " Please upgrade HomeAssistant to continue use this integration." 49 | ) 50 | 51 | LOGGER.warning(msg) 52 | persistent_notification.async_create( 53 | hass, msg, WINIX_NAME, f"{WINIX_DOMAIN}.inv_ha_version" 54 | ) 55 | return False 56 | 57 | hass.data.setdefault(WINIX_DOMAIN, {}) 58 | user_input = entry.data 59 | 60 | auth_response_data = user_input.get(WINIX_AUTH_RESPONSE) 61 | auth_response = ( 62 | auth_response_data 63 | if isinstance(auth_response_data, auth.WinixAuthResponse) 64 | else auth.WinixAuthResponse(**auth_response_data) 65 | ) 66 | 67 | if not auth_response: 68 | raise ConfigEntryAuthFailed( 69 | "No authentication data found. Please reconfigure the integration." 70 | ) 71 | 72 | manager = WinixManager(hass, entry, auth_response, DEFAULT_SCAN_INTERVAL) 73 | 74 | new_auth_response = await hass.async_add_executor_job( 75 | prepare_devices, 76 | manager, 77 | user_input[CONF_USERNAME], 78 | user_input[CONF_PASSWORD], 79 | ) 80 | if new_auth_response is not None: 81 | # Copy over new values 82 | LOGGER.debug( 83 | "access_token %s", 84 | "changed" 85 | if auth_response.access_token != new_auth_response.access_token 86 | else "unchanged", 87 | ) 88 | LOGGER.debug( 89 | "refresh_token %s", 90 | "changed" 91 | if auth_response.refresh_token != new_auth_response.refresh_token 92 | else "unchanged", 93 | ) 94 | LOGGER.debug( 95 | "id_token %s", 96 | "changed" 97 | if auth_response.id_token != new_auth_response.id_token 98 | else "unchanged", 99 | ) 100 | 101 | auth_response.access_token = new_auth_response.access_token 102 | auth_response.refresh_token = new_auth_response.refresh_token 103 | auth_response.id_token = new_auth_response.id_token 104 | 105 | # Update tokens into entry.data 106 | hass.config_entries.async_update_entry( 107 | entry, 108 | data={**user_input, WINIX_AUTH_RESPONSE: auth_response}, 109 | ) 110 | 111 | await manager.async_config_entry_first_refresh() 112 | 113 | hass.data[WINIX_DOMAIN][entry.entry_id] = {WINIX_DATA_COORDINATOR: manager} 114 | await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS) 115 | 116 | setup_hass_services(hass) 117 | return True 118 | 119 | 120 | def prepare_devices( 121 | manager: WinixManager, username: str, password: str 122 | ) -> auth.WinixAuthResponse | None: 123 | """Prepare devices synchronously. Returns new auth response if re-login was needed. 124 | 125 | Raises ConfigEntryAuthFailed or ConfigEntryNotReady. 126 | """ 127 | new_auth_response: auth.WinixAuthResponse = None 128 | 129 | try: 130 | manager.prepare_devices_wrappers() 131 | except WinixException as err: 132 | # 900:MULTI LOGIN: Same credentials were used to login elwsewhere. We need to 133 | # login again and get new tokens. 134 | # 400:The user is not valid. 135 | 136 | if err.result_code in ("900", "400"): 137 | LOGGER.info( 138 | f"Failed to get device list (code={err.result_code}, message={err.result_message}), reauthenticating with stored credentials" 139 | ) 140 | 141 | try: 142 | new_auth_response = Helpers.login(username, password) 143 | except WinixException as login_err: 144 | raise ConfigEntryAuthFailed("Unable to authenticate.") from login_err 145 | 146 | LOGGER.info("Reauthenticating successful, getting device list again") 147 | 148 | # Try preparing device wrappers again with new auth response 149 | try: 150 | manager.prepare_devices_wrappers(new_auth_response.access_token) 151 | except WinixException as err_retry: 152 | raise ConfigEntryAuthFailed( 153 | "Unable to access device data even after re-login." 154 | ) from err_retry 155 | 156 | else: 157 | raise ConfigEntryNotReady("Unable to access device data.") from err 158 | 159 | return new_auth_response 160 | 161 | 162 | def setup_hass_services(hass: HomeAssistant) -> None: 163 | """Home Assistant services.""" 164 | 165 | def remove_stale_entities(call: ServiceCall) -> None: 166 | """Remove stale entities.""" 167 | device_registry = dr.async_get(hass) 168 | entity_registry = er.async_get(hass) 169 | 170 | # Using set to avoid duplicates 171 | entity_ids = set() 172 | device_ids = set() 173 | 174 | for state in hass.states.async_all(SUPPORTED_PLATFORMS): 175 | entity_id = state.entity_id 176 | entity = entity_registry.async_get(entity_id) 177 | 178 | if entity.unique_id.startswith(f"{entity.domain}.{WINIX_DOMAIN}_"): 179 | device_id = entity.device_id 180 | device = device_registry.async_get(device_id) 181 | 182 | if state.state == STATE_UNAVAILABLE or not device: 183 | entity_ids.add(entity_id) 184 | device_ids.add(device_id) 185 | 186 | if entity_ids: 187 | hass.add_job( 188 | async_remove, entity_registry, device_registry, entity_ids, device_ids 189 | ) 190 | else: 191 | LOGGER.debug("Nothing to remove") 192 | 193 | hass.services.async_register( 194 | WINIX_DOMAIN, SERVICE_REMOVE_STALE_ENTITIES, remove_stale_entities 195 | ) 196 | 197 | 198 | @callback 199 | def async_remove( 200 | entity_registry: er.EntityRegistry, 201 | device_registry: dr.DeviceRegistry, 202 | entity_ids: Iterable[str], 203 | device_ids: Iterable[str], 204 | ) -> None: 205 | """Remove devices and entities.""" 206 | for entity_id in entity_ids: 207 | entity_registry.async_remove(entity_id) 208 | LOGGER.debug("Removing entity %s", entity_id) 209 | 210 | for device_id in device_ids: 211 | device_registry.async_remove_device(device_id) 212 | LOGGER.debug("Removing device %s", device_id) 213 | 214 | 215 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 216 | """Unload a config entry.""" 217 | unload_ok = await hass.config_entries.async_unload_platforms( 218 | entry, SUPPORTED_PLATFORMS 219 | ) 220 | if unload_ok: 221 | hass.data.pop(WINIX_DOMAIN) 222 | 223 | other_loaded_entries = [ 224 | _entry 225 | for _entry in hass.config_entries.async_loaded_entries(WINIX_DOMAIN) 226 | if _entry.entry_id != entry.entry_id 227 | ] 228 | if not other_loaded_entries: 229 | # If this is the last loaded instance, then unregister services 230 | hass.services.async_remove(WINIX_DOMAIN, SERVICE_REMOVE_STALE_ENTITIES) 231 | 232 | for service_name in FAN_SERVICES: 233 | hass.services.async_remove(WINIX_DOMAIN, service_name) 234 | 235 | return unload_ok 236 | 237 | 238 | def is_valid_ha_version() -> bool: 239 | """Check if HA version is valid for this integration.""" 240 | return AwesomeVersion(__version__) >= AwesomeVersion(__min_ha_version__) 241 | -------------------------------------------------------------------------------- /custom_components/winix/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Winix purifier.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | from winix import auth 10 | 11 | from homeassistant import config_entries 12 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 13 | from homeassistant.data_entry_flow import FlowResult 14 | 15 | from .const import WINIX_AUTH_RESPONSE, WINIX_DOMAIN, WINIX_NAME 16 | from .helpers import Helpers, WinixException 17 | 18 | REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) 19 | 20 | AUTH_DATA_SCHEMA = vol.Schema( 21 | { 22 | vol.Required(CONF_USERNAME): str, 23 | vol.Required(CONF_PASSWORD): str, 24 | } 25 | ) 26 | 27 | 28 | class WinixFlowHandler(config_entries.ConfigFlow, domain=WINIX_DOMAIN): 29 | """Config flow handler.""" 30 | 31 | VERSION = 1 32 | 33 | def __init__(self) -> None: 34 | """Start a config flow.""" 35 | self._reauth_unique_id = None 36 | 37 | async def _validate_input(self, username: str, password: str): 38 | """Validate the user input.""" 39 | try: 40 | auth_response = await Helpers.async_login(self.hass, username, password) 41 | except WinixException as err: 42 | if err.result_code == "UserNotFoundException": 43 | return {"errors": {"base": "invalid_user"}, WINIX_AUTH_RESPONSE: None} 44 | 45 | return { 46 | "errors": {"base": "invalid_auth"}, 47 | WINIX_AUTH_RESPONSE: None, 48 | } 49 | else: 50 | return {"errors": None, WINIX_AUTH_RESPONSE: auth_response} 51 | 52 | async def async_step_user( 53 | self, user_input: dict[str, Any] | None = None 54 | ) -> FlowResult: 55 | """Handle a flow initialized by the user.""" 56 | 57 | errors: dict[str, str] = {} 58 | if user_input is not None: 59 | username = user_input[CONF_USERNAME] 60 | errors_and_auth = await self._validate_input( 61 | username, user_input[CONF_PASSWORD] 62 | ) 63 | errors = errors_and_auth["errors"] 64 | if not errors: 65 | auth_response: auth.WinixAuthResponse = errors_and_auth[ 66 | WINIX_AUTH_RESPONSE 67 | ] 68 | await self.async_set_unique_id(username) 69 | self._abort_if_unique_id_configured() 70 | return self.async_create_entry( 71 | title=WINIX_NAME, 72 | data={**user_input, WINIX_AUTH_RESPONSE: auth_response}, 73 | ) 74 | 75 | return self.async_show_form( 76 | step_id="user", 77 | data_schema=AUTH_DATA_SCHEMA, 78 | errors=errors, 79 | ) 80 | 81 | async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: 82 | # pylint: disable=unused-argument 83 | """Handle reauthentication.""" 84 | self._reauth_unique_id = self.context["unique_id"] 85 | return await self.async_step_reauth_confirm() 86 | 87 | async def async_step_reauth_confirm(self, user_input=None): 88 | """Handle reauthentication.""" 89 | errors = {} 90 | existing_entry = await self.async_set_unique_id(self._reauth_unique_id) 91 | username = existing_entry.data[CONF_USERNAME] 92 | if user_input is not None: 93 | password = user_input[CONF_PASSWORD] 94 | errors_and_auth = await self._validate_input(username, password) 95 | errors = errors_and_auth["errors"] 96 | if not errors: 97 | auth_response = errors_and_auth[WINIX_AUTH_RESPONSE] 98 | self.hass.config_entries.async_update_entry( 99 | existing_entry, 100 | data={ 101 | **existing_entry.data, 102 | CONF_PASSWORD: password, 103 | WINIX_AUTH_RESPONSE: auth_response, 104 | }, 105 | ) 106 | await self.hass.config_entries.async_reload(existing_entry.entry_id) 107 | return self.async_abort(reason="reauth_successful") 108 | 109 | return self.async_show_form( 110 | description_placeholders={CONF_USERNAME: username}, 111 | step_id="reauth_confirm", 112 | data_schema=REAUTH_SCHEMA, 113 | errors=errors, 114 | ) 115 | -------------------------------------------------------------------------------- /custom_components/winix/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Winix C545 Air Purifier component.""" 2 | 3 | from enum import StrEnum, unique 4 | import logging 5 | from typing import Final 6 | 7 | __min_ha_version__ = "2024.8" 8 | 9 | LOGGER = logging.getLogger(__package__) 10 | 11 | WINIX_DOMAIN: Final = "winix" 12 | 13 | WINIX_NAME: Final = "Winix Purifier" 14 | WINIX_DATA_KEY: Final = "fan_winix_air_purifier" 15 | WINIX_DATA_COORDINATOR: Final = "coordinator" 16 | WINIX_AUTH_RESPONSE: Final = "WinixAuthResponse" 17 | WINIX_ACCESS_TOKEN_EXPIRATION: Final = "access_token_expiration" 18 | ATTR_AIRFLOW: Final = "airflow" 19 | ATTR_AIR_AQI: Final = "aqi" 20 | ATTR_AIR_QUALITY: Final = "air_quality" 21 | ATTR_AIR_QVALUE: Final = "air_qvalue" 22 | ATTR_FILTER_HOUR: Final = "filter_hour" 23 | ATTR_FILTER_REPLACEMENT_DATE: Final = "filter_replace_date" 24 | ATTR_LOCATION: Final = "location" 25 | ATTR_MODE: Final = "mode" 26 | ATTR_PLASMA: Final = "plasma" 27 | ATTR_POWER: Final = "power" 28 | 29 | SENSOR_AIR_QVALUE: Final = "air_qvalue" 30 | SENSOR_AQI: Final = "aqi" 31 | SENSOR_FILTER_LIFE: Final = "filter_life" 32 | 33 | OFF_VALUE: Final = "off" 34 | ON_VALUE: Final = "on" 35 | 36 | # The service name is the partial name of the method in WinixPurifier 37 | SERVICE_PLASMAWAVE_ON: Final = "plasmawave_on" 38 | SERVICE_PLASMAWAVE_OFF: Final = "plasmawave_off" 39 | SERVICE_PLASMAWAVE_TOGGLE: Final = "plasmawave_toggle" 40 | SERVICE_REMOVE_STALE_ENTITIES: Final = "remove_stale_entities" 41 | FAN_SERVICES: Final = [ 42 | SERVICE_PLASMAWAVE_ON, 43 | SERVICE_PLASMAWAVE_OFF, 44 | SERVICE_PLASMAWAVE_TOGGLE, 45 | ] 46 | 47 | # airflow can contain the special preset values of manual and sleep 48 | # but we are not using those as fan speed. 49 | AIRFLOW_LOW: Final = "low" 50 | AIRFLOW_MEDIUM: Final = "medium" 51 | AIRFLOW_HIGH: Final = "high" 52 | AIRFLOW_TURBO: Final = "turbo" 53 | AIRFLOW_SLEEP: Final = "sleep" 54 | 55 | ORDERED_NAMED_FAN_SPEEDS: Final = [ 56 | AIRFLOW_LOW, 57 | AIRFLOW_MEDIUM, 58 | AIRFLOW_HIGH, 59 | AIRFLOW_TURBO, 60 | ] 61 | 62 | # mode can contain the special preset value of manual. 63 | MODE_AUTO: Final = "auto" 64 | MODE_MANUAL: Final = "manual" 65 | 66 | PRESET_MODE_AUTO: Final = "Auto" 67 | PRESET_MODE_AUTO_PLASMA_OFF: Final = "Auto (PlasmaWave off)" 68 | PRESET_MODE_MANUAL: Final = "Manual" 69 | PRESET_MODE_MANUAL_PLASMA_OFF: Final = "Manual (PlasmaWave off)" 70 | PRESET_MODE_SLEEP: Final = "Sleep" 71 | PRESET_MODES: Final = [ 72 | PRESET_MODE_AUTO, 73 | PRESET_MODE_AUTO_PLASMA_OFF, 74 | PRESET_MODE_MANUAL, 75 | PRESET_MODE_MANUAL_PLASMA_OFF, 76 | PRESET_MODE_SLEEP, 77 | ] 78 | 79 | 80 | @unique 81 | class NumericPresetModes(StrEnum): 82 | """Alternate numeric preset modes. 83 | 84 | The value correspond to the index in PRESET_MODES. 85 | """ 86 | 87 | PRESET_MODE_AUTO = "1" 88 | PRESET_MODE_AUTO_PLASMA_OFF = "2" 89 | PRESET_MODE_MANUAL = "3" 90 | PRESET_MODE_MANUAL_PLASMA_OFF = "4" 91 | PRESET_MODE_SLEEP = "5" 92 | 93 | 94 | class Features: 95 | """Additional Winix purifier features.""" 96 | 97 | supports_brightness_level = False 98 | supports_child_lock = False 99 | -------------------------------------------------------------------------------- /custom_components/winix/device_wrapper.py: -------------------------------------------------------------------------------- 1 | """The Winix Air Purifier component.""" 2 | 3 | from __future__ import annotations 4 | 5 | import dataclasses 6 | 7 | import aiohttp 8 | 9 | from .const import ( 10 | AIRFLOW_LOW, 11 | AIRFLOW_SLEEP, 12 | ATTR_AIRFLOW, 13 | ATTR_MODE, 14 | ATTR_PLASMA, 15 | ATTR_POWER, 16 | MODE_AUTO, 17 | MODE_MANUAL, 18 | OFF_VALUE, 19 | ON_VALUE, 20 | PRESET_MODE_AUTO, 21 | PRESET_MODE_AUTO_PLASMA_OFF, 22 | PRESET_MODE_MANUAL, 23 | PRESET_MODE_MANUAL_PLASMA_OFF, 24 | PRESET_MODE_SLEEP, 25 | PRESET_MODES, 26 | Features, 27 | NumericPresetModes, 28 | ) 29 | from .driver import ATTR_BRIGHTNESS_LEVEL, ATTR_CHILD_LOCK, WinixDriver 30 | 31 | 32 | @dataclasses.dataclass 33 | class MyWinixDeviceStub: 34 | """Winix purifier device information.""" 35 | 36 | id: str 37 | mac: str 38 | alias: str 39 | location_code: str 40 | filter_replace_date: str 41 | model: str 42 | sw_version: str 43 | 44 | 45 | class WinixDeviceWrapper: 46 | """Representation of the Winix device data.""" 47 | 48 | # pylint: disable=too-many-instance-attributes 49 | 50 | def __init__( 51 | self, 52 | client: aiohttp.ClientSession, 53 | device_stub: MyWinixDeviceStub, 54 | logger, 55 | ) -> None: 56 | """Initialize the wrapper.""" 57 | 58 | self._driver = WinixDriver(device_stub.id, client) 59 | 60 | # Start as empty object in case fan was operated before it got updated 61 | self._state = {} 62 | 63 | self._on = False 64 | self._auto = False 65 | self._manual = False 66 | self._plasma_on = False 67 | self._sleep = False 68 | self._logger = logger 69 | self._child_lock_on = False 70 | self._brightness_level = None 71 | 72 | self.device_stub = device_stub 73 | self._alias = device_stub.alias 74 | self._features = Features() 75 | 76 | if device_stub.model.lower().startswith("c610"): 77 | self._features.supports_brightness_level = True 78 | self._features.supports_child_lock = True 79 | 80 | async def update(self) -> None: 81 | """Update the device data.""" 82 | self._state = await self._driver.get_state() 83 | self._auto = self._manual = self._sleep = self._plasma_on = False 84 | 85 | self._on = self._state.get(ATTR_POWER) == ON_VALUE 86 | self._plasma_on = self._state.get(ATTR_PLASMA) == ON_VALUE 87 | self._child_lock_on = self._state.get(ATTR_CHILD_LOCK) == ON_VALUE 88 | self._brightness_level = self._state.get(ATTR_BRIGHTNESS_LEVEL) 89 | 90 | # Sleep: airflow=sleep, mode can be manual 91 | # Auto: mode=auto, airflow can be anything 92 | # Low: manual+low 93 | 94 | if self._state.get(ATTR_MODE) == MODE_AUTO: 95 | self._auto = True 96 | self._manual = False 97 | elif self._state.get(ATTR_MODE) == MODE_MANUAL: 98 | self._auto = False 99 | self._manual = True 100 | 101 | if self._state.get(ATTR_AIRFLOW) == AIRFLOW_SLEEP: 102 | self._sleep = True 103 | 104 | self._logger.debug( 105 | "%s: updated on=%s, auto=%s, manual=%s, sleep=%s, airflow=%s, plasma=%s", 106 | self._alias, 107 | self._on, 108 | self._auto, 109 | self._manual, 110 | self._sleep, 111 | self._state.get(ATTR_AIRFLOW), 112 | self._plasma_on, 113 | ) 114 | 115 | def get_state(self) -> dict[str, str]: 116 | """Return the device data.""" 117 | return self._state 118 | 119 | @property 120 | def features(self) -> Features: 121 | """Return the purifiers features.""" 122 | return self._features 123 | 124 | @property 125 | def is_on(self) -> bool: 126 | """Return if the purifier is on.""" 127 | return self._on 128 | 129 | @property 130 | def is_auto(self) -> bool: 131 | """Return if the purifier is in Auto mode.""" 132 | return self._auto 133 | 134 | @property 135 | def is_manual(self) -> bool: 136 | """Return if the purifier is in Manual mode.""" 137 | return self._manual 138 | 139 | @property 140 | def is_plasma_on(self) -> bool: 141 | """Return if plasma is on.""" 142 | return self._plasma_on 143 | 144 | @property 145 | def is_sleep(self) -> bool: 146 | """Return if the purifier is in Sleep mode.""" 147 | return self._sleep 148 | 149 | async def async_ensure_on(self) -> None: 150 | """Turn on the purifier.""" 151 | if not self._on: 152 | self._on = True 153 | 154 | self._logger.debug("%s => turned on", self._alias) 155 | await self._driver.turn_on() 156 | 157 | async def async_turn_on(self) -> None: 158 | """Turn on the purifier in Auto mode.""" 159 | await self.async_ensure_on() 160 | await self.async_auto() 161 | 162 | async def async_turn_off(self) -> None: 163 | """Turn off the purifier.""" 164 | if self._on: 165 | self._on = False 166 | 167 | self._logger.debug("%s => turned off", self._alias) 168 | await self._driver.turn_off() 169 | 170 | async def async_auto(self) -> None: 171 | """Put the purifier in Auto mode with Low airflow. 172 | 173 | Plasma state is left unchanged. The Winix server seems to sometimes 174 | turns it on for Auto mode. 175 | """ 176 | 177 | if not self._auto: 178 | self._auto = True 179 | self._manual = False 180 | self._sleep = False 181 | self._state[ATTR_MODE] = MODE_AUTO 182 | self._state[ATTR_AIRFLOW] = ( 183 | AIRFLOW_LOW # Something other than AIRFLOW_SLEEP 184 | ) 185 | 186 | self._logger.debug("%s => set mode=auto", self._alias) 187 | await self._driver.auto() 188 | 189 | async def async_plasmawave_on(self, force: bool = False) -> None: 190 | """Turn on plasma wave.""" 191 | 192 | if force or not self._plasma_on: 193 | self._plasma_on = True 194 | self._state[ATTR_PLASMA] = ON_VALUE 195 | 196 | self._logger.debug("%s => set plasmawave=on", self._alias) 197 | await self._driver.plasmawave_on() 198 | 199 | async def async_plasmawave_off(self, force: bool = False) -> None: 200 | """Turn off plasma wave.""" 201 | 202 | if force or self._plasma_on: 203 | self._plasma_on = False 204 | self._state[ATTR_PLASMA] = OFF_VALUE 205 | 206 | self._logger.debug("%s => set plasmawave=off", self._alias) 207 | await self._driver.plasmawave_off() 208 | 209 | @property 210 | def is_child_lock_on(self) -> bool: 211 | """Return if child lock is on.""" 212 | return self._child_lock_on 213 | 214 | async def async_child_lock_on(self) -> bool: 215 | """Turn on child lock.""" 216 | 217 | if not self._features.supports_child_lock or self._child_lock_on: 218 | return False 219 | 220 | await self._driver.child_lock_on() 221 | self._child_lock_on = True 222 | return True 223 | 224 | async def async_child_lock_off(self) -> bool: 225 | """Turn off child lock.""" 226 | 227 | if not self._features.supports_child_lock or not self._child_lock_on: 228 | return False 229 | 230 | await self._driver.child_lock_off() 231 | self._child_lock_on = False 232 | return True 233 | 234 | @property 235 | def brightness_level(self) -> int | None: 236 | """Return current brightness level.""" 237 | return self._brightness_level 238 | 239 | async def async_set_brightness_level(self, value: int) -> bool: 240 | """Set brightness level.""" 241 | 242 | if not self._features.supports_brightness_level or ( 243 | self._brightness_level == value 244 | ): 245 | return False 246 | 247 | await self._driver.set_brightness_level(value) 248 | self._brightness_level = value 249 | return True 250 | 251 | async def async_manual(self) -> None: 252 | """Put the purifier in Manual mode with Low airflow. Plasma state is left unchanged.""" 253 | 254 | if not self._manual: 255 | self._manual = True 256 | self._auto = False 257 | self._sleep = False 258 | self._state[ATTR_MODE] = MODE_MANUAL 259 | self._state[ATTR_AIRFLOW] = ( 260 | AIRFLOW_LOW # Something other than AIRFLOW_SLEEP 261 | ) 262 | 263 | self._logger.debug("%s => set mode=manual", self._alias) 264 | await self._driver.manual() 265 | 266 | async def async_sleep(self) -> None: 267 | """Turn the purifier in Manual mode with Sleep airflow. Plasma state is left unchanged.""" 268 | 269 | if not self._sleep: 270 | self._sleep = True 271 | self._auto = False 272 | self._manual = False 273 | self._state[ATTR_AIRFLOW] = AIRFLOW_SLEEP 274 | self._state[ATTR_MODE] = MODE_MANUAL 275 | 276 | self._logger.debug("%s => set mode=sleep", self._alias) 277 | await self._driver.sleep() 278 | 279 | async def async_set_speed(self, speed) -> None: 280 | """Turn the purifier on, put it in Manual mode and set the speed.""" 281 | 282 | if self._state.get(ATTR_AIRFLOW) != speed: 283 | self._state[ATTR_AIRFLOW] = speed 284 | 285 | # Setting speed requires the fan to be in manual mode 286 | await self.async_ensure_on() 287 | await self.async_manual() 288 | 289 | self._logger.debug("%s => set speed=%s", self._alias, speed) 290 | await getattr(self._driver, speed)() 291 | 292 | async def async_set_preset_mode(self, preset_mode: str) -> None: 293 | """Turn the purifier on and put it in the new preset mode.""" 294 | 295 | preset_mode = preset_mode.strip() 296 | 297 | if preset_mode not in PRESET_MODES: 298 | values = [item.value for item in NumericPresetModes] 299 | 300 | # Convert the numeric preset mode to its corresponding key 301 | if preset_mode in values: 302 | index = int(preset_mode) - 1 303 | preset_mode = PRESET_MODES[index] 304 | else: 305 | raise ValueError(f"Invalid preset mode: {preset_mode}") 306 | 307 | await self.async_ensure_on() 308 | self._logger.debug("%s => set mode=%s", self._alias, preset_mode) 309 | 310 | if preset_mode == PRESET_MODE_SLEEP: 311 | await self.async_sleep() 312 | elif preset_mode == PRESET_MODE_AUTO: 313 | await self.async_auto() 314 | await self.async_plasmawave_on() 315 | elif preset_mode == PRESET_MODE_AUTO_PLASMA_OFF: 316 | await self.async_auto() 317 | await self.async_plasmawave_off(True) 318 | elif preset_mode == PRESET_MODE_MANUAL: 319 | await self.async_manual() 320 | await self.async_plasmawave_on() 321 | elif preset_mode == PRESET_MODE_MANUAL_PLASMA_OFF: 322 | await self.async_manual() 323 | await self.async_plasmawave_off(True) 324 | -------------------------------------------------------------------------------- /custom_components/winix/driver.py: -------------------------------------------------------------------------------- 1 | """The WinixDriver component.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum, unique 6 | from typing import Final 7 | 8 | import aiohttp 9 | 10 | from .const import LOGGER 11 | 12 | # Modified from https://github.com/hfern/winix to support async operations 13 | 14 | ATTR_BRIGHTNESS_LEVEL: Final = "brightness_level" 15 | ATTR_CHILD_LOCK: Final = "child_lock" 16 | 17 | 18 | @unique 19 | class BrightnessLevel(Enum): 20 | """Brightness levels.""" 21 | 22 | Level0 = 0 23 | Level1 = 30 24 | Level2 = 70 25 | Level3 = 100 26 | 27 | 28 | class WinixDriver: 29 | """WinixDevice driver.""" 30 | 31 | # pylint: disable=line-too-long 32 | CTRL_URL = "https://us.api.winix-iot.com/common/control/devices/{deviceid}/A211/{attribute}:{value}" 33 | STATE_URL = "https://us.api.winix-iot.com/common/event/sttus/devices/{deviceid}" 34 | PARAM_URL = "https://us.api.winix-iot.com/common/event/param/devices/{deviceid}" 35 | CONNECTED_STATUS_URL = ( 36 | "https://us.api.winix-iot.com/common/event/connsttus/devices/{deviceid}" 37 | ) 38 | 39 | category_keys = { 40 | "power": "A02", 41 | "mode": "A03", 42 | "airflow": "A04", 43 | "aqi": "A05", 44 | "plasma": "A07", 45 | ATTR_BRIGHTNESS_LEVEL: "A16", 46 | ATTR_CHILD_LOCK: "A08", 47 | "filter_hour": "A21", 48 | "air_quality": "S07", 49 | "air_qvalue": "S08", 50 | "ambient_light": "S14", 51 | } 52 | 53 | state_keys = { 54 | "power": {"off": "0", "on": "1"}, 55 | "mode": {"auto": "01", "manual": "02"}, 56 | "airflow": { 57 | "low": "01", 58 | "medium": "02", 59 | "high": "03", 60 | "turbo": "05", 61 | "sleep": "06", 62 | }, 63 | ATTR_CHILD_LOCK: {"off": "0", "on": "1"}, 64 | "plasma": {"off": "0", "on": "1"}, 65 | "air_quality": {"good": "01", "fair": "02", "poor": "03"}, 66 | } 67 | 68 | def __init__(self, device_id: str, client: aiohttp.ClientSession) -> None: 69 | """Create an instance of WinixDevice.""" 70 | self.device_id = device_id 71 | self._client = client 72 | 73 | async def turn_off(self) -> None: 74 | """Turn the device off.""" 75 | await self._rpc_attr( 76 | self.category_keys["power"], self.state_keys["power"]["off"] 77 | ) 78 | 79 | async def turn_on(self) -> None: 80 | """Turn the device on.""" 81 | await self._rpc_attr( 82 | self.category_keys["power"], self.state_keys["power"]["on"] 83 | ) 84 | 85 | async def auto(self) -> None: 86 | """Set device in auto mode.""" 87 | await self._rpc_attr( 88 | self.category_keys["mode"], self.state_keys["mode"]["auto"] 89 | ) 90 | 91 | async def manual(self) -> None: 92 | """Set device in manual mode.""" 93 | await self._rpc_attr( 94 | self.category_keys["mode"], self.state_keys["mode"]["manual"] 95 | ) 96 | 97 | async def child_lock_off(self) -> None: 98 | """Turn child lock off.""" 99 | await self._rpc_attr(self.category_keys[ATTR_CHILD_LOCK], "0") 100 | 101 | async def child_lock_on(self) -> None: 102 | """Turn child lock on.""" 103 | await self._rpc_attr(self.category_keys[ATTR_CHILD_LOCK], "1") 104 | 105 | async def set_brightness_level(self, value: int) -> bool: 106 | """Set brightness level.""" 107 | if not any(e.value == value for e in BrightnessLevel): 108 | return False 109 | 110 | await self._rpc_attr(self.category_keys[ATTR_BRIGHTNESS_LEVEL], value) 111 | return True 112 | 113 | async def plasmawave_off(self) -> None: 114 | """Turn plasmawave off.""" 115 | await self._rpc_attr( 116 | self.category_keys["plasma"], self.state_keys["plasma"]["off"] 117 | ) 118 | 119 | async def plasmawave_on(self) -> None: 120 | """Turn plasmawave on.""" 121 | await self._rpc_attr( 122 | self.category_keys["plasma"], self.state_keys["plasma"]["on"] 123 | ) 124 | 125 | async def low(self) -> None: 126 | """Set speed low.""" 127 | await self._rpc_attr( 128 | self.category_keys["airflow"], self.state_keys["airflow"]["low"] 129 | ) 130 | 131 | async def medium(self) -> None: 132 | """Set speed medium.""" 133 | await self._rpc_attr( 134 | self.category_keys["airflow"], self.state_keys["airflow"]["medium"] 135 | ) 136 | 137 | async def high(self) -> None: 138 | """Set speed high.""" 139 | await self._rpc_attr( 140 | self.category_keys["airflow"], self.state_keys["airflow"]["high"] 141 | ) 142 | 143 | async def turbo(self) -> None: 144 | """Set speed turbo.""" 145 | await self._rpc_attr( 146 | self.category_keys["airflow"], self.state_keys["airflow"]["turbo"] 147 | ) 148 | 149 | async def sleep(self) -> None: 150 | """Set device in sleep mode.""" 151 | await self._rpc_attr( 152 | self.category_keys["airflow"], self.state_keys["airflow"]["sleep"] 153 | ) 154 | 155 | async def _rpc_attr(self, attr: str, value: str) -> None: 156 | LOGGER.debug("_rpc_attr attribute=%s, value=%s", attr, value) 157 | resp = await self._client.get( 158 | self.CTRL_URL.format(deviceid=self.device_id, attribute=attr, value=value), 159 | raise_for_status=True, 160 | ) 161 | raw_resp = await resp.text() 162 | LOGGER.debug("_rpc_attr response=%s", raw_resp) 163 | 164 | async def get_filter_life(self) -> int | None: 165 | """Get the total filter life.""" 166 | response = await self._client.get( 167 | self.PARAM_URL.format(deviceid=self.device_id) 168 | ) 169 | if response.status != 200: 170 | LOGGER.error("Error getting filter life, status code %s", response.status) 171 | return None 172 | 173 | json = await response.json() 174 | 175 | # pylint: disable=pointless-string-statement 176 | """ 177 | { 178 | 'statusCode': 200, 'headers': {'resultCode': 'S100', 'resultMessage': ''}, 179 | 'body': { 180 | 'deviceId': '847207352CE0_364yr8i989', 'totalCnt': 1, 181 | 'data': [ 182 | { 183 | 'apiNo': 'A240', 'apiGroup': '004', 'modelId': 'C545', 'attributes': {'P01': '6480'} 184 | } 185 | ] 186 | } 187 | } 188 | """ 189 | 190 | headers = json.get("headers", {}) 191 | if headers.get("resultMessage") == "no data": 192 | LOGGER.info("No filter life data received") 193 | return None 194 | 195 | try: 196 | attributes = json["body"]["data"][0]["attributes"] 197 | if attributes: 198 | return int(attributes["P01"]) 199 | except Exception: # pylint: disable=broad-except # noqa: BLE001 200 | return None 201 | 202 | async def get_state(self) -> dict[str, str | int]: 203 | """Get device state.""" 204 | 205 | # All devices seem to have max 9 months filter life so don't need to call this API. 206 | # await self.get_filter_life() 207 | 208 | response = await self._client.get( 209 | self.STATE_URL.format(deviceid=self.device_id) 210 | ) 211 | if response.status != 200: 212 | LOGGER.error("Error getting data, status code %s", response.status) 213 | return {} 214 | 215 | json = await response.json() 216 | 217 | # pylint: disable=pointless-string-statement 218 | """ 219 | { 220 | 'statusCode': 200, 221 | 'headers': {'resultCode': 'S100', 'resultMessage': ''}, 222 | 'body': { 223 | 'deviceId': '847207352CE0_364yr8i989', 'totalCnt': 1, 224 | 'data': [ 225 | { 226 | 'apiNo': 'A210', 'apiGroup': '001', 'deviceGroup': 'Air01', 'modelId': 'C545', 227 | 'attributes': {'A02': '0', 'A03': '01', 'A04': '01', 'A05': '01', 'A07': '0', 'A21': '1257', 'S07': '01', 'S08': '74', 'S14': '121'}, 228 | 'rssi': '-55', 'creationTime': 1673449200634, 'utcDatetime': '2023-01-11 15:00:00', 'utcTimestamp': 1673449200 229 | } 230 | ] 231 | } 232 | } 233 | 234 | Another sample from https://github.com/iprak/winix/issues/98 235 | {'statusCode': 200, 'headers': {'resultCode': 'S100', 'resultMessage': 'no data'}, 'body': {}} 236 | """ 237 | 238 | headers = json.get("headers", {}) 239 | if headers.get("resultMessage") == "no data": 240 | LOGGER.info("No data received") 241 | return {} 242 | 243 | output = {} 244 | 245 | try: 246 | LOGGER.debug(json) 247 | payload = json["body"]["data"][0]["attributes"] 248 | except Exception as err: # pylint: disable=broad-except # noqa: BLE001 249 | LOGGER.error("Error parsing response json, received %s", json, exc_info=err) 250 | 251 | # Return empty object so that callers don't crash (#37) 252 | return output 253 | 254 | for payload_key, attribute in payload.items(): 255 | for category, local_key in self.category_keys.items(): 256 | if payload_key == local_key: 257 | # pylint: disable=consider-iterating-dictionary 258 | if category in self.state_keys: 259 | for value_key, value in self.state_keys[category].items(): 260 | if attribute == value: 261 | output[category] = value_key 262 | else: 263 | output[category] = int(attribute) 264 | 265 | return output 266 | -------------------------------------------------------------------------------- /custom_components/winix/fan.py: -------------------------------------------------------------------------------- 1 | """Winix Air Purifier Device.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Mapping 7 | from typing import Any 8 | 9 | import voluptuous as vol 10 | 11 | from homeassistant.components.fan import ( 12 | DOMAIN as FAN_DOMAIN, 13 | FanEntity, 14 | FanEntityFeature, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import ATTR_ENTITY_ID 18 | from homeassistant.core import HomeAssistant 19 | import homeassistant.helpers.config_validation as cv 20 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 21 | from homeassistant.util.percentage import ( 22 | ordered_list_item_to_percentage, 23 | percentage_to_ordered_list_item, 24 | ) 25 | 26 | from .const import ( 27 | ATTR_AIRFLOW, 28 | ATTR_FILTER_REPLACEMENT_DATE, 29 | ATTR_LOCATION, 30 | ATTR_POWER, 31 | FAN_SERVICES, 32 | LOGGER, 33 | ORDERED_NAMED_FAN_SPEEDS, 34 | PRESET_MODE_AUTO, 35 | PRESET_MODE_AUTO_PLASMA_OFF, 36 | PRESET_MODE_MANUAL, 37 | PRESET_MODE_MANUAL_PLASMA_OFF, 38 | PRESET_MODE_SLEEP, 39 | PRESET_MODES, 40 | WINIX_DATA_COORDINATOR, 41 | WINIX_DATA_KEY, 42 | WINIX_DOMAIN, 43 | ) 44 | from .device_wrapper import WinixDeviceWrapper 45 | from .manager import WinixEntity, WinixManager 46 | 47 | 48 | async def async_setup_entry( 49 | hass: HomeAssistant, 50 | entry: ConfigEntry, 51 | async_add_entities: AddEntitiesCallback, 52 | ) -> None: 53 | """Set up the Winix air purifiers.""" 54 | data = hass.data[WINIX_DOMAIN][entry.entry_id] 55 | manager: WinixManager = data[WINIX_DATA_COORDINATOR] 56 | entities = [ 57 | WinixPurifier(wrapper, manager) for wrapper in manager.get_device_wrappers() 58 | ] 59 | data[WINIX_DATA_KEY] = entities 60 | async_add_entities(entities) 61 | 62 | async def async_service_handler(service_call): 63 | """Service handler.""" 64 | method = "async_" + service_call.service 65 | LOGGER.debug("Service '%s' invoked", service_call.service) 66 | 67 | # The defined services do not accept any additional parameters 68 | params = {} 69 | 70 | entity_ids = service_call.data.get(ATTR_ENTITY_ID) 71 | if entity_ids: 72 | devices = [ 73 | entity 74 | for entity in data[WINIX_DATA_KEY] 75 | if entity.entity_id in entity_ids 76 | ] 77 | else: 78 | devices = data[WINIX_DATA_KEY] 79 | 80 | state_update_tasks = [] 81 | for device in devices: 82 | if not hasattr(device, method): 83 | continue 84 | 85 | await getattr(device, method)(**params) 86 | state_update_tasks.append( 87 | asyncio.create_task(device.async_update_ha_state(True)) 88 | ) 89 | 90 | if state_update_tasks: 91 | # Update device states in HA 92 | await asyncio.wait(state_update_tasks) 93 | 94 | for service in FAN_SERVICES: 95 | hass.services.async_register( 96 | WINIX_DOMAIN, 97 | service, 98 | async_service_handler, 99 | schema=vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}), 100 | ) 101 | 102 | LOGGER.info("Added %s Winix fans", len(entities)) 103 | 104 | 105 | class WinixPurifier(WinixEntity, FanEntity): 106 | """Representation of a Winix Purifier entity.""" 107 | 108 | # https://developers.home-assistant.io/docs/core/entity/fan/ 109 | _attr_supported_features = ( 110 | FanEntityFeature.PRESET_MODE 111 | | FanEntityFeature.SET_SPEED 112 | | FanEntityFeature.TURN_ON 113 | | FanEntityFeature.TURN_OFF 114 | ) 115 | 116 | def __init__(self, wrapper: WinixDeviceWrapper, coordinator: WinixManager) -> None: 117 | """Initialize the entity.""" 118 | super().__init__(wrapper, coordinator) 119 | self._attr_unique_id = f"{FAN_DOMAIN}.{WINIX_DOMAIN}_{self._mac}" 120 | 121 | @property 122 | def name(self) -> str | None: 123 | """Entity Name. 124 | 125 | Returning None, since this is the primary entity. 126 | """ 127 | return None 128 | 129 | @property 130 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 131 | """Return the state attributes.""" 132 | attributes = {} 133 | state = self._wrapper.get_state() 134 | 135 | if state is not None: 136 | # The power attribute is the entity state, so skip it 137 | attributes = { 138 | key: value for key, value in state.items() if key != ATTR_POWER 139 | } 140 | 141 | attributes[ATTR_LOCATION] = self._wrapper.device_stub.location_code 142 | attributes[ATTR_FILTER_REPLACEMENT_DATE] = ( 143 | self._wrapper.device_stub.filter_replace_date 144 | ) 145 | 146 | return attributes 147 | 148 | @property 149 | def is_on(self) -> bool: 150 | """Return true if switch is on.""" 151 | return self._wrapper.is_on 152 | 153 | @property 154 | def percentage(self) -> int | None: 155 | """Return the current speed percentage.""" 156 | state = self._wrapper.get_state() 157 | if state is None: 158 | return None 159 | if self._wrapper.is_sleep or self._wrapper.is_auto: 160 | return None 161 | if state.get(ATTR_AIRFLOW) is None: 162 | return None 163 | 164 | return ordered_list_item_to_percentage( 165 | ORDERED_NAMED_FAN_SPEEDS, state.get(ATTR_AIRFLOW) 166 | ) 167 | 168 | @property 169 | def preset_mode(self) -> str | None: 170 | """Return the current preset mode, e.g., auto, smart, interval, favorite.""" 171 | state = self._wrapper.get_state() 172 | if state is None: 173 | return None 174 | if self._wrapper.is_sleep: 175 | return PRESET_MODE_SLEEP 176 | if self._wrapper.is_auto: 177 | return ( 178 | PRESET_MODE_AUTO 179 | if self._wrapper.is_plasma_on 180 | else PRESET_MODE_AUTO_PLASMA_OFF 181 | ) 182 | if self._wrapper.is_manual: 183 | return ( 184 | PRESET_MODE_MANUAL 185 | if self._wrapper.is_plasma_on 186 | else PRESET_MODE_MANUAL_PLASMA_OFF 187 | ) 188 | 189 | return None 190 | 191 | @property 192 | def preset_modes(self) -> list[str] | None: 193 | """Return a list of available preset modes.""" 194 | return PRESET_MODES 195 | 196 | @property 197 | def speed_list(self) -> list: 198 | """Get the list of available speeds.""" 199 | return ORDERED_NAMED_FAN_SPEEDS 200 | 201 | @property 202 | def speed_count(self) -> int: 203 | """Return the number of speeds the fan supports.""" 204 | return len(ORDERED_NAMED_FAN_SPEEDS) 205 | 206 | async def async_set_percentage(self, percentage: int) -> None: 207 | """Set the speed percentage of the fan.""" 208 | if percentage == 0: 209 | await self.async_turn_off() 210 | else: 211 | await self._wrapper.async_set_speed( 212 | percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) 213 | ) 214 | 215 | self.async_write_ha_state() 216 | 217 | async def async_turn_on( 218 | self, 219 | percentage: int | None = None, 220 | preset_mode: str | None = None, 221 | **kwargs: Any, 222 | ) -> None: 223 | # pylint: disable=unused-argument 224 | """Turn on the purifier.""" 225 | 226 | if percentage: 227 | await self.async_set_percentage(percentage) 228 | if preset_mode: 229 | await self._wrapper.async_set_preset_mode(preset_mode) 230 | else: 231 | await self._wrapper.async_turn_on() 232 | 233 | self.async_write_ha_state() 234 | 235 | async def async_turn_off(self, **kwargs: Any) -> None: 236 | """Turn off the purifier.""" 237 | await self._wrapper.async_turn_off() 238 | self.async_write_ha_state() 239 | 240 | async def async_plasmawave_on(self) -> None: 241 | """Turn on plasma wave.""" 242 | await self._wrapper.async_plasmawave_on() 243 | self.async_write_ha_state() 244 | 245 | async def async_plasmawave_off(self) -> None: 246 | """Turn off plasma wave.""" 247 | await self._wrapper.async_plasmawave_off() 248 | self.async_write_ha_state() 249 | 250 | async def async_plasmawave_toggle(self) -> None: 251 | """Toggle plasma wave.""" 252 | 253 | if self._wrapper.is_plasma_on: 254 | await self._wrapper.async_plasmawave_off() 255 | else: 256 | await self._wrapper.async_plasmawave_on() 257 | 258 | self.async_write_ha_state() 259 | 260 | async def async_set_preset_mode(self, preset_mode: str) -> None: 261 | """Set new preset mode.""" 262 | await self._wrapper.async_set_preset_mode(preset_mode) 263 | self.async_write_ha_state() 264 | -------------------------------------------------------------------------------- /custom_components/winix/helpers.py: -------------------------------------------------------------------------------- 1 | """The Winix Air Purifier component.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | from datetime import datetime, timedelta 7 | from http import HTTPStatus 8 | 9 | import requests 10 | from winix import WinixAccount, auth 11 | 12 | from homeassistant.core import HomeAssistant 13 | 14 | from .const import LOGGER, WINIX_DOMAIN 15 | from .device_wrapper import MyWinixDeviceStub 16 | 17 | DEFAULT_POST_TIMEOUT = 5 18 | 19 | 20 | class Helpers: 21 | """Utility helper class.""" 22 | 23 | @staticmethod 24 | def send_notification( 25 | hass: HomeAssistant, notification_id: str, title: str, message: str 26 | ) -> None: 27 | """Display a persistent notification.""" 28 | hass.async_create_task( 29 | hass.services.async_call( 30 | domain="persistent_notification", 31 | service="create", 32 | service_data={ 33 | "title": title, 34 | "message": message, 35 | "notification_id": f"{WINIX_DOMAIN}.{notification_id}", 36 | }, 37 | ) 38 | ) 39 | 40 | @staticmethod 41 | async def async_login( 42 | hass: HomeAssistant, username: str, password: str 43 | ) -> auth.WinixAuthResponse: 44 | """Log in asynchronously.""" 45 | 46 | return await hass.async_add_executor_job(Helpers.login, username, password) 47 | 48 | @staticmethod 49 | def login(username: str, password: str) -> auth.WinixAuthResponse: 50 | """Log in synchronously.""" 51 | 52 | try: 53 | response = auth.login(username, password) 54 | except Exception as err: # pylint: disable=broad-except 55 | raise WinixException.from_aws_exception(err) from err 56 | 57 | access_token = response.access_token 58 | account = WinixAccount(access_token) 59 | 60 | # The next 2 operations can raise generic or botocore exceptions 61 | try: 62 | account.register_user(username) 63 | account.check_access_token() 64 | except Exception as err: # pylint: disable=broad-except 65 | raise WinixException.from_winix_exception(err) from err 66 | 67 | expires_at = (datetime.now() + timedelta(seconds=3600)).timestamp() 68 | LOGGER.debug("Login successful, token expires %d", expires_at) 69 | return response 70 | 71 | @staticmethod 72 | async def async_refresh_auth( 73 | hass: HomeAssistant, response: auth.WinixAuthResponse 74 | ) -> auth.WinixAuthResponse: 75 | """Refresh authentication. 76 | 77 | Raises WinixException. 78 | """ 79 | 80 | def _refresh(response: auth.WinixAuthResponse) -> auth.WinixAuthResponse: 81 | LOGGER.debug("Attempting re-authentication") 82 | 83 | try: 84 | reponse = auth.refresh( 85 | user_id=response.user_id, refresh_token=response.refresh_token 86 | ) 87 | except Exception as err: # pylint: disable=broad-except 88 | raise WinixException.from_aws_exception(err) from err 89 | 90 | account = WinixAccount(response.access_token) 91 | LOGGER.debug("Attempting access token check") 92 | 93 | try: 94 | account.check_access_token() 95 | except Exception as err: # pylint: disable=broad-except 96 | raise WinixException.from_winix_exception(err) from err 97 | 98 | LOGGER.debug("Re-authentication successful") 99 | return reponse 100 | 101 | return await hass.async_add_executor_job(_refresh, response) 102 | 103 | @staticmethod 104 | def get_device_stubs( 105 | hass: HomeAssistant, access_token: str 106 | ) -> list[MyWinixDeviceStub]: 107 | """Get device list. 108 | 109 | Raises WinixException. 110 | """ 111 | 112 | # Modified from https://github.com/hfern/winix to support additional attributes. 113 | 114 | # com.google.gson.k kVar = new com.google.gson.k(); 115 | # kVar.p("accessToken", deviceMainActivity2.f2938o); 116 | # kVar.p("uuid", Common.w(deviceMainActivity2.f2934k)); 117 | # new com.winix.smartiot.util.o0(deviceMainActivity2.f2934k, "https://us.mobile.winix-iot.com/getDeviceInfoList", kVar).a(new TypeToken() { 118 | # // from class: com.winix.smartiot.activity.DeviceMainActivity.9 119 | # }, new com.winix.smartiot.activity.d(deviceMainActivity2, 4)); 120 | 121 | resp = requests.post( 122 | "https://us.mobile.winix-iot.com/getDeviceInfoList", 123 | json={ 124 | "accessToken": access_token, 125 | "uuid": WinixAccount(access_token).get_uuid(), 126 | }, 127 | timeout=DEFAULT_POST_TIMEOUT, 128 | ) 129 | 130 | if resp.status_code != HTTPStatus.OK: 131 | err_data = resp.json() 132 | result_code = err_data.get("resultCode") 133 | result_message = err_data.get("resultMessage") 134 | 135 | raise WinixException( 136 | { 137 | "message": f"Failed to get device list (code-{result_code}). {result_message}.", 138 | "result_code": result_code, 139 | "result_message": result_message, 140 | } 141 | ) 142 | 143 | return [ 144 | MyWinixDeviceStub( 145 | id=d.get("deviceId"), 146 | mac=d.get("mac"), 147 | alias=d.get("deviceAlias"), 148 | location_code=d.get("deviceLocCode"), 149 | filter_replace_date=d.get("filterReplaceDate"), 150 | model=d.get("modelName"), 151 | sw_version=d.get("mcuVer"), 152 | ) 153 | for d in resp.json()["deviceInfoList"] 154 | ] 155 | 156 | 157 | class WinixException(Exception): 158 | """Wiinx related operation exception.""" 159 | 160 | def __init__(self, values: dict) -> None: 161 | """Create instance of WinixException.""" 162 | 163 | super().__init__(values["message"]) 164 | 165 | self.result_code: str = values.get("result_code", "") 166 | """Error code.""" 167 | self.result_message: str = values.get("result_message", "") 168 | """Error code message.""" 169 | 170 | @staticmethod 171 | def from_winix_exception(err: Exception) -> WinixException: 172 | """Build exception for Winix library operation.""" 173 | return WinixException(WinixException.parse_winix_exception(err)) 174 | 175 | @staticmethod 176 | def from_aws_exception(err: Exception) -> WinixException: 177 | """Build exception for AWS operation.""" 178 | return WinixException(WinixException.parse_aws_exception(err)) 179 | 180 | @staticmethod 181 | def parse_winix_exception(err: Exception) -> Mapping[str, str]: 182 | """Parse Winix library exception message.""" 183 | 184 | message = str(err) 185 | if message.find(":") == -1: 186 | return {"message": message} 187 | 188 | pcs = message.partition(":") 189 | if pcs[0].rfind("(") == -1: 190 | return {"message": message} 191 | 192 | pcs2 = pcs[0].rpartition("(") 193 | return { 194 | "message": message, 195 | "result_code": pcs2[2].rstrip(")"), 196 | "result_message": pcs[2], 197 | } 198 | 199 | @staticmethod 200 | def parse_aws_exception(err: Exception) -> Mapping[str, str]: 201 | """Parse AWS operation exception.""" 202 | message = str(err) 203 | 204 | # https://stackoverflow.com/questions/60703127/how-to-catch-botocore-errorfactory-usernotfoundexception 205 | try: 206 | response = err.response 207 | if response: 208 | return { 209 | "message": message, 210 | "result_code": response.get("Error", {}).get("Code"), 211 | } 212 | 213 | except AttributeError: 214 | return {"message": message} 215 | else: 216 | return None 217 | -------------------------------------------------------------------------------- /custom_components/winix/manager.py: -------------------------------------------------------------------------------- 1 | """The Winix C545 Air Purifier component.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta 6 | 7 | from winix import auth 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers import aiohttp_client 12 | from homeassistant.helpers.entity import DeviceInfo 13 | from homeassistant.helpers.update_coordinator import ( 14 | CoordinatorEntity, 15 | DataUpdateCoordinator, 16 | ) 17 | 18 | from .const import LOGGER, WINIX_DOMAIN 19 | from .device_wrapper import WinixDeviceWrapper 20 | from .helpers import Helpers 21 | 22 | # category_keys = { 23 | # "power": "A02", 24 | # "mode": "A03", 25 | # "airflow": "A04", 26 | # "aqi": "A05", 27 | # "plasma": "A07", 28 | # "filter_hour": "A21", 29 | # "air_quality": "S07", 30 | # "air_qvalue": "S08", 31 | # "ambient_light": "S14", 32 | # } 33 | 34 | 35 | class WinixEntity(CoordinatorEntity): 36 | """Represents a Winix entity.""" 37 | 38 | _attr_has_entity_name = True 39 | _attr_attribution = "Data provided by Winix" 40 | 41 | def __init__(self, wrapper: WinixDeviceWrapper, coordinator: WinixManager) -> None: 42 | """Initialize the Winix entity.""" 43 | super().__init__(coordinator) 44 | 45 | device_stub = wrapper.device_stub 46 | 47 | self._mac = device_stub.mac.lower() 48 | self._wrapper = wrapper 49 | 50 | self._attr_device_info: DeviceInfo = { 51 | "identifiers": {(WINIX_DOMAIN, self._mac)}, 52 | "name": f"Winix {device_stub.alias}", 53 | "manufacturer": "Winix", 54 | "model": device_stub.model, 55 | "sw_version": device_stub.sw_version, 56 | } 57 | 58 | @property 59 | def available(self) -> bool: 60 | """Return True if entity is available.""" 61 | state = self._wrapper.get_state() 62 | return state is not None 63 | 64 | 65 | class WinixManager(DataUpdateCoordinator): 66 | """Representation of the Winix device manager.""" 67 | 68 | def __init__( 69 | self, 70 | hass: HomeAssistant, 71 | entry: ConfigEntry, 72 | auth_response: auth.WinixAuthResponse, 73 | scan_interval: int, 74 | ) -> None: 75 | """Initialize the manager.""" 76 | 77 | # Always initialize _device_wrappers in case async_prepare_devices_wrappers 78 | # was not invoked. 79 | self._device_wrappers: list[WinixDeviceWrapper] = [] 80 | self._auth_response = auth_response 81 | 82 | super().__init__( 83 | hass, 84 | LOGGER, 85 | name="WinixManager", 86 | update_interval=timedelta(seconds=scan_interval), 87 | config_entry=entry, 88 | ) 89 | 90 | async def _async_update_data(self) -> None: 91 | """Fetch the latest data from the source. This overrides the method in DataUpdateCoordinator.""" 92 | await self.async_update() 93 | 94 | def prepare_devices_wrappers(self, access_token: str = "") -> None: 95 | """Prepare device wrappers. 96 | 97 | Raises WinixException. 98 | """ 99 | 100 | self._device_wrappers = [] # Reset device_stubs 101 | 102 | device_stubs = Helpers.get_device_stubs( 103 | self.hass, access_token or self._auth_response.access_token 104 | ) 105 | 106 | if device_stubs: 107 | client = aiohttp_client.async_get_clientsession(self.hass) 108 | 109 | for device_stub in device_stubs: 110 | self._device_wrappers.append( 111 | WinixDeviceWrapper(client, device_stub, LOGGER) 112 | ) 113 | 114 | LOGGER.info("%d purifiers found", len(self._device_wrappers)) 115 | else: 116 | LOGGER.info("No purifiers found") 117 | 118 | async def async_update(self, now=None) -> None: 119 | """Asynchronously update all the devices.""" 120 | LOGGER.info("Updating devices") 121 | for device_wrapper in self._device_wrappers: 122 | await device_wrapper.update() 123 | 124 | def get_device_wrappers(self) -> list[WinixDeviceWrapper]: 125 | """Return the device wrapper objects.""" 126 | return self._device_wrappers 127 | -------------------------------------------------------------------------------- /custom_components/winix/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "winix", 3 | "name": "Winix Air Purifier", 4 | "documentation": "https://github.com/iprak/winix", 5 | "requirements": [ 6 | "winix==0.3.0" 7 | ], 8 | "dependencies": [], 9 | "codeowners": [ 10 | "@iprak" 11 | ], 12 | "issue_tracker": "https://github.com/iprak/winix/issues", 13 | "version": "1.2.3", 14 | "config_flow": true, 15 | "iot_class": "cloud_polling" 16 | } -------------------------------------------------------------------------------- /custom_components/winix/select.py: -------------------------------------------------------------------------------- 1 | """Support for Winix select entities.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any, Final 6 | 7 | from homeassistant.components.select import ( 8 | DOMAIN as SELECT_DOMAIN, 9 | SelectEntity, 10 | SelectEntityDescription, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 15 | 16 | from . import WINIX_DOMAIN 17 | from .const import LOGGER, WINIX_DATA_COORDINATOR 18 | from .device_wrapper import WinixDeviceWrapper 19 | from .driver import BrightnessLevel 20 | from .manager import WinixEntity, WinixManager 21 | 22 | 23 | @dataclass(frozen=True, kw_only=True) 24 | class WinixSelectEntityDescription(SelectEntityDescription): 25 | """A class that describes custom select entities.""" 26 | 27 | exists_fn: Callable[[WinixDeviceWrapper], bool] 28 | current_option_fn: Callable[[WinixDeviceWrapper], str] 29 | select_option_fn: Callable[[WinixDeviceWrapper, str], Coroutine[Any, Any, Any]] 30 | 31 | 32 | def format_brightness_level(value: int | None) -> str: 33 | """Format numeric brightness level into select option.""" 34 | return None if value is None else f"{value} %" 35 | 36 | 37 | def parse_brightness_level(value: str) -> int: 38 | """Parse brightness level into integer equivalent.""" 39 | if value: 40 | value = value[:-1] # Remove % 41 | return int(value) 42 | 43 | return 0 44 | 45 | 46 | BRIGHTNESS_OPTIONS = [format_brightness_level(e.value) for e in BrightnessLevel] 47 | 48 | SELECT_DESCRIPTIONS: Final[tuple[WinixSelectEntityDescription, ...]] = ( 49 | WinixSelectEntityDescription( 50 | current_option_fn=lambda device: format_brightness_level( 51 | device.brightness_level 52 | ), 53 | exists_fn=lambda device: device.features.supports_brightness_level, 54 | icon="mdi:brightness-6", 55 | key="brightness_level", 56 | name="Brightness level", 57 | options=BRIGHTNESS_OPTIONS, 58 | select_option_fn=lambda device, value: device.async_set_brightness_level( 59 | parse_brightness_level(value) 60 | ), 61 | ), 62 | ) 63 | 64 | 65 | async def async_setup_entry( 66 | hass: HomeAssistant, 67 | entry: ConfigEntry, 68 | async_add_entities: AddConfigEntryEntitiesCallback, 69 | ) -> None: 70 | """Set up select platform.""" 71 | 72 | data = hass.data[WINIX_DOMAIN][entry.entry_id] 73 | manager: WinixManager = data[WINIX_DATA_COORDINATOR] 74 | 75 | entities = [ 76 | WinixSelectEntity(wrapper, manager, description) 77 | for description in SELECT_DESCRIPTIONS 78 | for wrapper in manager.get_device_wrappers() 79 | if description.exists_fn(wrapper) 80 | ] 81 | async_add_entities(entities) 82 | LOGGER.info("Added %s selects", len(entities)) 83 | 84 | 85 | class WinixSelectEntity(WinixEntity, SelectEntity): 86 | """Winix select entity class.""" 87 | 88 | entity_description: WinixSelectEntityDescription 89 | 90 | def __init__( 91 | self, 92 | wrapper: WinixDeviceWrapper, 93 | coordinator: WinixManager, 94 | description: WinixSelectEntityDescription, 95 | ) -> None: 96 | """Initialize the select.""" 97 | super().__init__(wrapper, coordinator) 98 | self.entity_description = description 99 | 100 | self._attr_unique_id = ( 101 | f"{SELECT_DOMAIN}.{WINIX_DOMAIN}_{description.key.lower()}_{self._mac}" 102 | ) 103 | 104 | @property 105 | def current_option(self) -> str | None: 106 | """Return the entity value.""" 107 | return self.entity_description.current_option_fn(self._wrapper) 108 | 109 | async def async_select_option(self, option: str) -> None: 110 | """Set the entity value.""" 111 | if await self.entity_description.select_option_fn(self._wrapper, option): 112 | await self.coordinator.async_request_refresh() 113 | -------------------------------------------------------------------------------- /custom_components/winix/sensor.py: -------------------------------------------------------------------------------- 1 | """Winix Air Purfier Air QValue Sensor.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Mapping 6 | from dataclasses import dataclass 7 | from typing import Any, Final 8 | 9 | from homeassistant.components.sensor import ( 10 | DOMAIN as SENSOR_DOMAIN, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import PERCENTAGE 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | from homeassistant.helpers.typing import StateType 20 | 21 | from . import WINIX_DOMAIN 22 | from .const import ( 23 | ATTR_AIR_AQI, 24 | ATTR_AIR_QUALITY, 25 | ATTR_AIR_QVALUE, 26 | ATTR_FILTER_HOUR, 27 | LOGGER, 28 | SENSOR_AIR_QVALUE, 29 | SENSOR_AQI, 30 | SENSOR_FILTER_LIFE, 31 | WINIX_DATA_COORDINATOR, 32 | ) 33 | from .device_wrapper import WinixDeviceWrapper 34 | from .manager import WinixEntity, WinixManager 35 | 36 | TOTAL_FILTER_LIFE: Final = 6480 # 9 months 37 | 38 | 39 | def get_air_quality_attr(state: dict[str, str]) -> dict[str, Any]: 40 | """Get air quality attribute.""" 41 | 42 | attributes = {ATTR_AIR_QUALITY: None} 43 | if state is not None: 44 | attributes[ATTR_AIR_QUALITY] = state.get(ATTR_AIR_QUALITY) 45 | 46 | return attributes 47 | 48 | 49 | def get_filter_life(state: dict[str, str]) -> int | None: 50 | """Get filter life percentage.""" 51 | 52 | return get_filter_life_percentage(state.get(ATTR_FILTER_HOUR)) 53 | 54 | 55 | def get_filter_life_percentage(hours: str | None) -> int | None: 56 | """Get filter life percentage.""" 57 | 58 | if hours is None: 59 | return None 60 | 61 | hours: int = int(hours) 62 | if hours > TOTAL_FILTER_LIFE: 63 | LOGGER.warning( 64 | "Reported filter life '%d' is more than max value '%d'", 65 | hours, 66 | TOTAL_FILTER_LIFE, 67 | ) 68 | return None 69 | 70 | return int((TOTAL_FILTER_LIFE - hours) * 100 / TOTAL_FILTER_LIFE) 71 | 72 | 73 | @dataclass(frozen=True, kw_only=True) 74 | class WininxSensorEntityDescription(SensorEntityDescription): 75 | """Describe Winix sensor entity.""" 76 | 77 | value_fn: Callable[[dict[str, str]], StateType] 78 | extra_state_attributes_fn: Callable[[dict[str, str]], dict[str, Any]] 79 | 80 | 81 | SENSOR_DESCRIPTIONS: tuple[WininxSensorEntityDescription, ...] = ( 82 | WininxSensorEntityDescription( 83 | key=SENSOR_AIR_QVALUE, 84 | icon="mdi:cloud", 85 | name="Air QValue", 86 | native_unit_of_measurement="qv", 87 | state_class=SensorStateClass.MEASUREMENT, 88 | value_fn=lambda state: state.get(ATTR_AIR_QVALUE), 89 | extra_state_attributes_fn=get_air_quality_attr, 90 | ), 91 | WininxSensorEntityDescription( 92 | key=SENSOR_FILTER_LIFE, 93 | icon="mdi:air-filter", 94 | name="Filter Life", 95 | native_unit_of_measurement=PERCENTAGE, 96 | state_class=SensorStateClass.MEASUREMENT, 97 | value_fn=get_filter_life, 98 | extra_state_attributes_fn=None, 99 | ), 100 | WininxSensorEntityDescription( 101 | key=SENSOR_AQI, 102 | icon="mdi:blur", 103 | name="AQI", 104 | state_class=SensorStateClass.MEASUREMENT, 105 | value_fn=lambda state: state.get(ATTR_AIR_AQI), 106 | extra_state_attributes_fn=None, 107 | ), 108 | ) 109 | 110 | 111 | async def async_setup_entry( 112 | hass: HomeAssistant, 113 | entry: ConfigEntry, 114 | async_add_entities: AddEntitiesCallback, 115 | ) -> None: 116 | """Set up the Winix sensors.""" 117 | data = hass.data[WINIX_DOMAIN][entry.entry_id] 118 | manager: WinixManager = data[WINIX_DATA_COORDINATOR] 119 | 120 | entities = [ 121 | WinixSensor(wrapper, manager, description) 122 | for description in SENSOR_DESCRIPTIONS 123 | for wrapper in manager.get_device_wrappers() 124 | ] 125 | async_add_entities(entities) 126 | LOGGER.info("Added %s sensors", len(entities)) 127 | 128 | 129 | class WinixSensor(WinixEntity, SensorEntity): 130 | """Representation of a Winix Purifier sensor.""" 131 | 132 | entity_description: WininxSensorEntityDescription 133 | 134 | def __init__( 135 | self, 136 | wrapper: WinixDeviceWrapper, 137 | coordinator: WinixManager, 138 | description: WininxSensorEntityDescription, 139 | ) -> None: 140 | """Initialize the sensor.""" 141 | super().__init__(wrapper, coordinator) 142 | self.entity_description = description 143 | 144 | self._attr_unique_id = ( 145 | f"{SENSOR_DOMAIN}.{WINIX_DOMAIN}_{description.key.lower()}_{self._mac}" 146 | ) 147 | 148 | @property 149 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 150 | """Return the state attributes.""" 151 | 152 | if self.entity_description.extra_state_attributes_fn is None: 153 | return None 154 | 155 | state = self._wrapper.get_state() 156 | return ( 157 | None 158 | if state is None 159 | else self.entity_description.extra_state_attributes_fn(state) 160 | ) 161 | 162 | @property 163 | def native_value(self) -> StateType: 164 | """Return the state of the sensor.""" 165 | state = self._wrapper.get_state() 166 | return None if state is None else self.entity_description.value_fn(state) 167 | -------------------------------------------------------------------------------- /custom_components/winix/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format of available services. 2 | 3 | plasmawave_on: 4 | description: Turn on plasmawave. 5 | fields: 6 | entity_id: 7 | description: Name(s) of Winix entities. Leave service data empty to use all Winix entities. 8 | example: "fan.winix_living_room" 9 | 10 | plasmawave_off: 11 | description: Turn off plasmawave. 12 | fields: 13 | entity_id: 14 | description: Name(s) of Winix entities. Leave service data empty to use all Winix entities. 15 | example: "fan.winix_living_room" 16 | 17 | plasmawave_toggle: 18 | description: Toggle plasmawave. 19 | fields: 20 | entity_id: 21 | description: Name(s) of Winix entities. Leave service data empty to use all Winix entities. 22 | example: "fan.winix_living_room" 23 | 24 | remove_stale_entities: 25 | description: Remove Winix entities with unavailable state and associated devices. 26 | -------------------------------------------------------------------------------- /custom_components/winix/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 | "description": "Set up Winix integration. Login with your mobile app credentials.", 10 | "title": "Winix Air Purifier" 11 | } 12 | }, 13 | "error": { 14 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 15 | "invalid_user": "[%key:common::config_flow::error::invalid_user%]", 16 | "unknown": "[%key:common::config_flow::error::unknown%]" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /custom_components/winix/switch.py: -------------------------------------------------------------------------------- 1 | """Support for Winix switches.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any, Final 6 | 7 | from homeassistant.components.switch import ( 8 | DOMAIN as SWITCH_DOMAIN, 9 | SwitchEntity, 10 | SwitchEntityDescription, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 15 | 16 | from . import WINIX_DOMAIN 17 | from .const import LOGGER, WINIX_DATA_COORDINATOR 18 | from .device_wrapper import WinixDeviceWrapper 19 | from .manager import WinixEntity, WinixManager 20 | 21 | 22 | @dataclass(frozen=True, kw_only=True) 23 | class WinixSwitchEntityDescription(SwitchEntityDescription): 24 | """A class that describes custom switch entities.""" 25 | 26 | is_on: Callable[[WinixDeviceWrapper], bool] 27 | exists_fn: Callable[[WinixDeviceWrapper], bool] 28 | on_fn: Callable[[WinixDeviceWrapper], Coroutine[Any, Any, bool]] 29 | off_fn: Callable[[WinixDeviceWrapper], Coroutine[Any, Any, bool]] 30 | 31 | 32 | SWITCH_DESCRIPTIONS: Final[tuple[WinixSwitchEntityDescription, ...]] = ( 33 | WinixSwitchEntityDescription( 34 | key="child_lock", 35 | is_on=lambda device: device.is_child_lock_on, 36 | exists_fn=lambda device: device.features.supports_child_lock, 37 | name="Child lock", 38 | on_fn=lambda device: device.async_child_lock_on(), 39 | off_fn=lambda device: device.async_child_lock_off(), 40 | ), 41 | ) 42 | 43 | 44 | async def async_setup_entry( 45 | hass: HomeAssistant, 46 | entry: ConfigEntry, 47 | async_add_entities: AddConfigEntryEntitiesCallback, 48 | ) -> None: 49 | """Set up switch platform.""" 50 | 51 | data = hass.data[WINIX_DOMAIN][entry.entry_id] 52 | manager: WinixManager = data[WINIX_DATA_COORDINATOR] 53 | 54 | entities = [ 55 | WinixSwitchEntity(wrapper, manager, description) 56 | for description in SWITCH_DESCRIPTIONS 57 | for wrapper in manager.get_device_wrappers() 58 | if description.exists_fn(wrapper) 59 | ] 60 | async_add_entities(entities) 61 | LOGGER.info("Added %s switches", len(entities)) 62 | 63 | 64 | class WinixSwitchEntity(WinixEntity, SwitchEntity): 65 | """Winix switch entity class.""" 66 | 67 | entity_description: WinixSwitchEntityDescription 68 | 69 | def __init__( 70 | self, 71 | wrapper: WinixDeviceWrapper, 72 | coordinator: WinixManager, 73 | description: WinixSwitchEntityDescription, 74 | ) -> None: 75 | """Initialize the switch.""" 76 | super().__init__(wrapper, coordinator) 77 | self.entity_description = description 78 | 79 | self._attr_unique_id = ( 80 | f"{SWITCH_DOMAIN}.{WINIX_DOMAIN}_{description.key.lower()}_{self._mac}" 81 | ) 82 | 83 | @property 84 | def is_on(self) -> bool | None: 85 | """Return the switch state.""" 86 | return self.entity_description.is_on(self._wrapper) 87 | 88 | async def async_turn_off(self, **kwargs: Any) -> None: 89 | """Turn the entity off.""" 90 | if await self.entity_description.off_fn(self._wrapper): 91 | self.schedule_update_ha_state() 92 | 93 | async def async_turn_on(self, **kwargs: Any) -> None: 94 | """Turn the entity on.""" 95 | if await self.entity_description.on_fn(self._wrapper): 96 | self.schedule_update_ha_state() 97 | -------------------------------------------------------------------------------- /custom_components/winix/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "Username", 7 | "password": "Password" 8 | }, 9 | "description": "Set up Winix integration. Login with your mobile app credentials.", 10 | "title": "Winix Air Purifier" 11 | } 12 | }, 13 | "error": { 14 | "invalid_auth": "Invalid credentials", 15 | "invalid_user": "Invalid user", 16 | "unknown": "Unexpected error" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Winix Purifier", 3 | "render_readme": true, 4 | "homeassistant": "2024.8.0" 5 | } 6 | -------------------------------------------------------------------------------- /images/entity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iprak/winix/530a921e70d0fd4d3085811f7df8c84965c333ad/images/entity.png -------------------------------------------------------------------------------- /requirements_component.txt: -------------------------------------------------------------------------------- 1 | # Custom requirements 2 | winix==0.3.0 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Winix component.""" 2 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Tests for Winix component.""" 2 | 3 | from unittest.mock import MagicMock, Mock, patch 4 | 5 | from pytest_homeassistant_custom_component.common import MockConfigEntry 6 | from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker 7 | from voluptuous.validators import Number 8 | 9 | from custom_components.winix.const import WINIX_AUTH_RESPONSE, WINIX_DOMAIN 10 | from custom_components.winix.device_wrapper import MyWinixDeviceStub, WinixDeviceWrapper 11 | from custom_components.winix.fan import WinixPurifier 12 | from custom_components.winix.manager import WinixManager 13 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 14 | from homeassistant.core import HomeAssistant 15 | 16 | TEST_DEVICE_ID = "847207352CE0_364yr8i989" 17 | 18 | 19 | def config_entry(hass: HomeAssistant) -> MockConfigEntry: 20 | """Create a mock config entry.""" 21 | user_input = { 22 | WINIX_AUTH_RESPONSE: { 23 | "user_id": "user_id", 24 | "access_token": "access_token", 25 | "refresh_token": "refresh_token", 26 | "id_token": "id_token", 27 | }, 28 | CONF_USERNAME: "username", 29 | CONF_PASSWORD: "password", 30 | } 31 | 32 | entry = MockConfigEntry( 33 | domain=WINIX_DOMAIN, 34 | data=user_input, 35 | ) 36 | entry.add_to_hass(hass) 37 | return entry 38 | 39 | 40 | async def init_integration( 41 | hass: HomeAssistant, 42 | test_device_stub: MyWinixDeviceStub, 43 | test_device_json: any, 44 | aioclient_mock: AiohttpClientMocker, 45 | ) -> MockConfigEntry: 46 | """Prepare the integration.""" 47 | 48 | entry = config_entry(hass) 49 | 50 | aioclient_mock.get( 51 | f"https://us.api.winix-iot.com/common/event/sttus/devices/{TEST_DEVICE_ID}", 52 | json=test_device_json, 53 | ) 54 | 55 | with ( 56 | patch( 57 | "custom_components.winix.Helpers.get_device_stubs", 58 | return_value=[test_device_stub], 59 | ), 60 | ): 61 | await hass.config_entries.async_setup(entry.entry_id) 62 | await hass.async_block_till_done() 63 | 64 | return entry 65 | 66 | 67 | def build_mock_wrapper(index: Number = 0) -> WinixDeviceWrapper: 68 | """Return a mocked WinixDeviceWrapper instance.""" 69 | client = Mock() 70 | 71 | device_stub = Mock() 72 | 73 | device_stub.mac = f"f190d35456d{index}" 74 | device_stub.alias = f"Purifier{index}" 75 | 76 | logger = Mock() 77 | logger.debug = Mock() 78 | logger.warning = Mock() 79 | 80 | return WinixDeviceWrapper(client, device_stub, logger) 81 | 82 | 83 | def build_fake_manager(wrapper_count: Number) -> WinixManager: 84 | """Return a mocked WinixManager instance.""" 85 | wrappers = [] 86 | 87 | # Prepare fake wrappers 88 | wrappers = {build_mock_wrapper(index) for index in range(wrapper_count)} 89 | 90 | manager = MagicMock() 91 | manager.get_device_wrappers = Mock(return_value=wrappers) 92 | return manager 93 | 94 | 95 | def build_purifier( 96 | hass: HomeAssistant, device_wrapper: WinixDeviceWrapper 97 | ) -> WinixPurifier: 98 | """Return a WinixPurifier instance.""" 99 | 100 | device = WinixPurifier(device_wrapper, Mock()) 101 | device.add_to_platform_start(hass, None, None) 102 | 103 | # Use unique_id as entity_id, this is required for async_update_ha_state 104 | device.entity_id = device.unique_id 105 | return device 106 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Tests for Winixdevice component.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock, Mock 4 | 5 | import pytest 6 | 7 | from custom_components.winix.device_wrapper import MyWinixDeviceStub, WinixDeviceWrapper 8 | from custom_components.winix.driver import WinixDriver 9 | 10 | from .common import TEST_DEVICE_ID 11 | 12 | 13 | @pytest.fixture 14 | async def device_stub() -> MyWinixDeviceStub: 15 | """Build mocked device stub.""" 16 | 17 | return MyWinixDeviceStub( 18 | id=TEST_DEVICE_ID, 19 | mac="mac", 20 | alias="deviceAlias", 21 | location_code="deviceLocCode", 22 | filter_replace_date="filterReplaceDate", 23 | model="modelName", 24 | sw_version="mcuVer", 25 | ) 26 | 27 | 28 | @pytest.fixture 29 | async def device_data() -> any: 30 | """Get mocked device data.""" 31 | 32 | filter_life_hours = "1257" 33 | air_qvalue = "74" 34 | aqi = "01" 35 | 36 | return { 37 | "statusCode": 200, 38 | "headers": {"resultCode": "S100", "resultMessage": ""}, 39 | "body": { 40 | "deviceId": TEST_DEVICE_ID, 41 | "totalCnt": 1, 42 | "data": [ 43 | { 44 | "apiNo": "A210", 45 | "apiGroup": "001", 46 | "deviceGroup": "Air01", 47 | "modelId": "C545", 48 | "attributes": { 49 | "A02": "0", 50 | "A03": "01", 51 | "A04": "01", 52 | "A05": aqi, 53 | "A07": "0", 54 | "A21": filter_life_hours, 55 | "S07": "01", 56 | "S08": air_qvalue, 57 | "S14": "121", 58 | }, 59 | "rssi": "-55", 60 | "creationTime": 1673449200634, 61 | "utcDatetime": "2023-01-11 15:00:00", 62 | "utcTimestamp": 1673449200, 63 | } 64 | ], 65 | }, 66 | } 67 | 68 | 69 | @pytest.fixture 70 | def mock_device_wrapper() -> WinixDeviceWrapper: 71 | """Return a mocked WinixDeviceWrapper instance.""" 72 | 73 | device_wrapper = MagicMock() 74 | device_wrapper.device_stub.mac = "f190d35456d0" 75 | device_wrapper.device_stub.alias = "Purifier1" 76 | 77 | device_wrapper.async_plasmawave_off = AsyncMock() 78 | device_wrapper.async_plasmawave_on = AsyncMock() 79 | device_wrapper.async_set_preset_mode = AsyncMock() 80 | device_wrapper.async_set_speed = AsyncMock() 81 | device_wrapper.async_turn_on = AsyncMock() 82 | 83 | return device_wrapper 84 | 85 | 86 | @pytest.fixture 87 | def mock_driver() -> WinixDriver: 88 | """Return a mocked WinixDriver instance.""" 89 | client = Mock() 90 | device_id = "device_1" 91 | return WinixDriver(device_id, client) 92 | 93 | 94 | @pytest.fixture 95 | def mock_driver_with_payload(request) -> WinixDriver: 96 | """Return a mocked WinixDriver instance.""" 97 | 98 | json_value = {"body": {"data": [{"attributes": request.param}]}} 99 | 100 | response = Mock() 101 | response.json = AsyncMock(return_value=json_value) 102 | response.status = 200 103 | 104 | client = Mock() # aiohttp.ClientSession 105 | client.get = AsyncMock(return_value=response) 106 | 107 | device_id = "device_1" 108 | return WinixDriver(device_id, client) 109 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test config flow.""" 2 | 3 | from unittest.mock import AsyncMock, patch 4 | 5 | from custom_components.winix.const import WINIX_DOMAIN 6 | from custom_components.winix.helpers import WinixException 7 | from homeassistant import data_entry_flow 8 | from homeassistant.config_entries import SOURCE_USER 9 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 10 | from homeassistant.core import HomeAssistant 11 | 12 | TEST_USER_DATA = { 13 | CONF_USERNAME: "user_name", 14 | CONF_PASSWORD: "password", 15 | } 16 | 17 | LOGIN_AUTH_RESPONSE = { 18 | "user_id": "test_userid", 19 | "access_token": "AccessToken", 20 | "refresh_token": "RefreshToken", 21 | "id_token": "IdToken", 22 | } 23 | 24 | 25 | async def test_form(hass: HomeAssistant, enable_custom_integrations) -> None: 26 | """Test that form shows up.""" 27 | 28 | result = await hass.config_entries.flow.async_init( 29 | WINIX_DOMAIN, context={"source": SOURCE_USER} 30 | ) 31 | assert result["type"] == data_entry_flow.FlowResultType.FORM 32 | assert result["errors"] == {} 33 | 34 | 35 | async def test_invalid_user(hass: HomeAssistant, enable_custom_integrations) -> None: 36 | """Test user validation in form.""" 37 | 38 | with patch( 39 | "custom_components.winix.Helpers.async_login", 40 | side_effect=WinixException( 41 | {"result_code": "UserNotFoundException", "message": "User not found"} 42 | ), 43 | ): 44 | result = await hass.config_entries.flow.async_init( 45 | WINIX_DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_DATA 46 | ) 47 | assert result["errors"]["base"] == "invalid_user" 48 | assert result["step_id"] == "user" 49 | assert result["type"] == data_entry_flow.FlowResultType.FORM 50 | 51 | 52 | async def test_invalid_authentication( 53 | hass: HomeAssistant, enable_custom_integrations 54 | ) -> None: 55 | """Test user authentication in form.""" 56 | 57 | with patch( 58 | "custom_components.winix.Helpers.async_login", 59 | side_effect=WinixException( 60 | {"result_code": "failure", "message": "Authentication failed"} 61 | ), 62 | ): 63 | result = await hass.config_entries.flow.async_init( 64 | WINIX_DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_DATA 65 | ) 66 | 67 | assert result["errors"]["base"] == "invalid_auth" 68 | assert result["step_id"] == "user" 69 | assert result["type"] == data_entry_flow.FlowResultType.FORM 70 | 71 | 72 | async def test_create_entry(hass: HomeAssistant, enable_custom_integrations) -> None: 73 | """Test that entry is created.""" 74 | 75 | with patch( 76 | "custom_components.winix.Helpers.async_login", 77 | side_effect=AsyncMock(return_value=LOGIN_AUTH_RESPONSE), 78 | ): 79 | result = await hass.config_entries.flow.async_init( 80 | WINIX_DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_DATA 81 | ) 82 | 83 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 84 | -------------------------------------------------------------------------------- /tests/test_device_wrapper.py: -------------------------------------------------------------------------------- 1 | """Test WinixDeviceWrapper component.""" 2 | 3 | from unittest.mock import AsyncMock, Mock, patch 4 | 5 | import pytest 6 | 7 | from custom_components.winix.const import ( 8 | AIRFLOW_HIGH, 9 | AIRFLOW_LOW, 10 | AIRFLOW_SLEEP, 11 | ATTR_AIRFLOW, 12 | ATTR_MODE, 13 | ATTR_PLASMA, 14 | ATTR_POWER, 15 | MODE_AUTO, 16 | MODE_MANUAL, 17 | OFF_VALUE, 18 | ON_VALUE, 19 | PRESET_MODE_AUTO, 20 | PRESET_MODE_AUTO_PLASMA_OFF, 21 | PRESET_MODE_MANUAL, 22 | PRESET_MODE_MANUAL_PLASMA_OFF, 23 | PRESET_MODE_SLEEP, 24 | NumericPresetModes, 25 | ) 26 | from custom_components.winix.device_wrapper import WinixDeviceWrapper 27 | 28 | from .common import build_mock_wrapper 29 | 30 | WinixDriver_TypeName = "custom_components.winix.driver.WinixDriver" 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("mock_state", "is_auto", "is_manual", "is_on", "is_plasma_on", "is_sleep"), 35 | [ 36 | # On 37 | ({}, False, False, False, False, False), 38 | # On, plasma on 39 | ({ATTR_POWER: ON_VALUE}, False, False, True, False, False), 40 | ( 41 | {ATTR_POWER: ON_VALUE, ATTR_PLASMA: ON_VALUE}, 42 | False, 43 | False, 44 | True, 45 | True, 46 | False, 47 | ), 48 | # On, auto 49 | ( 50 | {ATTR_POWER: ON_VALUE, ATTR_MODE: MODE_AUTO}, 51 | True, 52 | False, 53 | True, 54 | False, 55 | False, 56 | ), 57 | # On, manual 58 | ( 59 | {ATTR_POWER: ON_VALUE, ATTR_MODE: MODE_MANUAL}, 60 | False, 61 | True, 62 | True, 63 | False, 64 | False, 65 | ), 66 | # On, sleep 67 | ( 68 | {ATTR_POWER: ON_VALUE, ATTR_AIRFLOW: AIRFLOW_SLEEP}, 69 | False, 70 | False, 71 | True, 72 | False, 73 | True, 74 | ), 75 | ], 76 | ) 77 | async def test_wrapper_update( 78 | mock_state, is_auto, is_manual, is_on, is_plasma_on, is_sleep 79 | ) -> None: 80 | """Tests device wrapper states.""" 81 | 82 | with patch( 83 | f"{WinixDriver_TypeName}.get_state", 84 | AsyncMock(return_value=mock_state), 85 | ) as get_state: 86 | wrapper = build_mock_wrapper() 87 | await wrapper.update() 88 | assert get_state.call_count == 1 89 | assert wrapper.get_state() == mock_state 90 | 91 | assert wrapper.is_auto == is_auto 92 | assert wrapper.is_manual == is_manual 93 | assert wrapper.is_on == is_on 94 | assert wrapper.is_plasma_on == is_plasma_on 95 | assert wrapper.is_sleep == is_sleep 96 | 97 | 98 | async def test_async_ensure_on() -> None: 99 | """Test ensuring device is on.""" 100 | with patch(f"{WinixDriver_TypeName}.turn_on") as turn_on: 101 | wrapper = build_mock_wrapper() 102 | assert not wrapper.is_on # initially off 103 | 104 | await wrapper.async_ensure_on() 105 | assert wrapper.is_on 106 | assert turn_on.call_count == 1 107 | 108 | await wrapper.async_ensure_on() # Test turning it on again 109 | assert turn_on.call_count == 1 # Should not do anything 110 | 111 | 112 | async def test_async_turn_off() -> None: 113 | """Test turning off.""" 114 | with ( 115 | patch(f"{WinixDriver_TypeName}.turn_on") as turn_on, 116 | patch(f"{WinixDriver_TypeName}.turn_off") as turn_off, 117 | ): 118 | wrapper = build_mock_wrapper() 119 | assert not wrapper.is_on # initially off 120 | 121 | await wrapper.async_ensure_on() 122 | assert wrapper.is_on 123 | assert turn_on.call_count == 1 124 | assert turn_off.call_count == 0 125 | 126 | await wrapper.async_ensure_on() # Test turning it on again 127 | assert turn_on.call_count == 1 # Should not do anything 128 | assert turn_off.call_count == 0 129 | 130 | await wrapper.async_turn_off() # Turn it off 131 | assert turn_on.call_count == 1 132 | assert turn_off.call_count == 1 133 | 134 | await wrapper.async_turn_off() # Test turning it off agian 135 | 136 | assert turn_on.call_count == 1 137 | assert turn_off.call_count == 1 # Should not do anything 138 | 139 | 140 | async def test_async_turn_on() -> None: 141 | """Test turning on.""" 142 | wrapper = build_mock_wrapper() 143 | 144 | wrapper.async_ensure_on = AsyncMock() 145 | wrapper.async_auto = AsyncMock() 146 | 147 | await wrapper.async_turn_on() 148 | 149 | assert wrapper.async_ensure_on.call_count == 1 150 | assert wrapper.async_auto.call_count == 1 151 | 152 | 153 | async def test_async_auto() -> None: 154 | """Test setting auto mode.""" 155 | 156 | # async_auto does not need the device to be turned on 157 | with patch(f"{WinixDriver_TypeName}.auto") as auto: 158 | wrapper = build_mock_wrapper() 159 | 160 | await wrapper.async_auto() 161 | assert auto.call_count == 1 162 | 163 | assert wrapper.is_auto 164 | assert not wrapper.is_manual 165 | assert not wrapper.is_plasma_on # unchanged 166 | assert not wrapper.is_sleep 167 | assert wrapper.get_state().get(ATTR_AIRFLOW) == AIRFLOW_LOW 168 | 169 | await wrapper.async_auto() # Calling again should not do anything 170 | assert auto.call_count == 1 171 | 172 | 173 | async def test_async_plasmawave_on_off() -> None: 174 | """Test turning plasmawave on.""" 175 | 176 | # async_plasmawave does not need the device to be turned on 177 | with ( 178 | patch(f"{WinixDriver_TypeName}.plasmawave_on") as plasmawave_on, 179 | patch(f"{WinixDriver_TypeName}.plasmawave_off") as plasmawave_off, 180 | ): 181 | wrapper = build_mock_wrapper() 182 | 183 | await wrapper.async_plasmawave_on() 184 | assert plasmawave_on.call_count == 1 185 | assert plasmawave_off.call_count == 0 186 | 187 | assert wrapper.is_plasma_on 188 | assert wrapper.get_state().get(ATTR_PLASMA) == ON_VALUE 189 | 190 | await wrapper.async_plasmawave_on() # Calling again should not do anything 191 | assert plasmawave_on.call_count == 1 192 | assert plasmawave_off.call_count == 0 193 | 194 | await wrapper.async_plasmawave_off() # Turn plasma off 195 | assert not wrapper.is_plasma_on 196 | assert wrapper.get_state().get(ATTR_PLASMA) == OFF_VALUE 197 | assert plasmawave_on.call_count == 1 198 | assert plasmawave_off.call_count == 1 199 | 200 | await wrapper.async_plasmawave_off() # Calling again should not do anything 201 | assert plasmawave_on.call_count == 1 202 | assert plasmawave_off.call_count == 1 203 | 204 | 205 | async def test_async_manual() -> None: 206 | """Test setting manual mode.""" 207 | 208 | # async_manual does not need the device to be turned on 209 | with patch(f"{WinixDriver_TypeName}.manual") as manual: 210 | wrapper = build_mock_wrapper() 211 | 212 | await wrapper.async_manual() 213 | assert manual.call_count == 1 214 | 215 | assert not wrapper.is_auto 216 | assert wrapper.is_manual 217 | assert not wrapper.is_plasma_on # unchanged 218 | assert not wrapper.is_sleep 219 | assert wrapper.get_state().get(ATTR_MODE) == MODE_MANUAL 220 | assert wrapper.get_state().get(ATTR_AIRFLOW) == AIRFLOW_LOW 221 | 222 | await wrapper.async_manual() # Calling again should not do anything 223 | assert manual.call_count == 1 224 | 225 | 226 | async def test_async_sleep() -> None: 227 | """Test setting sleep mode.""" 228 | 229 | # async_sleep does not need the device to be turned on 230 | with patch(f"{WinixDriver_TypeName}.sleep") as sleep: 231 | wrapper = build_mock_wrapper() 232 | 233 | await wrapper.async_sleep() 234 | assert sleep.call_count == 1 235 | 236 | assert not wrapper.is_auto 237 | assert not wrapper.is_manual 238 | assert not wrapper.is_plasma_on 239 | assert wrapper.is_sleep 240 | assert wrapper.get_state().get(ATTR_MODE) == MODE_MANUAL 241 | assert wrapper.get_state().get(ATTR_AIRFLOW) == AIRFLOW_SLEEP 242 | 243 | await wrapper.async_sleep() # Calling again should not do anything 244 | assert sleep.call_count == 1 245 | 246 | 247 | async def test_async_set_speed() -> None: 248 | """Test setting speed.""" 249 | 250 | with ( 251 | patch(f"{WinixDriver_TypeName}.turn_on"), 252 | patch(f"{WinixDriver_TypeName}.manual"), 253 | patch(f"{WinixDriver_TypeName}.high") as high_speed, 254 | patch(f"{WinixDriver_TypeName}.low") as low_speed, 255 | ): 256 | wrapper = build_mock_wrapper() 257 | 258 | await wrapper.async_set_speed(AIRFLOW_LOW) 259 | assert high_speed.call_count == 0 260 | assert low_speed.call_count == 1 261 | assert wrapper.is_on 262 | assert not wrapper.is_auto 263 | assert wrapper.is_manual 264 | 265 | # Calling again at same speed does nothing 266 | await wrapper.async_set_speed(AIRFLOW_LOW) 267 | assert high_speed.call_count == 0 268 | assert low_speed.call_count == 1 269 | assert wrapper.is_on 270 | assert not wrapper.is_auto 271 | assert wrapper.is_manual 272 | 273 | # Setting a different speed 274 | await wrapper.async_set_speed(AIRFLOW_HIGH) 275 | assert high_speed.call_count == 1 276 | assert low_speed.call_count == 1 277 | assert wrapper.is_on 278 | assert not wrapper.is_auto 279 | assert wrapper.is_manual 280 | 281 | 282 | @pytest.mark.parametrize( 283 | ("preset_mode", "sleep", "auto", "manual", "plasmawave_off", "plasmawave_on"), 284 | [ 285 | (PRESET_MODE_SLEEP, 1, 0, 0, 0, 0), 286 | (PRESET_MODE_AUTO, 0, 1, 0, 0, 1), 287 | (PRESET_MODE_AUTO_PLASMA_OFF, 0, 1, 0, 1, 0), 288 | (PRESET_MODE_MANUAL, 0, 0, 1, 0, 1), 289 | (PRESET_MODE_MANUAL_PLASMA_OFF, 0, 0, 1, 1, 0), 290 | (NumericPresetModes.PRESET_MODE_SLEEP, 1, 0, 0, 0, 0), 291 | (NumericPresetModes.PRESET_MODE_AUTO, 0, 1, 0, 0, 1), 292 | (NumericPresetModes.PRESET_MODE_AUTO_PLASMA_OFF, 0, 1, 0, 1, 0), 293 | (NumericPresetModes.PRESET_MODE_MANUAL, 0, 0, 1, 0, 1), 294 | (NumericPresetModes.PRESET_MODE_MANUAL_PLASMA_OFF, 0, 0, 1, 1, 0), 295 | ], 296 | ) 297 | async def test_async_set_preset_mode( 298 | preset_mode, sleep, auto, manual, plasmawave_off, plasmawave_on 299 | ) -> None: 300 | """Test setting preset mode.""" 301 | 302 | wrapper = build_mock_wrapper() 303 | 304 | wrapper.async_ensure_on = AsyncMock() 305 | wrapper.async_sleep = AsyncMock() 306 | wrapper.async_auto = AsyncMock() 307 | wrapper.async_manual = AsyncMock() 308 | wrapper.async_plasmawave_off = AsyncMock() 309 | wrapper.async_plasmawave_on = AsyncMock() 310 | 311 | await wrapper.async_set_preset_mode(preset_mode) 312 | assert wrapper.async_ensure_on.call_count == 1 313 | 314 | assert wrapper.async_sleep.call_count == sleep 315 | assert wrapper.async_auto.call_count == auto 316 | assert wrapper.async_manual.call_count == manual 317 | assert wrapper.async_plasmawave_off.call_count == plasmawave_off 318 | assert wrapper.async_plasmawave_on.call_count == plasmawave_on 319 | 320 | 321 | async def test_async_set_preset_mode_invalid() -> None: 322 | """Test invalid preset mode.""" 323 | 324 | client = Mock() 325 | device_stub = Mock() 326 | 327 | logger = Mock() 328 | logger.debug = Mock() 329 | logger.warning = Mock() 330 | 331 | wrapper = WinixDeviceWrapper(client, device_stub, logger) 332 | 333 | with pytest.raises(ValueError): 334 | await wrapper.async_set_preset_mode("INVALID_PRESET") 335 | -------------------------------------------------------------------------------- /tests/test_driver.py: -------------------------------------------------------------------------------- 1 | """Test WinixDevice component.""" 2 | 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from custom_components.winix.driver import WinixDriver 8 | 9 | 10 | @patch("custom_components.winix.driver.WinixDriver._rpc_attr") 11 | @pytest.mark.parametrize( 12 | ("method", "category", "value"), 13 | [ 14 | ("turn_off", "power", "off"), 15 | ("turn_on", "power", "on"), 16 | ("auto", "mode", "auto"), 17 | ("manual", "mode", "manual"), 18 | ("plasmawave_off", "plasma", "off"), 19 | ("plasmawave_on", "plasma", "on"), 20 | ("low", "airflow", "low"), 21 | ("medium", "airflow", "medium"), 22 | ("high", "airflow", "high"), 23 | ("turbo", "airflow", "turbo"), 24 | ("sleep", "airflow", "sleep"), 25 | ], 26 | ) 27 | async def test_turn_off(mock_rpc_attr, mock_driver, method, category, value) -> None: 28 | """Test various driver methods.""" 29 | 30 | await getattr(mock_driver, method)() 31 | assert mock_rpc_attr.call_count == 1 32 | assert mock_rpc_attr.call_args[0] == ( 33 | WinixDriver.category_keys[category], 34 | WinixDriver.state_keys[category][value], 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | ("mock_driver_with_payload", "expected"), 40 | [ 41 | ({"A02": "0"}, {"power": "off"}), 42 | ({"A02": "1"}, {"power": "on"}), 43 | ({"S08": "79"}, {"air_qvalue": 79}), # air_qvalue 44 | ], 45 | indirect=["mock_driver_with_payload"], 46 | ) 47 | async def test_get_state(mock_driver_with_payload, expected) -> None: 48 | """Test get_state.""" 49 | 50 | # payload = {"A02": "0"} # "A02" represents "power" and "0" means "off" 51 | 52 | state = await mock_driver_with_payload.get_state() 53 | assert state == expected 54 | -------------------------------------------------------------------------------- /tests/test_fan.py: -------------------------------------------------------------------------------- 1 | """Test Winixdevice component.""" 2 | 3 | from unittest.mock import AsyncMock, Mock, patch 4 | 5 | import pytest 6 | from pytest_homeassistant_custom_component.common import MockConfigEntry 7 | 8 | from custom_components.winix.const import ( 9 | AIRFLOW_HIGH, 10 | AIRFLOW_LOW, 11 | ATTR_AIRFLOW, 12 | ORDERED_NAMED_FAN_SPEEDS, 13 | PRESET_MODE_AUTO, 14 | PRESET_MODE_AUTO_PLASMA_OFF, 15 | PRESET_MODE_MANUAL, 16 | PRESET_MODE_MANUAL_PLASMA_OFF, 17 | PRESET_MODE_SLEEP, 18 | PRESET_MODES, 19 | SERVICE_PLASMAWAVE_ON, 20 | WINIX_DATA_COORDINATOR, 21 | WINIX_DATA_KEY, 22 | WINIX_DOMAIN, 23 | ) 24 | from custom_components.winix.fan import WinixPurifier, async_setup_entry 25 | from homeassistant.components.fan import FanEntityFeature 26 | from homeassistant.const import ATTR_ENTITY_ID 27 | from homeassistant.core import HomeAssistant 28 | 29 | from .common import build_fake_manager, build_purifier 30 | 31 | 32 | async def test_setup_platform(hass: HomeAssistant) -> None: 33 | """Test platform setup.""" 34 | 35 | manager = build_fake_manager(3) 36 | config = MockConfigEntry(domain=WINIX_DOMAIN, data={}, entry_id="id1") 37 | hass.data = {WINIX_DOMAIN: {"id1": {WINIX_DATA_COORDINATOR: manager}}} 38 | async_add_entities = Mock() 39 | 40 | await async_setup_entry(hass, config, async_add_entities) 41 | 42 | assert async_add_entities.called 43 | assert len(async_add_entities.call_args[0][0]) == 3 44 | 45 | 46 | async def test_service(hass: HomeAssistant) -> None: 47 | """Test platform setup.""" 48 | 49 | manager = build_fake_manager(2) 50 | config = MockConfigEntry(domain=WINIX_DOMAIN, data={}, entry_id="id1") 51 | hass.data = {WINIX_DOMAIN: {"id1": {WINIX_DATA_COORDINATOR: manager}}} 52 | async_add_entities = Mock() 53 | 54 | await async_setup_entry(hass, config, async_add_entities) 55 | 56 | first_entity_id = None 57 | 58 | # Prepare the devices for serive call 59 | data = hass.data[WINIX_DOMAIN][config.entry_id] 60 | for device in data[WINIX_DATA_KEY]: 61 | device.hass = hass 62 | device.entity_id = device.unique_id 63 | 64 | if first_entity_id is None: 65 | first_entity_id = device.entity_id 66 | 67 | # Test service call with a specific entity_id 68 | with ( 69 | patch( 70 | "custom_components.winix.fan.WinixPurifier.async_plasmawave_on" 71 | ) as mock_plasmawave_on, 72 | patch( 73 | "custom_components.winix.fan.WinixPurifier.async_update_ha_state" 74 | ) as mock_update_ha_state, 75 | ): 76 | service_data = {ATTR_ENTITY_ID: [first_entity_id]} 77 | await hass.services.async_call( 78 | WINIX_DOMAIN, SERVICE_PLASMAWAVE_ON, service_data, blocking=True 79 | ) 80 | 81 | assert mock_plasmawave_on.call_count == 1 # Should be called once 82 | 83 | # Devices on which service call is made have their state updated 84 | assert mock_update_ha_state.call_count == 1 85 | 86 | # Test service call with no entity_id, call is made on all devices 87 | with ( 88 | patch( 89 | "custom_components.winix.fan.WinixPurifier.async_plasmawave_on" 90 | ) as mock_plasmawave_on, 91 | patch( 92 | "custom_components.winix.fan.WinixPurifier.async_update_ha_state" 93 | ) as mock_update_ha_state, 94 | ): 95 | await hass.services.async_call( 96 | WINIX_DOMAIN, SERVICE_PLASMAWAVE_ON, {}, blocking=True 97 | ) 98 | assert mock_plasmawave_on.call_count == 2 # Called for each device 99 | 100 | # Devices on which service call is made have their state updated 101 | assert mock_update_ha_state.call_count == 2 102 | 103 | 104 | def test_construction(hass: HomeAssistant) -> None: 105 | """Test device construction.""" 106 | device_wrapper = Mock() 107 | device_wrapper.get_state = Mock(return_value={}) 108 | 109 | device = WinixPurifier(device_wrapper, Mock()) 110 | assert device.unique_id is not None 111 | assert device.preset_modes == PRESET_MODES 112 | assert device.speed_list == ORDERED_NAMED_FAN_SPEEDS 113 | assert device.speed_count == len(ORDERED_NAMED_FAN_SPEEDS) 114 | assert device.supported_features == ( 115 | FanEntityFeature.PRESET_MODE 116 | | FanEntityFeature.SET_SPEED 117 | | FanEntityFeature.TURN_ON 118 | | FanEntityFeature.TURN_OFF 119 | ) 120 | 121 | assert device.device_info is not None 122 | assert ( 123 | device.name is None 124 | ) # name is should be None since purifier fan is the primary entity. 125 | 126 | 127 | def test_device_availability(hass: HomeAssistant) -> None: 128 | """Test device availability.""" 129 | device_wrapper = Mock() 130 | device_wrapper.get_state = Mock(return_value=None) 131 | 132 | device = WinixPurifier(device_wrapper, Mock()) 133 | assert not device.available 134 | 135 | device_wrapper.get_state = Mock(return_value={}) 136 | assert device.available 137 | 138 | 139 | def test_device_attributes(hass: HomeAssistant) -> None: 140 | """Test device attributes.""" 141 | device_wrapper = Mock() 142 | device_wrapper.get_state = Mock(return_value=None) 143 | 144 | device = WinixPurifier(device_wrapper, Mock()) 145 | assert device.extra_state_attributes is not None 146 | 147 | device_wrapper.get_state = Mock(return_value={"DUMMY_ATTR": 12}) 148 | assert device.extra_state_attributes["DUMMY_ATTR"] == 12 149 | 150 | 151 | @pytest.mark.parametrize("value", [(True), (False)]) 152 | def test_device_on(value) -> None: 153 | """Test device on.""" 154 | 155 | device_wrapper = Mock() 156 | type(device_wrapper).is_on = value 157 | device = WinixPurifier(device_wrapper, Mock()) 158 | assert device.is_on == value 159 | 160 | 161 | @pytest.mark.parametrize( 162 | ("state", "is_sleep", "is_auto", "expected"), 163 | [ 164 | (None, None, None, None), 165 | ({}, True, False, None), 166 | ({}, False, True, None), 167 | ({ATTR_AIRFLOW: AIRFLOW_LOW}, False, False, 25), 168 | ({ATTR_AIRFLOW: AIRFLOW_HIGH}, False, False, 75), 169 | ({ATTR_AIRFLOW: None}, None, None, None), 170 | ], 171 | ) 172 | def test_device_percentage(state, is_sleep, is_auto, expected) -> None: 173 | """Test device percentage.""" 174 | 175 | device_wrapper = Mock() 176 | type(device_wrapper).is_sleep = is_sleep 177 | type(device_wrapper).is_auto = is_auto 178 | device_wrapper.get_state = Mock(return_value=state) 179 | device = WinixPurifier(device_wrapper, Mock()) 180 | assert device.percentage is expected 181 | 182 | 183 | @pytest.mark.parametrize( 184 | ( 185 | "state", 186 | "is_sleep", 187 | "is_auto", 188 | "is_manual", 189 | "is_plasma_on", 190 | "is_plasma_off", 191 | "expected", 192 | ), 193 | [ 194 | (None, None, None, None, None, None, None), 195 | ({}, True, False, False, False, False, PRESET_MODE_SLEEP), 196 | ({}, False, False, False, False, False, None), 197 | ({}, False, True, False, True, False, PRESET_MODE_AUTO), 198 | ({}, False, True, False, False, True, PRESET_MODE_AUTO_PLASMA_OFF), 199 | ({}, False, False, True, True, False, PRESET_MODE_MANUAL), 200 | ({}, False, False, True, False, True, PRESET_MODE_MANUAL_PLASMA_OFF), 201 | ], 202 | ) 203 | def test_device_preset_mode( 204 | state, is_sleep, is_auto, is_manual, is_plasma_on, is_plasma_off, expected 205 | ) -> None: 206 | """Test device preset mode.""" 207 | 208 | device_wrapper = Mock() 209 | type(device_wrapper).is_sleep = is_sleep 210 | type(device_wrapper).is_auto = is_auto 211 | type(device_wrapper).is_manual = is_manual 212 | type(device_wrapper).is_plasma_on = is_plasma_on 213 | type(device_wrapper).is_plasma_off = is_plasma_off 214 | device_wrapper.get_state = Mock(return_value=state) 215 | device = WinixPurifier(device_wrapper, Mock()) 216 | assert device.preset_mode is expected 217 | 218 | 219 | async def test_async_set_percentage_zero( 220 | hass: HomeAssistant, mock_device_wrapper 221 | ) -> None: 222 | """Test setting percentage speed.""" 223 | 224 | device = build_purifier(hass, mock_device_wrapper) 225 | device.async_turn_off = AsyncMock() 226 | 227 | await device.async_set_percentage(0) 228 | assert device.async_turn_off.call_count == 1 229 | assert mock_device_wrapper.async_set_speed.call_count == 0 230 | 231 | 232 | async def test_async_set_percentage_non_zero( 233 | hass: HomeAssistant, mock_device_wrapper 234 | ) -> None: 235 | """Test setting percentage speed.""" 236 | 237 | device = build_purifier(hass, mock_device_wrapper) 238 | device.async_turn_off = AsyncMock() 239 | 240 | await device.async_set_percentage(20) 241 | assert device.async_turn_off.call_count == 0 242 | assert mock_device_wrapper.async_set_speed.call_count == 1 243 | 244 | 245 | async def test_async_turn_on(hass: HomeAssistant, mock_device_wrapper) -> None: 246 | """Test turning on.""" 247 | 248 | device = build_purifier(hass, mock_device_wrapper) 249 | device.async_set_percentage = AsyncMock() 250 | 251 | await device.async_turn_on() 252 | assert device.async_set_percentage.call_count == 0 253 | assert mock_device_wrapper.async_set_preset_mode.call_count == 0 254 | assert mock_device_wrapper.async_turn_on.call_count == 1 255 | 256 | 257 | async def test_async_turn_on_percentage( 258 | hass: HomeAssistant, mock_device_wrapper 259 | ) -> None: 260 | """Test turning on.""" 261 | 262 | device = build_purifier(hass, mock_device_wrapper) 263 | device.async_set_percentage = AsyncMock() 264 | 265 | await device.async_turn_on(25) 266 | assert device.async_set_percentage.call_count == 1 267 | assert mock_device_wrapper.async_set_preset_mode.call_count == 0 268 | assert mock_device_wrapper.async_turn_on.call_count == 1 269 | 270 | 271 | async def test_async_turn_on_preset(hass: HomeAssistant, mock_device_wrapper) -> None: 272 | """Test turning on.""" 273 | 274 | device = build_purifier(hass, mock_device_wrapper) 275 | device.async_set_percentage = AsyncMock() 276 | 277 | await device.async_turn_on(None, PRESET_MODE_MANUAL) 278 | assert device.async_set_percentage.call_count == 0 279 | assert mock_device_wrapper.async_set_preset_mode.call_count == 1 280 | assert mock_device_wrapper.async_turn_on.call_count == 0 281 | 282 | 283 | @pytest.mark.parametrize( 284 | "args", 285 | [ 286 | (["async_turn_off"]), 287 | (["async_plasmawave_on"]), 288 | (["async_plasmawave_off"]), 289 | (["async_set_preset_mode", PRESET_MODE_MANUAL]), 290 | ], 291 | ) 292 | async def test_fan_operations(hass: HomeAssistant, mock_device_wrapper, args) -> None: 293 | """Test other fan operations.""" 294 | mocked_method = AsyncMock() 295 | method = args[0] 296 | 297 | with patch.object(mock_device_wrapper, method, mocked_method): 298 | device = build_purifier(hass, mock_device_wrapper) 299 | 300 | if len(args) == 2: 301 | await getattr(device, method)(args[1]) 302 | else: 303 | await getattr(device, method)() 304 | 305 | assert mocked_method.call_count == 1 306 | 307 | 308 | @pytest.mark.parametrize( 309 | "is_plasma_on", 310 | [ 311 | (True), 312 | (False), 313 | ], 314 | ) 315 | async def test_plasma_toggle( 316 | hass: HomeAssistant, mock_device_wrapper, is_plasma_on 317 | ) -> None: 318 | """Test pasma toggle operation.""" 319 | type(mock_device_wrapper).is_plasma_on = is_plasma_on 320 | 321 | device = build_purifier(hass, mock_device_wrapper) 322 | 323 | await device.async_plasmawave_toggle() 324 | 325 | if is_plasma_on: 326 | assert mock_device_wrapper.async_plasmawave_off.call_count == 1 327 | assert mock_device_wrapper.async_plasmawave_on.call_count == 0 328 | else: 329 | assert mock_device_wrapper.async_plasmawave_off.call_count == 0 330 | assert mock_device_wrapper.async_plasmawave_on.call_count == 1 331 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test component setup.""" 2 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Test WinixAirQualitySensor component.""" 2 | 3 | import logging 4 | 5 | import pytest 6 | from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker 7 | 8 | from custom_components.winix.const import ATTR_AIR_QUALITY, WINIX_DOMAIN 9 | from custom_components.winix.sensor import TOTAL_FILTER_LIFE, get_filter_life_percentage 10 | from homeassistant.config_entries import ConfigEntryState 11 | from homeassistant.core import HomeAssistant 12 | 13 | from .common import init_integration 14 | 15 | TEST_DEVICE_ID = "847207352CE0_364yr8i989" 16 | 17 | 18 | async def test_setup_integration( 19 | hass: HomeAssistant, 20 | device_stub, 21 | device_data, 22 | aioclient_mock: AiohttpClientMocker, 23 | enable_custom_integrations, 24 | ) -> None: 25 | """Test integration setup.""" 26 | 27 | entry = await init_integration(hass, device_stub, device_data, aioclient_mock) 28 | assert len(hass.config_entries.async_entries(WINIX_DOMAIN)) == 1 29 | assert entry.state is ConfigEntryState.LOADED 30 | 31 | 32 | async def test_sensor_air_qvalue( 33 | hass: HomeAssistant, 34 | device_stub, 35 | device_data, 36 | aioclient_mock: AiohttpClientMocker, 37 | enable_custom_integrations, 38 | ) -> None: 39 | """Test qvalue sensor.""" 40 | 41 | air_qvalue = "71" 42 | device_data["body"]["data"][0]["attributes"]["S08"] = air_qvalue 43 | 44 | await init_integration(hass, device_stub, device_data, aioclient_mock) 45 | 46 | entity_state = hass.states.get("sensor.winix_devicealias_air_qvalue") 47 | assert entity_state is not None 48 | assert int(entity_state.state) == int(air_qvalue) 49 | assert entity_state.attributes.get("unit_of_measurement") == "qv" 50 | assert entity_state.attributes.get(ATTR_AIR_QUALITY) == "good" 51 | 52 | 53 | async def test_sensors( 54 | hass: HomeAssistant, 55 | device_stub, 56 | device_data, 57 | aioclient_mock: AiohttpClientMocker, 58 | enable_custom_integrations, 59 | ) -> None: 60 | """Test other sensor.""" 61 | 62 | await init_integration(hass, device_stub, device_data, aioclient_mock) 63 | 64 | filter_life_hours = "1257" 65 | aqi = "01" 66 | 67 | entity_state = hass.states.get("sensor.winix_devicealias_filter_life") 68 | assert entity_state is not None 69 | assert int(entity_state.state) == get_filter_life_percentage(filter_life_hours) 70 | 71 | entity_state = hass.states.get("sensor.winix_devicealias_aqi") 72 | assert entity_state is not None 73 | assert int(entity_state.state) == int(aqi) 74 | 75 | 76 | async def test_sensor_filter_life_missing( 77 | hass: HomeAssistant, 78 | device_stub, 79 | device_data, 80 | aioclient_mock: AiohttpClientMocker, 81 | enable_custom_integrations, 82 | ) -> None: 83 | """Test filter life sensor for missing data.""" 84 | 85 | del device_data["body"]["data"][0]["attributes"]["A21"] # Mock missing data 86 | 87 | await init_integration(hass, device_stub, device_data, aioclient_mock) 88 | 89 | entity_state = hass.states.get("sensor.winix_devicealias_filter_life") 90 | assert entity_state is not None 91 | assert ( 92 | entity_state.state == "unknown" 93 | ) # Missing data evaluates to None which is unknown state 94 | 95 | 96 | async def test_sensor_filter_life_out_of_bounds( 97 | hass: HomeAssistant, 98 | device_stub, 99 | device_data, 100 | aioclient_mock: AiohttpClientMocker, 101 | enable_custom_integrations, 102 | caplog: pytest.LogCaptureFixture, 103 | ) -> None: 104 | """Test filter life sensor for invalid data.""" 105 | 106 | filter_life_hours = TOTAL_FILTER_LIFE + 1 107 | device_data["body"]["data"][0]["attributes"]["A21"] = str(TOTAL_FILTER_LIFE + 1) 108 | 109 | caplog.clear() 110 | caplog.set_level(logging.WARNING) 111 | 112 | await init_integration(hass, device_stub, device_data, aioclient_mock) 113 | 114 | entity_state = hass.states.get("sensor.winix_devicealias_filter_life") 115 | assert entity_state is not None 116 | assert ( 117 | entity_state.state == "unknown" 118 | ) # Out of bounds data evaluates to None which is unknown state 119 | 120 | assert ( 121 | f"Reported filter life '{filter_life_hours}' is more than max value" 122 | in caplog.text 123 | ) 124 | --------------------------------------------------------------------------------