├── .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 | 
3 | [](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 | 
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 | 
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 |
--------------------------------------------------------------------------------