From 3039d49f0219f1264b1f5b3b2dd86304875a3e3e Mon Sep 17 00:00:00 2001 From: cdpuk <24318424+cdpuk@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:00:16 +0000 Subject: [PATCH] Support for Airjet_V01 and Hydrojet devices (#46) --- .devcontainer.json | 44 +++ .devcontainer/README.md | 60 ---- .devcontainer/devcontainer.json | 30 -- .gitignore | 2 + .pre-commit-config.yaml | 4 +- .vscode/tasks.json | 22 +- README.md | 11 +- {.devcontainer => config}/configuration.yaml | 2 - custom_components/bestway/__init__.py | 1 + custom_components/bestway/bestway/api.py | 350 ++++++++++--------- custom_components/bestway/bestway/model.py | 134 +++++-- custom_components/bestway/binary_sensor.py | 152 ++++---- custom_components/bestway/climate.py | 185 ++++++++-- custom_components/bestway/const.py | 1 + custom_components/bestway/entity.py | 38 +- custom_components/bestway/number.py | 18 +- custom_components/bestway/select.py | 133 +++++++ custom_components/bestway/sensor.py | 5 +- custom_components/bestway/switch.py | 278 +++++++++------ docs/supported-devices.md | 24 ++ scripts/develop | 20 ++ scripts/setup | 7 + 22 files changed, 946 insertions(+), 575 deletions(-) create mode 100644 .devcontainer.json delete mode 100644 .devcontainer/README.md delete mode 100644 .devcontainer/devcontainer.json rename {.devcontainer => config}/configuration.yaml (54%) create mode 100644 custom_components/bestway/select.py create mode 100644 docs/supported-devices.md create mode 100644 scripts/develop create mode 100644 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..42213c6 --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,44 @@ +{ + "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11-bullseye", + "name": "bestway", + "appPort": [ + "9125:8123" + ], + "postCreateCommand": "scripts/setup", + "portsAttributes": { + "9125": { + "label": "Home Assistant", + "onAutoForward": "notify" + }, + "5678": { + "label": "Debug", + "onAutoForward": "ignore" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.black-formatter", + "ms-python.pylint", + "ms-python.vscode-pylance", + "bierner.github-markdown-preview" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index e304a9a..0000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,60 +0,0 @@ -## Developing with Visual Studio Code + devcontainer - -The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. - -In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. - -**Prerequisites** - -- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- Docker - - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) - - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. -- [Visual Studio code](https://code.visualstudio.com/) -- [Remote - Containers (VSC Extension)][extension-link] - -[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) - -[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers - -**Getting started:** - -1. Fork the repository. -2. Clone the repository to your computer. -3. Open the repository using Visual Studio code. - -When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. - -_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ - -### Tasks - -The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. - -When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. - -The available tasks are: - -Task | Description --- | -- -Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. -Run Home Assistant configuration against /config | Check the configuration. -Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. -Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. - -### Step by Step debugging - -With the development container, -you can test your custom component in Home Assistant with step by step debugging. - -You need to modify the `configuration.yaml` file in `.devcontainer` folder -by uncommenting the line: - -```yaml -# debugpy: -``` - -Then launch the task `Run Home Assistant on port 9123`, and launch the debugger -with the existing debugging configuration `Python: Attach Local`. - -For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index e884bf8..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,30 +0,0 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. -{ - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", - "name": "bestway", - "context": "..", - "appPort": [ - "9125:8123" - ], - "postCreateCommand": "container install", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 492cda3..96b5238 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__ +config/* +!config/configuration.yaml pythonenv* venv .venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index abae17a..c91cf09 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: rev: v3.4.0 hooks: - id: pyupgrade - args: [--py310-plus] + args: [--py311-plus] - repo: https://github.com/psf/black rev: 23.3.0 hooks: @@ -73,7 +73,7 @@ repos: - id: python-typing-update stages: [manual] args: - - --py310-plus + - --py311-plus - --force - --keep-updates files: ^(custom_components|tests|script)/.+\.py$ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1d09774..ef7d9bf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,27 +2,9 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9124", + "label": "Run Home Assistant", "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", + "command": "scripts/develop", "problemMatcher": [] } ] diff --git a/README.md b/README.md index 897971c..5a98562 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,7 @@ Bestway uses different API endpoints for EU and US. If you get an error stating A Wi-Fi enabled model is required. No custom hardware is required. -The following devices are supported: - -* Lay-Z-Spa Airjet -* Flowclear Smart Touch - -The following devices require further development effort from willing and able volunteers: - -* Lay-Z-Spa HydroJet Pro - -The integration attempts to detect unknown devices and will provide some entities and debug logs in these cases. Please report these values and logs in any reports. +See the [supported devices](docs/supported-devices.md) list for more details. ## Installation diff --git a/.devcontainer/configuration.yaml b/config/configuration.yaml similarity index 54% rename from .devcontainer/configuration.yaml rename to config/configuration.yaml index 68f21fe..5b8f686 100644 --- a/.devcontainer/configuration.yaml +++ b/config/configuration.yaml @@ -6,8 +6,6 @@ logger: logs: custom_components.bestway: debug -# If you need to debug uncomment the line below -# Doc: https://www.home-assistant.io/integrations/debugpy/ debugpy: start: true wait: false diff --git a/custom_components/bestway/__init__.py b/custom_components/bestway/__init__.py index e71f4d2..50c41d0 100644 --- a/custom_components/bestway/__init__.py +++ b/custom_components/bestway/__init__.py @@ -27,6 +27,7 @@ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/custom_components/bestway/bestway/api.py b/custom_components/bestway/bestway/api.py index a0c7cf1..6452ea8 100644 --- a/custom_components/bestway/bestway/api.py +++ b/custom_components/bestway/bestway/api.py @@ -10,13 +10,15 @@ import async_timeout from .model import ( + AIRJET_V01_BUBBLES_MAP, + HYDROJET_BUBBLES_MAP, BestwayDevice, BestwayDeviceStatus, BestwayDeviceType, - BestwayPoolFilterDeviceStatus, - BestwaySpaDeviceStatus, BestwayUserToken, - TemperatureUnit, + BubblesLevel, + HydrojetFilter, + HydrojetHeat, ) _LOGGER = getLogger(__name__) @@ -31,9 +33,7 @@ class BestwayApiResults: """A snapshot of device status reports returned from the API.""" - spa_devices: dict[str, BestwaySpaDeviceStatus] - pool_filter_devices: dict[str, BestwayPoolFilterDeviceStatus] - unknown_devices: dict[str, str] + devices: dict[str, BestwayDeviceStatus] class BestwayException(Exception): @@ -108,9 +108,7 @@ def __init__(self, session: ClientSession, user_token: str, api_root: str) -> No # When updating state via HA, we update the cache and return this value # until the API can provide us with a response containing a timestamp # more recent than the local update. - self._spa_state_cache: dict[str, BestwaySpaDeviceStatus] = {} - self._pool_filter_state_cache: dict[str, BestwayPoolFilterDeviceStatus] = {} - self._unknown_states: dict[str, str] = {} + self._state_cache: dict[str, BestwayDeviceStatus] = {} @staticmethod async def get_user_token( @@ -179,9 +177,7 @@ async def fetch_data(self) -> BestwayApiResults: # locally cached state local_update_timestamp = 0 cached_state: BestwayDeviceStatus | None - if cached_state := self._spa_state_cache.get(did): - local_update_timestamp = cached_state.timestamp - elif cached_state := self._pool_filter_state_cache.get(did): + if cached_state := self._state_cache.get(did): local_update_timestamp = cached_state.timestamp # If the API timestamp is more recent, update the cache @@ -193,211 +189,237 @@ async def fetch_data(self) -> BestwayApiResults: _LOGGER.debug("New data received for device %s", did) device_attrs = latest_data["attr"] + self._state_cache[did] = BestwayDeviceStatus( + latest_data["updated_at"], device_attrs + ) + + attr_dump = json.dumps(device_attrs) - try: - if device_info.device_type == BestwayDeviceType.AIRJET_SPA: - errors = [] - for err_num in range(1, 10): - if device_attrs[f"system_err{err_num}"] == 1: - errors.append(err_num) - - spa_status = BestwaySpaDeviceStatus( - latest_data["updated_at"], - device_attrs["power"], - device_attrs["temp_now"], - device_attrs["temp_set"], - ( - TemperatureUnit.CELSIUS - if device_attrs["temp_set_unit"] - == "摄氏" # Chinese translates to "Celsius" - else TemperatureUnit.FAHRENHEIT - ), - device_attrs["heat_power"] == 1, - device_attrs["heat_temp_reach"] == 1, - device_attrs["filter_power"] == 1, - device_attrs["wave_power"] == 1, - device_attrs["locked"] == 1, - errors, - device_attrs["earth"] == 1, - ) - - self._spa_state_cache[did] = spa_status - - elif device_info.device_type == BestwayDeviceType.POOL_FILTER: - # The status attribute has been observed with the following values - # and translations: - # 运行中 - running - # 已停止 - stopped - # - # The error attribute has only been observed as '0'. This is forced - # to a boolean until we know more. - filter_status = BestwayPoolFilterDeviceStatus( - latest_data["updated_at"], - device_attrs["filter"] == 1, - device_attrs["power"] == 1, - device_attrs["time"], - device_attrs["status"] == "运行中", - device_attrs["error"] != 0, - ) - - self._pool_filter_state_cache[did] = filter_status - - elif device_info.device_type == BestwayDeviceType.UNKNOWN: - attr_dump = json.dumps(device_attrs) - _LOGGER.warning( - "Status for unknown device type '%s' returned: %s", - device_info.product_name, - attr_dump, - ) - self._unknown_states[did] = attr_dump - - except KeyError as err: - _LOGGER.error( - "Unexpected missing key '%s' while decoding device attributes %s", - err, - json.dumps(device_attrs), + if device_info.device_type == BestwayDeviceType.UNKNOWN: + _LOGGER.warning( + "Status for unknown device type '%s' returned: %s", + device_info.product_name, + attr_dump, + ) + else: + _LOGGER.debug( + "Status for device type '%s' returned: %s", + device_info.product_name, + attr_dump, ) - return BestwayApiResults( - self._spa_state_cache, self._pool_filter_state_cache, self._unknown_states - ) + return BestwayApiResults(self._state_cache) - async def spa_set_power(self, device_id: str, power: bool) -> None: + async def airjet_spa_set_power(self, device_id: str, power: bool) -> None: """Turn the spa on/off.""" - if (cached_state := self._spa_state_cache.get(device_id)) is None: + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") + api_value = 1 if power else 0 _LOGGER.debug("Setting power to %s", "ON" if power else "OFF") - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"power": 1 if power else 0}}, - ) + await self._do_control_post(device_id, power=api_value) cached_state.timestamp = int(time()) - cached_state.spa_power = power + cached_state.attrs["spa_power"] = api_value if not power: # When powering off, all other functions also turn off - cached_state.filter_power = False - cached_state.heat_power = False - cached_state.wave_power = False + cached_state.attrs["filter_power"] = 0 + cached_state.attrs["heat_power"] = 0 + cached_state.attrs["wave_power"] = 0 - async def spa_set_heat(self, device_id: str, heat: bool) -> None: + async def airjet_spa_set_filter(self, device_id: str, filtering: bool) -> None: + """Turn the filter pump on/off on a spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + api_value = 1 if filtering else 0 + _LOGGER.debug("Setting filter mode to %s", "ON" if filtering else "OFF") + await self._do_control_post(device_id, filter_power=api_value) + cached_state.timestamp = int(time()) + cached_state.attrs["filter_power"] = api_value + if filtering: + cached_state.attrs["spa_power"] = 1 + else: + cached_state.attrs["wave_power"] = 0 + cached_state.attrs["heat_power"] = 0 + + async def airjet_spa_set_heat(self, device_id: str, heat: bool) -> None: """ Turn the heater on/off on a spa device. Turning the heater on will also turn on the filter pump. """ - if (cached_state := self._spa_state_cache.get(device_id)) is None: + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") + api_value = 1 if heat else 0 _LOGGER.debug("Setting heater mode to %s", "ON" if heat else "OFF") - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"heat_power": 1 if heat else 0}}, - ) + await self._do_control_post(device_id, heat_power=api_value) cached_state.timestamp = int(time()) - cached_state.heat_power = heat + cached_state.attrs["heat_power"] = api_value if heat: - cached_state.spa_power = True - cached_state.filter_power = True + cached_state.attrs["spa_power"] = 1 + cached_state.attrs["filter_power"] = 1 - async def spa_set_filter(self, device_id: str, filtering: bool) -> None: - """Turn the filter pump on/off on a spa device.""" - if (cached_state := self._spa_state_cache.get(device_id)) is None: + async def airjet_spa_set_target_temp( + self, device_id: str, target_temp: int + ) -> None: + """Set the target temperature on a spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") - _LOGGER.debug("Setting filter mode to %s", "ON" if filtering else "OFF") - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"filter_power": 1 if filtering else 0}}, - ) + target_temp = int(target_temp) + _LOGGER.debug("Setting target temperature to %d", target_temp) + await self._do_control_post(device_id, temp_set=target_temp) cached_state.timestamp = int(time()) - cached_state.filter_power = filtering - if filtering: - cached_state.spa_power = True - else: - cached_state.wave_power = False - cached_state.heat_power = False + cached_state.attrs["temp_set"] = target_temp - async def spa_set_locked(self, device_id: str, locked: bool) -> None: + async def airjet_spa_set_locked(self, device_id: str, locked: bool) -> None: """Lock or unlock the physical control panel on a spa device.""" - if (cached_state := self._spa_state_cache.get(device_id)) is None: + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") + api_value = 1 if locked else 0 _LOGGER.debug("Setting lock state to %s", "ON" if locked else "OFF") - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"locked": 1 if locked else 0}}, - ) + await self._do_control_post(device_id, locked=api_value) cached_state.timestamp = int(time()) - cached_state.locked = locked + cached_state.attrs["locked"] = api_value - async def spa_set_bubbles(self, device_id: str, bubbles: bool) -> None: - """Turn the bubbles on/off on a spa device.""" - if (cached_state := self._spa_state_cache.get(device_id)) is None: + async def airjet_spa_set_bubbles(self, device_id: str, bubbles: bool) -> None: + """Turn the bubbles on/off on an Airjet spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") _LOGGER.debug("Setting bubbles mode to %s", "ON" if bubbles else "OFF") - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"wave_power": 1 if bubbles else 0}}, - ) + await self._do_control_post(device_id, wave_power=1 if bubbles else 0) cached_state.timestamp = int(time()) - cached_state.wave_power = bubbles + cached_state.attrs["wave_power"] = bubbles if bubbles: - cached_state.spa_power = True + cached_state.attrs["spa_power"] = 1 - async def spa_set_target_temp(self, device_id: str, target_temp: int) -> None: - """Set the target temperature on a spa device.""" - if (cached_state := self._spa_state_cache.get(device_id)) is None: + async def airjet_v01_spa_set_bubbles( + self, device_id: str, bubbles: BubblesLevel + ) -> None: + """Control the bubbles on an Airjet V01 spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") + api_value = AIRJET_V01_BUBBLES_MAP.to_api_value(bubbles) + _LOGGER.debug("Setting bubbles mode to %d", api_value) + await self._do_control_post(device_id, wave=api_value) + cached_state.timestamp = int(time()) + cached_state.attrs["wave"] = api_value + if bubbles != BubblesLevel.OFF: + cached_state.attrs["power"] = 1 + + async def hydrojet_spa_set_power(self, device_id: str, power: bool) -> None: + """Turn the spa on/off.""" + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + _LOGGER.debug("Setting power to %s", "ON" if power else "OFF") + await self._do_control_post(device_id, power=1 if power else 0) + cached_state.timestamp = int(time()) + cached_state.attrs["power"] = power + if not power: + # When powering off, all other functions also turn off + cached_state.attrs["filter"] = 0 + cached_state.attrs["heat"] = 0 + cached_state.attrs["wave"] = HYDROJET_BUBBLES_MAP.off_val + + async def hydrojet_spa_set_filter( + self, device_id: str, filtering: HydrojetFilter + ) -> None: + """Turn the filter pump on/off on a spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + _LOGGER.debug("Setting filter mode to %s", "ON" if filtering else "OFF") + await self._do_control_post(device_id, filter=filtering) + cached_state.timestamp = int(time()) + cached_state.attrs["filter"] = filtering + if filtering == HydrojetFilter.ON: + cached_state.attrs["power"] = 1 + else: + cached_state.attrs["wave"] = HYDROJET_BUBBLES_MAP.off_val + cached_state.attrs["heat"] = 0 + + async def hydrojet_spa_set_heat(self, device_id: str, heat: HydrojetHeat) -> None: + """ + Turn the heater on/off on a Hydrojet spa device. + + Turning the heater on will also turn on the filter pump. + """ + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + _LOGGER.debug("Setting heater mode to %s", "ON" if heat else "OFF") + await self._do_control_post(device_id, heat=heat) + cached_state.timestamp = int(time()) + cached_state.attrs["heat"] = heat + if heat == HydrojetHeat.ON: + cached_state.attrs["power"] = 1 + cached_state.attrs["filter"] = HydrojetFilter.ON + + async def hydrojet_spa_set_target_temp( + self, device_id: str, target_temp: int + ) -> None: + """Set the target temperature on a Hydrojet spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + target_temp = int(target_temp) _LOGGER.debug("Setting target temperature to %d", target_temp) - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"temp_set": target_temp}}, - ) + await self._do_control_post(device_id, Tset=target_temp) + cached_state.timestamp = int(time()) + cached_state.attrs["Tset"] = target_temp + + async def hydrojet_spa_set_bubbles( + self, device_id: str, bubbles: BubblesLevel + ) -> None: + """Control the bubbles on a Hydrojet spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + api_value = HYDROJET_BUBBLES_MAP.to_api_value(bubbles) + _LOGGER.debug("Setting bubbles mode to %d", api_value) + await self._do_control_post(device_id, wave=api_value) + cached_state.timestamp = int(time()) + cached_state.attrs["wave"] = api_value + if bubbles != BubblesLevel.OFF: + cached_state.attrs["power"] = 1 + + async def hydrojet_spa_set_jets(self, device_id: str, jets: bool) -> None: + """Control the jets on a Hydrojet spa device.""" + if (cached_state := self._state_cache.get(device_id)) is None: + raise BestwayException(f"Device '{device_id}' is not recognised") + + api_value = 1 if jets else 0 + _LOGGER.debug("Setting jets to %s", "ON" if jets else "OFF") + await self._do_control_post(device_id, jet=api_value) cached_state.timestamp = int(time()) - cached_state.temp_set = target_temp + cached_state.attrs["jet"] = api_value + if jets: + cached_state.attrs["power"] = 1 async def pool_filter_set_power(self, device_id: str, power: bool) -> None: """Control power to a pump device.""" - if (cached_state := self._pool_filter_state_cache.get(device_id)) is None: + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") _LOGGER.debug("Setting power to %s", "ON" if power else "OFF") - headers = dict(_HEADERS) - headers["X-Gizwits-User-token"] = self._user_token - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"power": 1 if power else 0}}, - ) + await self._do_control_post(device_id, power=1 if power else 0) cached_state.timestamp = int(time()) - cached_state.power = power + cached_state.attrs["power"] = power async def pool_filter_set_time(self, device_id: str, hours: int) -> None: """Set filter timeout for for pool devices.""" - if (cached_state := self._pool_filter_state_cache.get(device_id)) is None: + if (cached_state := self._state_cache.get(device_id)) is None: raise BestwayException(f"Device '{device_id}' is not recognised") _LOGGER.debug("Setting filter timeout to %d hours", hours) - await self._do_post( - f"{self._api_root}/app/control/{device_id}", - {"attrs": {"time": hours}}, - ) + await self._do_control_post(device_id, time=hours) cached_state.timestamp = int(time()) - cached_state.time = hours + cached_state.attrs["time"] = hours async def _do_get(self, url: str) -> dict[str, Any]: """Make an API call to the specified URL, returning the response as a JSON object.""" @@ -413,6 +435,14 @@ async def _do_get(self, url: str) -> dict[str, Any]: response_json: dict[str, Any] = await response.json(content_type=None) return response_json + async def _do_control_post( + self, device_id: str, **kwargs: int | str + ) -> dict[str, Any]: + return await self._do_post( + f"{self._api_root}/app/control/{device_id}", + {"attrs": kwargs}, + ) + async def _do_post(self, url: str, body: dict[str, Any]) -> dict[str, Any]: """Make an API call to the specified URL, returning the response as a JSON object.""" headers = dict(_HEADERS) diff --git a/custom_components/bestway/bestway/model.py b/custom_components/bestway/bestway/model.py index c881961..ad36cb0 100644 --- a/custom_components/bestway/bestway/model.py +++ b/custom_components/bestway/bestway/model.py @@ -2,17 +2,20 @@ from __future__ import annotations from dataclasses import dataclass -from enum import Enum, auto -from time import time +from enum import Enum, IntEnum, auto +from logging import getLogger -# How old the latest update can be before a spa is considered offline -_CONNECTIVITY_TIMEOUT = 1000 +from typing import Any + +_LOGGER = getLogger(__name__) class BestwayDeviceType(Enum): """Bestway device types.""" AIRJET_SPA = "Airjet" + AIRJET_V01_SPA = "Airjet V01" + HYDROJET_SPA = "Hydrojet" POOL_FILTER = "Pool Filter" UNKNOWN = "Unknown" @@ -22,6 +25,10 @@ def from_api_product_name(product_name: str) -> BestwayDeviceType: if product_name == "Airjet": return BestwayDeviceType.AIRJET_SPA + if product_name == "Airjet_V01": + return BestwayDeviceType.AIRJET_V01_SPA + if product_name == "Hydrojet": + return BestwayDeviceType.HYDROJET_SPA if product_name == "泳池过滤器": # Chinese translates to "pool filter" return BestwayDeviceType.POOL_FILTER @@ -35,6 +42,90 @@ class TemperatureUnit(Enum): FAHRENHEIT = auto() +class HydrojetFilter(IntEnum): + """Airjet_V01/Hydrojet filter values.""" + + OFF = 0 + ON = 2 + + +class HydrojetHeat(IntEnum): + """Airjet_V01/Hydrojet heater values.""" + + OFF = 0 + ON = 3 + + +class BubblesLevel(Enum): + """Bubbles levels available to a range of spa models.""" + + OFF = auto() + MEDIUM = auto() + MAX = auto() + + +class BubblesValues: + """Values that represent a given level of bubbles. + + The write_value is the integer used to set the level via the API. + + The read_values list contains a set of integers that may be read from the API to signal the + desired state. This came about because different users of Airjet_V01 devices reported that + their app/device would sometimes represent MEDIUM bubbles as 50, but sometimes as 51. + """ + + write_value: int + read_values: list[int] + + def __init__(self, write_value: int, read_values: list[int] | None = None) -> None: + """Define the values used for a specific bubbles level.""" + self.write_value = write_value + if read_values: + self.read_values = read_values + else: + self.read_values = [write_value] + + +class BubblesMapping: + """Maps off, medium and max bubbles levels to integer API values.""" + + def __init__( + self, off_val: BubblesValues, medium_val: BubblesValues, max_val: BubblesValues + ) -> None: + """Construct a bubbles mapping using the given integer values.""" + self.off_val = off_val + self.medium_val = medium_val + self.max_val = max_val + + def to_api_value(self, level: BubblesLevel) -> int: + """Get the API value to be used when setting the given bubbles level.""" + + if level == BubblesLevel.MAX: + return self.max_val.write_value + elif level == BubblesLevel.MEDIUM: + return self.medium_val.write_value + else: + return self.off_val.write_value + + def from_api_value(self, value: int) -> BubblesLevel: + """Get the enum value based on the 'wave' field in the API response.""" + + if value in self.max_val.read_values: + return BubblesLevel.MAX + if value in self.medium_val.read_values: + return BubblesLevel.MEDIUM + if value in self.off_val.read_values: + return BubblesLevel.OFF + + _LOGGER.warning("Unexpected API value %d - assuming OFF", value) + return BubblesLevel.OFF + + +BV = BubblesValues +AIRJET_V01_BUBBLES_MAP = BubblesMapping(BV(0), BV(50, [50, 51]), BV(100)) +HYDROJET_BUBBLES_MAP = BubblesMapping(BV(0), BV(40), BV(100)) + + @dataclass class BestwayDevice: """A device under a user's account.""" @@ -60,40 +151,7 @@ class BestwayDeviceStatus: """A snapshot of the status of a spa (i.e. Lay-Z-Spa) device.""" timestamp: int - - @property - def online(self) -> bool: - """Determine whether the device is online based on the age of the latest update.""" - return self.timestamp > (time() - _CONNECTIVITY_TIMEOUT) - - -@dataclass -class BestwaySpaDeviceStatus(BestwayDeviceStatus): - """A snapshot of the status of a spa (i.e. Lay-Z-Spa) device.""" - - spa_power: bool - temp_now: float - temp_set: float - temp_set_unit: TemperatureUnit - heat_power: bool - heat_temp_reach: bool - filter_power: bool - wave_power: bool - locked: bool - errors: list[int] - earth_fault: bool - - -@dataclass -class BestwayPoolFilterDeviceStatus(BestwayDeviceStatus): - """A snapshot of the status of a filter device.""" - - timestamp: int - filter_change_required: bool - power: bool - time: int - running: bool - error: bool + attrs: dict[str, Any] @dataclass diff --git a/custom_components/bestway/binary_sensor.py b/custom_components/bestway/binary_sensor.py index 2b28313..a9467cf 100644 --- a/custom_components/bestway/binary_sensor.py +++ b/custom_components/bestway/binary_sensor.py @@ -16,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BestwayUpdateCoordinator +from .bestway.model import BestwayDeviceType from .const import DOMAIN, Icon -from .entity import BestwayEntity, BestwayPoolFilterEntity, BestwaySpaEntity +from .entity import BestwayEntity _SPA_CONNECTIVITY_SENSOR_DESCRIPTION = BinarySensorEntityDescription( key="spa_connected", @@ -26,7 +27,7 @@ name="Spa Connected", ) -_SPA_ERRORS_SENSOR_DESCRIPTION = BinarySensorEntityDescription( +_AIRJET_SPA_ERRORS_SENSOR_DESCRIPTION = BinarySensorEntityDescription( key="spa_has_error", name="Spa Errors", device_class=BinarySensorDeviceClass.PROBLEM, @@ -61,36 +62,75 @@ async def async_setup_entry( coordinator: BestwayUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[BestwayEntity] = [] - for device_id in coordinator.data.spa_devices.keys(): - entities.extend( - [ - SpaConnectivitySensor(coordinator, config_entry, device_id), - SpaErrorsSensor(coordinator, config_entry, device_id), - ] - ) - for device_id in coordinator.data.pool_filter_devices.keys(): - entities.extend( - [ - PoolFilterConnectivitySensor(coordinator, config_entry, device_id), - PoolFilterChangeRequiredSensor(coordinator, config_entry, device_id), - PoolFilterErrorSensor(coordinator, config_entry, device_id), - ] - ) + for device_id, device in coordinator.api.devices.items(): + if device.device_type == BestwayDeviceType.AIRJET_SPA: + entities.extend( + [ + DeviceConnectivitySensor( + coordinator, + config_entry, + device_id, + _SPA_CONNECTIVITY_SENSOR_DESCRIPTION, + ), + AirjetSpaErrorsSensor(coordinator, config_entry, device_id), + ] + ) + + if device.device_type == BestwayDeviceType.AIRJET_V01_SPA: + entities.extend( + [ + DeviceConnectivitySensor( + coordinator, + config_entry, + device_id, + _SPA_CONNECTIVITY_SENSOR_DESCRIPTION, + ) + ] + ) + + if device.device_type == BestwayDeviceType.HYDROJET_SPA: + entities.extend( + [ + DeviceConnectivitySensor( + coordinator, + config_entry, + device_id, + _SPA_CONNECTIVITY_SENSOR_DESCRIPTION, + ) + ] + ) + + if device.device_type == BestwayDeviceType.POOL_FILTER: + entities.extend( + [ + DeviceConnectivitySensor( + coordinator, + config_entry, + device_id, + _POOL_FILTER_CONNECTIVITY_SENSOR_DESCRIPTION, + ), + PoolFilterChangeRequiredSensor( + coordinator, config_entry, device_id + ), + PoolFilterErrorSensor(coordinator, config_entry, device_id), + ] + ) async_add_entities(entities) -class SpaConnectivitySensor(BestwaySpaEntity, BinarySensorEntity): - """Sensor to indicate whether a spa is currently online.""" +class DeviceConnectivitySensor(BestwayEntity, BinarySensorEntity): + """Sensor to indicate whether a device is currently online.""" def __init__( self, coordinator: BestwayUpdateCoordinator, config_entry: ConfigEntry, device_id: str, + entity_description: BinarySensorEntityDescription, ) -> None: """Initialize sensor.""" - self.entity_description = _SPA_CONNECTIVITY_SENSOR_DESCRIPTION + self.entity_description = entity_description self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_unique_id = f"{device_id}_{self.entity_description.key}" super().__init__( @@ -102,7 +142,7 @@ def __init__( @property def is_on(self) -> bool | None: """Return True if the spa is online.""" - return self.status is not None and self.status.online + return self.bestway_device is not None and self.bestway_device.is_online @property def available(self) -> bool: @@ -110,7 +150,7 @@ def available(self) -> bool: return True -class SpaErrorsSensor(BestwaySpaEntity, BinarySensorEntity): +class AirjetSpaErrorsSensor(BestwayEntity, BinarySensorEntity): """Sensor to indicate an error state for a spa.""" def __init__( @@ -120,7 +160,7 @@ def __init__( device_id: str, ) -> None: """Initialize sensor.""" - self.entity_description = _SPA_ERRORS_SENSOR_DESCRIPTION + self.entity_description = _AIRJET_SPA_ERRORS_SENSOR_DESCRIPTION self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_unique_id = f"{device_id}_{self.entity_description.key}" super().__init__( @@ -135,7 +175,12 @@ def is_on(self) -> bool | None: if not self.status: return None - return len(self.status.errors) > 0 or self.status.earth_fault + errors = [] + for err_num in range(1, 10): + if self.status.attrs[f"system_err{err_num}"] == 1: + errors.append(err_num) + + return len(errors) > 0 or self.status.attrs["earth"] @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -143,52 +188,21 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if not self.status: return None - errors = self.status.errors return { - "e01": 1 in errors, - "e02": 2 in errors, - "e03": 3 in errors, - "e04": 4 in errors, - "e05": 5 in errors, - "e06": 6 in errors, - "e07": 7 in errors, - "e08": 8 in errors, - "e09": 9 in errors, - "gcf": self.status.earth_fault, + "e01": self.status.attrs["system_err1"], + "e02": self.status.attrs["system_err2"], + "e03": self.status.attrs["system_err3"], + "e04": self.status.attrs["system_err4"], + "e05": self.status.attrs["system_err5"], + "e06": self.status.attrs["system_err6"], + "e07": self.status.attrs["system_err7"], + "e08": self.status.attrs["system_err8"], + "e09": self.status.attrs["system_err9"], + "gcf": self.status.attrs["earth"], } -class PoolFilterConnectivitySensor(BestwayPoolFilterEntity, BinarySensorEntity): - """Sensor to indicate whether a pool filter is currently online.""" - - def __init__( - self, - coordinator: BestwayUpdateCoordinator, - config_entry: ConfigEntry, - device_id: str, - ) -> None: - """Initialize sensor.""" - self.entity_description = _POOL_FILTER_CONNECTIVITY_SENSOR_DESCRIPTION - self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_unique_id = f"{device_id}_{self.entity_description.key}" - super().__init__( - coordinator, - config_entry, - device_id, - ) - - @property - def is_on(self) -> bool | None: - """Return True if the pool filter is online.""" - return self.status is not None and self.status.online - - @property - def available(self) -> bool: - """Return True, as the connectivity sensor is always available.""" - return True - - -class PoolFilterChangeRequiredSensor(BestwayPoolFilterEntity, BinarySensorEntity): +class PoolFilterChangeRequiredSensor(BestwayEntity, BinarySensorEntity): """Sensor to indicate whether a pool filter requires a change.""" def __init__( @@ -210,10 +224,10 @@ def __init__( @property def is_on(self) -> bool | None: """Return true if the spa is online.""" - return self.status is not None and self.status.filter_change_required + return self.status is not None and self.status.attrs["filter"] -class PoolFilterErrorSensor(BestwayPoolFilterEntity, BinarySensorEntity): +class PoolFilterErrorSensor(BestwayEntity, BinarySensorEntity): """Sensor to indicate an error state for a pool filter.""" def __init__( @@ -235,4 +249,4 @@ def __init__( @property def is_on(self) -> bool | None: """Return true if the pool filter is reporting an error.""" - return self.status is not None and self.status.error + return self.status is not None and self.status.attrs["error"] diff --git a/custom_components/bestway/climate.py b/custom_components/bestway/climate.py index fcdcc5e..b695d89 100644 --- a/custom_components/bestway/climate.py +++ b/custom_components/bestway/climate.py @@ -6,19 +6,19 @@ from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature from homeassistant.components.climate.const import ATTR_HVAC_MODE, HVACAction, HVACMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_WHOLE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BestwayUpdateCoordinator -from .bestway.model import TemperatureUnit +from .bestway.model import BestwayDeviceType, HydrojetHeat from .const import DOMAIN -from .entity import BestwayEntity, BestwaySpaEntity +from .entity import BestwayEntity + +_SPA_MIN_TEMP_C = 20 +_SPA_MIN_TEMP_F = 68 +_SPA_MAX_TEMP_C = 40 +_SPA_MAX_TEMP_F = 104 async def async_setup_entry( @@ -30,15 +30,23 @@ async def async_setup_entry( coordinator: BestwayUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[BestwayEntity] = [] - entities.extend( - SpaThermostat(coordinator, config_entry, device_id) - for device_id in coordinator.data.spa_devices.keys() - ) + + for device_id, device in coordinator.api.devices.items(): + if device.device_type == BestwayDeviceType.AIRJET_SPA: + entities.append(AirjetSpaThermostat(coordinator, config_entry, device_id)) + if device.device_type in [ + BestwayDeviceType.AIRJET_V01_SPA, + BestwayDeviceType.HYDROJET_SPA, + ]: + entities.append( + AirjetV01HydrojetSpaThermostat(coordinator, config_entry, device_id) + ) + async_add_entities(entities) -class SpaThermostat(BestwaySpaEntity, ClimateEntity): - """The main thermostat entity for a spa.""" +class AirjetSpaThermostat(BestwayEntity, ClimateEntity): + """A thermostat that works for Airjet spa devices.""" _attr_name = "Spa Thermostat" _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE @@ -61,15 +69,15 @@ def hvac_mode(self) -> HVACMode | str | None: """Return the current mode (HEAT or OFF).""" if not self.status: return None - return HVACMode.HEAT if self.status.heat_power else HVACMode.OFF + return HVACMode.HEAT if self.status.attrs["heat_power"] else HVACMode.OFF @property def hvac_action(self) -> HVACAction | str | None: """Return the current running action (HEATING or IDLE).""" if not self.status: return None - heat_on = self.status.heat_power - target_reached = self.status.heat_temp_reach + heat_on = self.status.attrs["heat_power"] + target_reached = self.status.attrs["heat_temp_reach"] return ( HVACAction.HEATING if (heat_on and not target_reached) else HVACAction.IDLE ) @@ -79,22 +87,22 @@ def current_temperature(self) -> float | None: """Return the current temperature.""" if not self.status: return None - return self.status.temp_now + return int(self.status.attrs["temp_now"]) @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if not self.status: return None - return self.status.temp_set + return int(self.status.attrs["temp_set"]) @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - if not self.status or self.status.temp_set_unit == TemperatureUnit.CELSIUS: - return str(TEMP_CELSIUS) + if not self.status or self.status.attrs["temp_set_unit"] == "摄氏": + return str(UnitOfTemperature.CELSIUS) else: - return str(TEMP_FAHRENHEIT) + return str(UnitOfTemperature.FAHRENHEIT) @property def min_temp(self) -> float: @@ -103,7 +111,11 @@ def min_temp(self) -> float: As the Spa can be switched between temperature units, this needs to be dynamic. """ - return 20 if self.temperature_unit == TEMP_CELSIUS else 68 + return ( + _SPA_MIN_TEMP_C + if self.temperature_unit == UnitOfTemperature.CELSIUS + else _SPA_MIN_TEMP_F + ) @property def max_temp(self) -> float: @@ -112,12 +124,129 @@ def max_temp(self) -> float: As the Spa can be switched between temperature units, this needs to be dynamic. """ - return 40 if self.temperature_unit == TEMP_CELSIUS else 104 + return ( + _SPA_MAX_TEMP_C + if self.temperature_unit == UnitOfTemperature.CELSIUS + else _SPA_MAX_TEMP_F + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" should_heat = hvac_mode == HVACMode.HEAT - await self.coordinator.api.spa_set_heat(self.device_id, should_heat) + await self.coordinator.api.airjet_spa_set_heat(self.device_id, should_heat) + await self.coordinator.async_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set a new target temperature.""" + target_temperature = kwargs.get(ATTR_TEMPERATURE) + if target_temperature is None: + return + + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + should_heat = hvac_mode == HVACMode.HEAT + await self.coordinator.api.airjet_spa_set_heat(self.device_id, should_heat) + + await self.coordinator.api.airjet_spa_set_target_temp( + self.device_id, target_temperature + ) + await self.coordinator.async_refresh() + + +class AirjetV01HydrojetSpaThermostat(BestwayEntity, ClimateEntity): + """A thermostat that works for Airjet_V01 and Hydrojet devices.""" + + _attr_name = "Spa Thermostat" + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_precision = PRECISION_WHOLE + _attr_target_temperature_step = 1 + + def __init__( + self, + coordinator: BestwayUpdateCoordinator, + config_entry: ConfigEntry, + device_id: str, + ) -> None: + """Initialize thermostat.""" + super().__init__(coordinator, config_entry, device_id) + self._attr_unique_id = f"{device_id}_thermostat" + + @property + def hvac_mode(self) -> HVACMode | str | None: + """Return the current mode (HEAT or OFF).""" + if not self.status: + return None + return HVACMode.HEAT if self.status.attrs["heat"] == 3 else HVACMode.OFF + + @property + def hvac_action(self) -> HVACAction | str | None: + """Return the current running action (HEATING or IDLE).""" + if not self.status: + return None + heat_on = self.status.attrs["heat"] == HydrojetHeat.ON + target_reached = self.status.attrs["word3"] == 1 + return ( + HVACAction.HEATING if (heat_on and not target_reached) else HVACAction.IDLE + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if not self.status: + return None + return int(self.status.attrs["Tnow"]) + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + if not self.status: + return None + return int(self.status.attrs["Tset"]) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + if not self.status or self.status.attrs["Tunit"]: + return str(UnitOfTemperature.CELSIUS) + else: + return str(UnitOfTemperature.FAHRENHEIT) + + @property + def min_temp(self) -> float: + """ + Get the minimum temperature that a user can set. + + As the Spa can be switched between temperature units, this needs to be dynamic. + """ + return ( + _SPA_MIN_TEMP_C + if self.temperature_unit == UnitOfTemperature.CELSIUS + else _SPA_MIN_TEMP_F + ) + + @property + def max_temp(self) -> float: + """ + Get the maximum temperature that a user can set. + + As the Spa can be switched between temperature units, this needs to be dynamic. + """ + return ( + _SPA_MAX_TEMP_C + if self.temperature_unit == UnitOfTemperature.CELSIUS + else _SPA_MAX_TEMP_F + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.HEAT: + await self.coordinator.api.hydrojet_spa_set_heat( + self.device_id, HydrojetHeat.ON + ) + else: + await self.coordinator.api.hydrojet_spa_set_heat( + self.device_id, HydrojetHeat.OFF + ) await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: @@ -128,9 +257,11 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if hvac_mode := kwargs.get(ATTR_HVAC_MODE): should_heat = hvac_mode == HVACMode.HEAT - await self.coordinator.api.spa_set_heat(self.device_id, should_heat) + await self.coordinator.api.hydrojet_spa_set_heat( + self.device_id, should_heat + ) - await self.coordinator.api.spa_set_target_temp( + await self.coordinator.api.hydrojet_spa_set_target_temp( self.device_id, target_temperature ) await self.coordinator.async_refresh() diff --git a/custom_components/bestway/const.py b/custom_components/bestway/const.py index f0c9b0b..4214968 100644 --- a/custom_components/bestway/const.py +++ b/custom_components/bestway/const.py @@ -18,6 +18,7 @@ class Icon(str, Enum): BUBBLES = "mdi:chart-bubble" FILTER = "mdi:image-filter-tilt-shift" HARDWARE = "mdi:chip" + JETS = "mdi:turbine" LOCK = "mdi:lock" POWER = "mdi:power" PROTOCOL = "mdi:protocol" diff --git a/custom_components/bestway/entity.py b/custom_components/bestway/entity.py index bf39bb6..c4d5d61 100644 --- a/custom_components/bestway/entity.py +++ b/custom_components/bestway/entity.py @@ -6,11 +6,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BestwayUpdateCoordinator -from .bestway.model import ( - BestwayDevice, - BestwayPoolFilterDeviceStatus, - BestwaySpaDeviceStatus, -) +from .bestway.model import BestwayDevice, BestwayDeviceStatus from .const import DOMAIN @@ -48,18 +44,9 @@ def bestway_device(self) -> BestwayDevice | None: return device @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.bestway_device is not None and self.bestway_device.is_online - - -class BestwaySpaEntity(BestwayEntity): - """Bestway spa entity type.""" - - @property - def status(self) -> BestwaySpaDeviceStatus | None: + def status(self) -> BestwayDeviceStatus | None: """Get status data for the spa providing this entity.""" - status: BestwaySpaDeviceStatus | None = self.coordinator.data.spa_devices.get( + status: BestwayDeviceStatus | None = self.coordinator.data.devices.get( self.device_id ) return status @@ -67,21 +54,4 @@ def status(self) -> BestwaySpaDeviceStatus | None: @property def available(self) -> bool: """Return True if entity is available.""" - return self.status is not None and self.status.online - - -class BestwayPoolFilterEntity(BestwayEntity): - """Bestway pool filter entity type.""" - - @property - def status(self) -> BestwayPoolFilterDeviceStatus | None: - """Get status data for the spa providing this entity.""" - status: BestwayPoolFilterDeviceStatus | None = ( - self.coordinator.data.pool_filter_devices.get(self.device_id) - ) - return status - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.status is not None and self.status.online + return self.bestway_device is not None and self.bestway_device.is_online diff --git a/custom_components/bestway/number.py b/custom_components/bestway/number.py index afe5629..33547af 100644 --- a/custom_components/bestway/number.py +++ b/custom_components/bestway/number.py @@ -9,8 +9,9 @@ from homeassistant.helpers.typing import StateType from . import BestwayUpdateCoordinator +from .bestway.model import BestwayDeviceType from .const import DOMAIN -from .entity import BestwayEntity, BestwayPoolFilterEntity +from .entity import BestwayEntity _POOL_FILTER_TIME = NumberEntityDescription( key="pool_filter_time", @@ -30,14 +31,17 @@ async def async_setup_entry( coordinator: BestwayUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[BestwayEntity] = [] - entities.extend( - PoolFilterTimeNumber(coordinator, config_entry, device_id, _POOL_FILTER_TIME) - for device_id in coordinator.data.pool_filter_devices.keys() - ) + for device_id, device in coordinator.api.devices.items(): + if device.device_type == BestwayDeviceType.POOL_FILTER: + entities.append( + PoolFilterTimeNumber( + coordinator, config_entry, device_id, _POOL_FILTER_TIME + ) + ) async_add_entities(entities) -class PoolFilterTimeNumber(BestwayPoolFilterEntity, NumberEntity): +class PoolFilterTimeNumber(BestwayEntity, NumberEntity): """Pool filter entity representing the number of hours to stay on for.""" def __init__( @@ -56,7 +60,7 @@ def __init__( def native_value(self) -> StateType: """Get the number of hours to stay on for.""" if self.status is not None: - return self.status.time + return self.status.attrs["time"] return None async def async_set_native_value(self, value: float) -> None: diff --git a/custom_components/bestway/select.py b/custom_components/bestway/select.py new file mode 100644 index 0000000..45e6b90 --- /dev/null +++ b/custom_components/bestway/select.py @@ -0,0 +1,133 @@ +"""Select platform.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from custom_components.bestway.bestway.api import BestwayApi + +from . import BestwayUpdateCoordinator +from .bestway.model import ( + AIRJET_V01_BUBBLES_MAP, + HYDROJET_BUBBLES_MAP, + BestwayDeviceType, + BubblesLevel, +) +from .const import DOMAIN, Icon +from .entity import BestwayEntity + +_BUBBLES_OPTIONS = { + BubblesLevel.OFF: "OFF", + BubblesLevel.MEDIUM: "MEDIUM", + BubblesLevel.MAX: "MAX", +} + + +@dataclass(frozen=True) +class BubblesRequiredKeys: + """Mixin for required keys.""" + + set_fn: Callable[[BestwayApi, str, BubblesLevel], Awaitable[None]] + get_fn: Callable[[int], BubblesLevel] + + +@dataclass(frozen=True) +class BubblesSelectEntityDescription(SelectEntityDescription, BubblesRequiredKeys): + """Describes bubbles selection.""" + + +_AIRJET_V01_BUBBLES_SELECT_DESCRIPTION = BubblesSelectEntityDescription( + key="bubbles", + options=list(_BUBBLES_OPTIONS.values()), + icon=Icon.BUBBLES, + name="Spa Bubbles", + set_fn=lambda api, device_id, level: api.airjet_v01_spa_set_bubbles( + device_id, level + ), + get_fn=lambda api_value: AIRJET_V01_BUBBLES_MAP.from_api_value(api_value), +) + +_HYDROJET_BUBBLES_SELECT_DESCRIPTION = BubblesSelectEntityDescription( + key="bubbles", + options=list(_BUBBLES_OPTIONS.values()), + icon=Icon.BUBBLES, + name="Spa Bubbles", + set_fn=lambda api, device_id, level: api.hydrojet_spa_set_bubbles(device_id, level), + get_fn=lambda api_value: HYDROJET_BUBBLES_MAP.from_api_value(api_value), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up select entities.""" + coordinator: BestwayUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities: list[BestwayEntity] = [] + + for device_id, device in coordinator.api.devices.items(): + if device.device_type == BestwayDeviceType.AIRJET_V01_SPA: + entities.append( + ThreeWaySpaBubblesSelect( + coordinator, + config_entry, + device_id, + _AIRJET_V01_BUBBLES_SELECT_DESCRIPTION, + ) + ) + + if device.device_type == BestwayDeviceType.HYDROJET_SPA: + entities.append( + ThreeWaySpaBubblesSelect( + coordinator, + config_entry, + device_id, + _HYDROJET_BUBBLES_SELECT_DESCRIPTION, + ) + ) + + async_add_entities(entities) + + +class ThreeWaySpaBubblesSelect(BestwayEntity, SelectEntity): + """Bubbles selection for spa devices that support 3 levels.""" + + entity_description: BubblesSelectEntityDescription + + def __init__( + self, + coordinator: BestwayUpdateCoordinator, + config_entry: ConfigEntry, + device_id: str, + description: BubblesSelectEntityDescription, + ) -> None: + """Initialize thermostat.""" + super().__init__(coordinator, config_entry, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option.""" + if device := self.coordinator.data.devices.get(self.device_id): + bubbles_level = self.entity_description.get_fn(device.attrs["wave"]) + return _BUBBLES_OPTIONS.get(bubbles_level) + return None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + bubbles_level = BubblesLevel.OFF + if option == _BUBBLES_OPTIONS[BubblesLevel.MEDIUM]: + bubbles_level = BubblesLevel.MEDIUM + elif option == _BUBBLES_OPTIONS[BubblesLevel.MAX]: + bubbles_level = BubblesLevel.MAX + + await self.entity_description.set_fn( + self.coordinator.api, self.device_id, bubbles_level + ) diff --git a/custom_components/bestway/sensor.py b/custom_components/bestway/sensor.py index cedec88..81a3e78 100644 --- a/custom_components/bestway/sensor.py +++ b/custom_components/bestway/sensor.py @@ -36,7 +36,10 @@ async def async_setup_entry( for device_id, device_info in coordinator.api.devices.items(): name_prefix = "Bestway" - if device_info.device_type == BestwayDeviceType.AIRJET_SPA: + if device_info.device_type in [ + BestwayDeviceType.AIRJET_SPA, + BestwayDeviceType.HYDROJET_SPA, + ]: name_prefix = "Spa" elif device_info.device_type == BestwayDeviceType.POOL_FILTER: name_prefix = "Pool Filter" diff --git a/custom_components/bestway/switch.py b/custom_components/bestway/switch.py index 64d6fe9..f99b80b 100644 --- a/custom_components/bestway/switch.py +++ b/custom_components/bestway/switch.py @@ -13,86 +13,100 @@ from . import BestwayUpdateCoordinator from .bestway.api import BestwayApi -from .bestway.model import BestwayPoolFilterDeviceStatus, BestwaySpaDeviceStatus +from .bestway.model import BestwayDeviceStatus, BestwayDeviceType, HydrojetFilter from .const import DOMAIN, Icon -from .entity import BestwayEntity, BestwayPoolFilterEntity, BestwaySpaEntity +from .entity import BestwayEntity -@dataclass -class SpaSwitchFunctionsMixin: +@dataclass(frozen=True) +class SwitchFunctionsMixin: """Functions for spa devices.""" - value_fn: Callable[[BestwaySpaDeviceStatus], bool] + value_fn: Callable[[BestwayDeviceStatus], bool] turn_on_fn: Callable[[BestwayApi, str], Awaitable[None]] turn_off_fn: Callable[[BestwayApi, str], Awaitable[None]] -@dataclass -class PoolFilterSwitchFunctionsMixin: - """Functions for pool filter devices.""" - - value_fn: Callable[[BestwayPoolFilterDeviceStatus], bool] - turn_on_fn: Callable[[BestwayApi, str], Awaitable[None]] - turn_off_fn: Callable[[BestwayApi, str], Awaitable[None]] - - -@dataclass -class SpaSwitchEntityDescription(SwitchEntityDescription, SpaSwitchFunctionsMixin): +@dataclass(frozen=True) +class BestwaySwitchEntityDescription(SwitchEntityDescription, SwitchFunctionsMixin): """Entity description for bestway spa switches.""" -@dataclass -class PoolFilterSwitchEntityDescription( - SwitchEntityDescription, PoolFilterSwitchFunctionsMixin -): - """Entity description for bestway pool filter switches.""" - - -_SPA_SWITCH_TYPES = [ - SpaSwitchEntityDescription( - key="spa_power", - name="Spa Power", - icon=Icon.POWER, - value_fn=lambda s: s.spa_power, - turn_on_fn=lambda api, device_id: api.spa_set_power(device_id, True), - turn_off_fn=lambda api, device_id: api.spa_set_power(device_id, False), - ), - SpaSwitchEntityDescription( - key="spa_filter_power", - name="Spa Filter", - icon=Icon.FILTER, - value_fn=lambda s: s.filter_power, - turn_on_fn=lambda api, device_id: api.spa_set_filter(device_id, True), - turn_off_fn=lambda api, device_id: api.spa_set_filter(device_id, False), - ), - SpaSwitchEntityDescription( - key="spa_wave_power", - name="Spa Bubbles", - icon=Icon.BUBBLES, - value_fn=lambda s: s.wave_power, - turn_on_fn=lambda api, device_id: api.spa_set_bubbles(device_id, True), - turn_off_fn=lambda api, device_id: api.spa_set_bubbles(device_id, False), +_AIRJET_SPA_POWER_SWITCH = BestwaySwitchEntityDescription( + key="spa_power", + name="Spa Power", + icon=Icon.POWER, + value_fn=lambda s: bool(s.attrs["power"]), + turn_on_fn=lambda api, device_id: api.airjet_spa_set_power(device_id, True), + turn_off_fn=lambda api, device_id: api.airjet_spa_set_power(device_id, False), +) + +_AIRJET_SPA_FILTER_SWITCH = BestwaySwitchEntityDescription( + key="spa_filter_power", + name="Spa Filter", + icon=Icon.FILTER, + value_fn=lambda s: bool(s.attrs["filter_power"]), + turn_on_fn=lambda api, device_id: api.airjet_spa_set_filter(device_id, True), + turn_off_fn=lambda api, device_id: api.airjet_spa_set_filter(device_id, False), +) + +_AIRJET_SPA_BUBBLES_SWITCH = BestwaySwitchEntityDescription( + key="spa_wave_power", + name="Spa Bubbles", + icon=Icon.BUBBLES, + value_fn=lambda s: bool(s.attrs["wave_power"]), + turn_on_fn=lambda api, device_id: api.airjet_spa_set_bubbles(device_id, True), + turn_off_fn=lambda api, device_id: api.airjet_spa_set_bubbles(device_id, False), +) + +_AIRJET_SPA_LOCK_SWITCH = BestwaySwitchEntityDescription( + key="spa_locked", + name="Spa Locked", + icon=Icon.LOCK, + value_fn=lambda s: bool(s.attrs["locked"]), + turn_on_fn=lambda api, device_id: api.airjet_spa_set_locked(device_id, True), + turn_off_fn=lambda api, device_id: api.airjet_spa_set_locked(device_id, False), +) + +_AIRJET_V01_HYDROJET_SPA_POWER_SWITCH = BestwaySwitchEntityDescription( + key="spa_power", + name="Spa Power", + icon=Icon.POWER, + value_fn=lambda s: bool(s.attrs["power"]), + turn_on_fn=lambda api, device_id: api.hydrojet_spa_set_power(device_id, True), + turn_off_fn=lambda api, device_id: api.hydrojet_spa_set_power(device_id, False), +) + +_AIRJET_V01_HYDROJET_SPA_FILTER_SWITCH = BestwaySwitchEntityDescription( + key="spa_filter_power", + name="Spa Filter", + icon=Icon.FILTER, + value_fn=lambda s: bool(s.attrs["filter"] == 2), + turn_on_fn=lambda api, device_id: api.hydrojet_spa_set_filter( + device_id, HydrojetFilter.ON ), - SpaSwitchEntityDescription( - key="spa_locked", - name="Spa Locked", - icon=Icon.LOCK, - value_fn=lambda s: s.locked, - turn_on_fn=lambda api, device_id: api.spa_set_locked(device_id, True), - turn_off_fn=lambda api, device_id: api.spa_set_locked(device_id, False), + turn_off_fn=lambda api, device_id: api.hydrojet_spa_set_filter( + device_id, HydrojetFilter.OFF ), -] - -_POOL_FILTER_SWITCH_TYPES = [ - PoolFilterSwitchEntityDescription( - key="pool_filter_power", - name="Pool Filter Power", - icon=Icon.FILTER, - value_fn=lambda s: s.power, - turn_on_fn=lambda api, device_id: api.pool_filter_set_power(device_id, True), - turn_off_fn=lambda api, device_id: api.pool_filter_set_power(device_id, False), - ), -] +) + +_HYDROJET_SPA_JETS_SWITCH = BestwaySwitchEntityDescription( + key="spa_jets", + name="Spa Jets", + icon=Icon.JETS, + value_fn=lambda s: bool(s.attrs["jet"]), + turn_on_fn=lambda api, device_id: api.hydrojet_spa_set_jets(device_id, True), + turn_off_fn=lambda api, device_id: api.hydrojet_spa_set_jets(device_id, False), +) + +_POOL_FILTER_POWER_SWITCH = BestwaySwitchEntityDescription( + key="pool_filter_power", + name="Pool Filter Power", + icon=Icon.FILTER, + value_fn=lambda s: bool(s.attrs["power"]), + turn_on_fn=lambda api, device_id: api.pool_filter_set_power(device_id, True), + turn_off_fn=lambda api, device_id: api.pool_filter_set_power(device_id, False), +) async def async_setup_entry( @@ -104,66 +118,100 @@ async def async_setup_entry( coordinator: BestwayUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[BestwayEntity] = [] - entities.extend( - SpaSwitch(coordinator, config_entry, device_id, description) - for device_id in coordinator.data.spa_devices.keys() - for description in _SPA_SWITCH_TYPES - ) - entities.extend( - PoolFilterSwitch(coordinator, config_entry, device_id, description) - for device_id in coordinator.data.pool_filter_devices.keys() - for description in _POOL_FILTER_SWITCH_TYPES - ) - async_add_entities(entities) - - -class SpaSwitch(BestwaySpaEntity, SwitchEntity): - """Bestway switch entity for spa devices.""" - - entity_description: SpaSwitchEntityDescription - - def __init__( - self, - coordinator: BestwayUpdateCoordinator, - config_entry: ConfigEntry, - device_id: str, - description: SpaSwitchEntityDescription, - ) -> None: - """Initialize switch.""" - super().__init__(coordinator, config_entry, device_id) - self.entity_description = description - self._attr_unique_id = f"{device_id}_{description.key}" - @property - def is_on(self) -> bool | None: - """Return true if the switch is on.""" - if status := self.status: - return self.entity_description.value_fn(status) - - return None + for device_id, device in coordinator.api.devices.items(): + if device.device_type == BestwayDeviceType.AIRJET_SPA: + entities.extend( + [ + BestwaySwitch( + coordinator, config_entry, device_id, _AIRJET_SPA_POWER_SWITCH + ), + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_SPA_FILTER_SWITCH, + ), + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_SPA_BUBBLES_SWITCH, + ), + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_SPA_LOCK_SWITCH, + ), + ] + ) + + if device.device_type == BestwayDeviceType.AIRJET_V01_SPA: + entities.extend( + [ + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_V01_HYDROJET_SPA_POWER_SWITCH, + ), + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_V01_HYDROJET_SPA_FILTER_SWITCH, + ), + ] + ) + + if device.device_type == BestwayDeviceType.HYDROJET_SPA: + entities.extend( + [ + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_V01_HYDROJET_SPA_POWER_SWITCH, + ), + BestwaySwitch( + coordinator, + config_entry, + device_id, + _AIRJET_V01_HYDROJET_SPA_FILTER_SWITCH, + ), + BestwaySwitch( + coordinator, + config_entry, + device_id, + _HYDROJET_SPA_JETS_SWITCH, + ), + ] + ) + + if device.device_type == BestwayDeviceType.POOL_FILTER: + entities.extend( + [ + BestwaySwitch( + coordinator, config_entry, device_id, _POOL_FILTER_POWER_SWITCH + ) + ] + ) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on.""" - await self.entity_description.turn_on_fn(self.coordinator.api, self.device_id) - await self.coordinator.async_refresh() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - await self.entity_description.turn_off_fn(self.coordinator.api, self.device_id) - await self.coordinator.async_refresh() + async_add_entities(entities) -class PoolFilterSwitch(BestwayPoolFilterEntity, SwitchEntity): - """Bestway switch entity for pool filter devices.""" +class BestwaySwitch(BestwayEntity, SwitchEntity): + """Bestway switch entity.""" - entity_description: PoolFilterSwitchEntityDescription + entity_description: BestwaySwitchEntityDescription def __init__( self, coordinator: BestwayUpdateCoordinator, config_entry: ConfigEntry, device_id: str, - description: PoolFilterSwitchEntityDescription, + description: BestwaySwitchEntityDescription, ) -> None: """Initialize switch.""" super().__init__(coordinator, config_entry, device_id) diff --git a/docs/supported-devices.md b/docs/supported-devices.md new file mode 100644 index 0000000..c1db43d --- /dev/null +++ b/docs/supported-devices.md @@ -0,0 +1,24 @@ +# Supported devices + +Bestway devices change over time, so new devices can sometimes require changes to the integration to add support. This page attempts to keep track of how each device communicates, and the support status for each of these protocols. + +## Known products + +Raise an issue or log a pull request to update this list. Please do not suggest updates based on marketing material - only trust values that have been observed in the integration logs. + +| Model | Protocol | +| ------------------------------------- | ----------- | +| Lay-Z-Spa Dominica HydroJet | Hydrojet | +| Lay-Z-Spa Milan AirJet Plus | Airjet | +| Lay-Z-Spa Santorini HydroJet Pro | Hydrojet | +| SaluSpa Coronado EnergySense | Airjet_V01 | +| Bestway Smart Touch Wi-Fi Filter Pump | Pool Filter | + +## Protocol support + +| Protocol | Supported | +| ----------- | ------------------ | +| Airjet | :white_check_mark: | +| Airjet_V01 | :white_check_mark: | +| Hydrojet | :white_check_mark: | +| Pool Filter | :white_check_mark: | diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..21695d0 --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements_dev.txt