diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..90b8c186 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT=3-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/python:${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..6060c954 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,48 @@ +{ + "name": "Python 3", + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": { + // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "3.10-bullseye", + // Options + "NODE_VERSION": "lts/*" + } + }, + "customizations": { + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "python.formatting.provider": "black", + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "pylint.args": [ + + ], + "files.trimTrailingWhitespace": true + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "charliermarsh.ruff", + "ms-python.pylint", + "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github" + ] + } + }, + "remoteUser": "root", + "containerUser": "vscode", + "postAttachCommand": "pip3 install --user -r requirements.txt" +} \ No newline at end of file diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml new file mode 100644 index 00000000..2f7000e9 --- /dev/null +++ b/.github/workflows/python-build.yml @@ -0,0 +1,38 @@ +name: Python build + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-cov genbadge[all] + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --output-file ./reports/flake8/flake8stats.txt + - name: Test with pytest + run: | + pytest --cov=sunweg --cov-report html --cov-report xml --junitxml=reports/junit/junit.xml + mv htmlcov reports/coverage + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..9ba6e3eb --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,78 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-cov genbadge[all] + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --output-file ./reports/flake8/flake8stats.txt + - name: Test with pytest + run: | + pytest --cov=sunweg --cov-report html --cov-report xml --junitxml=reports/junit/junit.xml + mv htmlcov reports/coverage + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + - name: Generate badges + if: github.event_name != 'pull_request' + run: | + genbadge tests -o reports/tests.svg + genbadge coverage -i coverage.xml -o reports/coverage.svg + genbadge flake8 -i reports/flake8/flake8stats.txt -o reports/flake8.svg + - name: Publish badges report to badges branch + uses: JamesIves/github-pages-deploy-action@v4 + if: github.event_name != 'pull_request' + with: + branch: badges + folder: reports + token: ${{ secrets.SUNWEG_GITHUB_PAT }} + + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9a7d8b2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +reports/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..307ce0d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 George Zhao + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..d80a97cc --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Midea-local python lib + +Control your Midea M-Smart appliances via local area network. + +This library is part of [[https://github.com/georgezhao2010/midea_ac_lan|@georgezhao2010]] code. It was separated to segregate responsabilities. + +⭐If this component is helpful for you, please star it, it encourages me a lot. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..e6d16ed6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pytest +ruff +aiohttp +ifaddr +pycryptodome \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..8563b7f4 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +#!/bin/python +"""setup midea-local.""" + +import setuptools + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +requires = ["aiohttp", "ifaddr", "pycryptodome"] + +setuptools.setup( + name="midea-local", + version="1.0.0", + author="rokam", + author_email="lucas@mindello.com.br", + description="Control your Midea M-Smart appliances via local area network", + license="MIT", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/rokam/midea-local", + install_requires=requires, + packages=setuptools.find_packages(exclude=["tests", "tests.*"]), + python_requires=">=3.10", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], +) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backports/enum.py b/src/backports/enum.py new file mode 100644 index 00000000..6a6c1b0c --- /dev/null +++ b/src/backports/enum.py @@ -0,0 +1,35 @@ +"""Enum backports from standard lib.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, TypeVar + +_StrEnumSelfT = TypeVar("_StrEnumSelfT", bound="StrEnum") + + +class StrEnum(str, Enum): + """Partial backport of Python 3.11's StrEnum for our basic use cases.""" + + def __new__( + cls: type[_StrEnumSelfT], value: str, *args: Any, **kwargs: Any + ) -> _StrEnumSelfT: + """Create a new StrEnum instance.""" + if not isinstance(value, str): + raise TypeError(f"{value!r} is not a string") + return super().__new__(cls, value, *args, **kwargs) + + def __str__(self) -> str: + """Return self.value.""" + return str(self.value) + + @staticmethod + def _generate_next_value_( + name: str, start: int, count: int, last_values: list[Any] + ) -> Any: + """ + Make `auto()` explicitly unsupported. + We may revisit this when it's very clear that Python 3.11's + `StrEnum.auto()` behavior will no longer change. + """ + raise TypeError("auto() is not supported by this implementation") diff --git a/src/cloud.py b/src/cloud.py new file mode 100644 index 00000000..18f85063 --- /dev/null +++ b/src/cloud.py @@ -0,0 +1,665 @@ +import logging +import time +import datetime +import json +import base64 +from threading import Lock +from aiohttp import ClientSession +from secrets import token_hex +from .security import ( + CloudSecurity, + MeijuCloudSecurity, + MSmartCloudSecurity, + MideaAirSecurity, +) + +_LOGGER = logging.getLogger(__name__) + +clouds = { + "美的美居": { + "class_name": "MeijuCloud", + "app_id": "900", + "app_key": "46579c15", + "login_key": "ad0ee21d48a64bf49f4fb583ab76e799", + "iot_key": bytes.fromhex( + format(9795516279659324117647275084689641883661667, "x") + ).decode(), + "hmac_key": bytes.fromhex( + format(117390035944627627450677220413733956185864939010425, "x") + ).decode(), + "api_url": "https://mp-prod.smartmidea.net/mas/v5/app/proxy?alias=", + }, + "MSmartHome": { + "class_name": "MSmartHomeCloud", + "app_id": "1010", + "app_key": "ac21b9f9cbfe4ca5a88562ef25e2b768", + "iot_key": bytes.fromhex(format(7882822598523843940, "x")).decode(), + "hmac_key": bytes.fromhex( + format(117390035944627627450677220413733956185864939010425, "x") + ).decode(), + "api_url": "https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=", + }, + "Midea Air": { + "class_name": "MideaAirCloud", + "app_id": "1117", + "app_key": "ff0cf6f5f0c3471de36341cab3f7a9af", + "api_url": "https://mapp.appsmb.com", + }, + "NetHome Plus": { + "class_name": "MideaAirCloud", + "app_id": "1017", + "app_key": "3742e9e5842d4ad59c2db887e12449f9", + "api_url": "https://mapp.appsmb.com", + }, + "Ariston Clima": { + "class_name": "MideaAirCloud", + "app_id": "1005", + "app_key": "434a209a5ce141c3b726de067835d7f0", + "api_url": "https://mapp.appsmb.com", + }, +} + +default_keys = { + 99: { + "token": "ee755a84a115703768bcc7c6c13d3d629aa416f1e2fd798beb9f78cbb1381d09" + "1cc245d7b063aad2a900e5b498fbd936c811f5d504b2e656d4f33b3bbc6d1da3", + "key": "ed37bd31558a4b039aaf4e7a7a59aa7a75fd9101682045f69baf45d28380ae5c", + } +} + + +class MideaCloud: + def __init__( + self, + session: ClientSession, + security: CloudSecurity, + app_id: str, + app_key: str, + account: str, + password: str, + api_url: str, + ): + self._device_id = CloudSecurity.get_deviceid(account) + self._session = session + self._security = security + self._api_lock = Lock() + self._app_id = app_id + self._app_key = app_key + self._account = account + self._password = password + self._api_url = api_url + self._access_token = None + self._uid = None + self._login_id = None + + def _make_general_data(self): + return {} + + async def _api_request(self, endpoint: str, data: dict, header=None) -> dict | None: + header = header or {} + if not data.get("reqId"): + data.update({"reqId": token_hex(16)}) + if not data.get("stamp"): + data.update({"stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S")}) + random = str(int(time.time())) + url = self._api_url + endpoint + dump_data = json.dumps(data) + sign = self._security.sign("", dump_data, random) + header.update( + { + "content-type": "application/json; charset=utf-8", + "secretVersion": "1", + "sign": sign, + "random": random, + } + ) + if self._uid is not None: + header.update({"uid": self._uid}) + if self._access_token is not None: + header.update({"accessToken": self._access_token}) + response: dict = {"code": -1} + for i in range(0, 3): + try: + with self._api_lock: + r = await self._session.request( + "POST", url, headers=header, data=dump_data, timeout=10 + ) + raw = await r.read() + _LOGGER.debug( + f"Midea cloud API url: {url}, data: {data}, response: {raw}" + ) + response = json.loads(raw) + break + except Exception as e: + _LOGGER.warning(f"Midea cloud API error, url: {url}, error: {repr(e)}") + if int(response["code"]) == 0 and "data" in response: + return response["data"] + return None + + async def _get_login_id(self) -> str | None: + data = self._make_general_data() + data.update({"loginAccount": f"{self._account}"}) + if response := await self._api_request( + endpoint="/v1/user/login/id/get", data=data + ): + return response.get("loginId") + return None + + async def login(self) -> bool: + raise NotImplementedError() + + async def get_keys(self, appliance_id: int): + result = {} + for method in [1, 2]: + udp_id = self._security.get_udp_id(appliance_id, method) + data = self._make_general_data() + data.update({"udpid": udp_id}) + response = await self._api_request( + endpoint="/v1/iot/secure/getToken", data=data + ) + if response and "tokenlist" in response: + for token in response["tokenlist"]: + if token["udpId"] == udp_id: + result[method] = { + "token": token["token"].lower(), + "key": token["key"].lower(), + } + result.update(default_keys) + return result + + async def list_home(self) -> dict | None: + return {1: "My home"} + + async def list_appliances(self, home_id) -> dict | None: + raise NotImplementedError() + + async def get_device_info(self, device_id: int): + if response := await self.list_appliances(home_id=None): + if device_id in response.keys(): + return response[device_id] + return None + + async def download_lua( + self, + path: str, + device_type: int, + sn: str, + model_number: str | None, + manufacturer_code: str = "0000", + ): + raise NotImplementedError() + + +class MeijuCloud(MideaCloud): + def __init__( + self, + cloud_name: str, + session: ClientSession, + account: str, + password: str, + ): + super().__init__( + session=session, + security=MeijuCloudSecurity( + login_key=clouds[cloud_name]["login_key"], + iot_key=clouds[cloud_name]["iot_key"], + hmac_key=clouds[cloud_name]["hmac_key"], + ), + app_id=clouds[cloud_name]["app_id"], + app_key=clouds[cloud_name]["app_key"], + account=account, + password=password, + api_url=clouds[cloud_name]["api_url"], + ) + + async def login(self) -> bool: + if login_id := await self._get_login_id(): + self._login_id = login_id + stamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + data = { + "iotData": { + "clientType": 1, + "deviceId": self._device_id, + "iampwd": self._security.encrypt_iam_password( + self._login_id, self._password + ), + "iotAppId": self._app_id, + "loginAccount": self._account, + "password": self._security.encrypt_password( + self._login_id, self._password + ), + "reqId": token_hex(16), + "stamp": stamp, + }, + "data": { + "appKey": self._app_key, + "deviceId": self._device_id, + "platform": 2, + }, + "timestamp": stamp, + "stamp": stamp, + } + if response := await self._api_request( + endpoint="/mj/user/login", data=data + ): + self._access_token = response["mdata"]["accessToken"] + self._security.set_aes_keys( + self._security.aes_decrypt_with_fixed_key(response["key"]), None + ) + + return True + return False + + async def list_home(self): + if response := await self._api_request( + endpoint="/v1/homegroup/list/get", data={} + ): + homes = {} + for home in response["homeList"]: + homes.update({int(home["homegroupId"]): home["name"]}) + return homes + return None + + async def list_appliances(self, home_id) -> dict | None: + data = {"homegroupId": home_id} + if response := await self._api_request( + endpoint="/v1/appliance/home/list/get", data=data + ): + appliances = {} + for home in response.get("homeList") or []: + for room in home.get("roomList") or []: + for appliance in room.get("applianceList"): + try: + model_number = int(appliance.get("modelNumber", 0)) + except ValueError: + model_number = 0 + device_info = { + "name": appliance.get("name"), + "type": int(appliance.get("type"), 16), + "sn": ( + self._security.aes_decrypt(appliance.get("sn")) + if appliance.get("sn") + else "" + ), + "sn8": appliance.get("sn8", "00000000"), + "model_number": model_number, + "manufacturer_code": appliance.get( + "enterpriseCode", "0000" + ), + "model": appliance.get("productModel"), + "online": appliance.get("onlineStatus") == "1", + } + if ( + device_info.get("sn8") is None + or len(device_info.get("sn8")) == 0 + ): + device_info["sn8"] = "00000000" + if ( + device_info.get("model") is None + or len(device_info.get("model")) == 0 + ): + device_info["model"] = device_info["sn8"] + appliances[int(appliance["applianceCode"])] = device_info + return appliances + return None + + async def get_device_info(self, device_id: int): + data = {"applianceCode": device_id} + if response := await self._api_request( + endpoint="/v1/appliance/info/get", data=data + ): + try: + model_number = int(response.get("modelNumber", 0)) + except ValueError: + model_number = 0 + device_info = { + "name": response.get("name"), + "type": int(response.get("type"), 16), + "sn": ( + self._security.aes_decrypt(response.get("sn")) + if response.get("sn") + else "" + ), + "sn8": response.get("sn8", "00000000"), + "model_number": model_number, + "manufacturer_code": response.get("enterpriseCode", "0000"), + "model": response.get("productModel"), + "online": response.get("onlineStatus") == "1", + } + if device_info.get("sn8") is None or len(device_info.get("sn8")) == 0: + device_info["sn8"] = "00000000" + if device_info.get("model") is None or len(device_info.get("model")) == 0: + device_info["model"] = device_info["sn8"] + return device_info + return None + + async def download_lua( + self, + path: str, + device_type: int, + sn: str, + model_number: str | None, + manufacturer_code: str = "0000", + ): + data = { + "applianceSn": sn, + "applianceType": "0x%02X" % device_type, + "applianceMFCode": manufacturer_code, + "version": "0", + "iotAppId": self._app_id, + } + fnm = None + if response := await self._api_request( + endpoint="/v1/appliance/protocol/lua/luaGet", data=data + ): + res = await self._session.get(response["url"]) + if res.status == 200: + lua = await res.text() + if lua: + stream = ( + 'local bit = require "bit"\n' + + self._security.aes_decrypt_with_fixed_key(lua) + ) + stream = stream.replace("\r\n", "\n") + fnm = f"{path}/{response['fileName']}" + with open(fnm, "w") as fp: + fp.write(stream) + return fnm + + +class MSmartHomeCloud(MideaCloud): + def __init__( + self, + cloud_name: str, + session: ClientSession, + account: str, + password: str, + ): + super().__init__( + session=session, + security=MSmartCloudSecurity( + login_key=clouds[cloud_name]["app_key"], + iot_key=clouds[cloud_name]["iot_key"], + hmac_key=clouds[cloud_name]["hmac_key"], + ), + app_id=clouds[cloud_name]["app_id"], + app_key=clouds[cloud_name]["app_key"], + account=account, + password=password, + api_url=clouds[cloud_name]["api_url"], + ) + self._auth_base = base64.b64encode( + f"{self._app_key}:{clouds['MSmartHome']['iot_key']}".encode("ascii") + ).decode("ascii") + + def _make_general_data(self): + return { + # "appVersion": self.APP_VERSION, + "src": self._app_id, + "format": "2", + "stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S"), + "platformId": "1", + "deviceId": self._device_id, + "reqId": token_hex(16), + "uid": self._uid, + "clientType": "1", + "appId": self._app_id, + } + + async def _api_request(self, endpoint: str, data: dict, header=None) -> dict | None: + header = header or {} + header.update( + {"x-recipe-app": self._app_id, "authorization": f"Basic {self._auth_base}"} + ) + + return await super()._api_request(endpoint, data, header) + + async def _re_route(self): + data = self._make_general_data() + data.update({"userType": "0", "userName": f"{self._account}"}) + if response := await self._api_request( + endpoint="/v1/multicloud/platform/user/route", data=data + ): + if api_url := response.get("masUrl"): + self._api_url = api_url + + async def login(self) -> bool: + await self._re_route() + if login_id := await self._get_login_id(): + self._login_id = login_id + iot_data = self._make_general_data() + iot_data.pop("uid") + stamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + iot_data.update( + { + "iampwd": self._security.encrypt_iam_password( + self._login_id, self._password + ), + "loginAccount": self._account, + "password": self._security.encrypt_password( + self._login_id, self._password + ), + "stamp": stamp, + } + ) + data = { + "iotData": iot_data, + "data": { + "appKey": self._app_key, + "deviceId": self._device_id, + "platform": "2", + }, + "stamp": stamp, + } + if response := await self._api_request( + endpoint="/mj/user/login", data=data + ): + self._uid = response["uid"] + self._access_token = response["mdata"]["accessToken"] + self._security.set_aes_keys( + response["accessToken"], response["randomData"] + ) + return True + return False + + async def list_appliances(self, home_id) -> dict | None: + data = self._make_general_data() + if response := await self._api_request( + endpoint="/v1/appliance/user/list/get", data=data + ): + appliances = {} + for appliance in response["list"]: + try: + model_number = int(appliance.get("modelNumber", 0)) + except ValueError: + model_number = 0 + device_info = { + "name": appliance.get("name"), + "type": int(appliance.get("type"), 16), + "sn": ( + self._security.aes_decrypt(appliance.get("sn")) + if appliance.get("sn") + else "" + ), + "sn8": "", + "model_number": model_number, + "manufacturer_code": appliance.get("enterpriseCode", "0000"), + "model": "", + "online": appliance.get("onlineStatus") == "1", + } + device_info["sn8"] = ( + device_info.get("sn")[9:17] if len(device_info["sn"]) > 17 else "" + ) + device_info["model"] = device_info.get("sn8") + appliances[int(appliance["id"])] = device_info + return appliances + return None + + async def download_lua( + self, + path: str, + device_type: int, + sn: str, + model_number: str | None, + manufacturer_code: str = "0000", + ): + data = { + "clientType": "1", + "appId": self._app_id, + "format": "2", + "deviceId": self._device_id, + "iotAppId": self._app_id, + "applianceMFCode": manufacturer_code, + "applianceType": "0x%02X" % device_type, + "modelNumber": model_number, + "applianceSn": self._security.aes_encrypt_with_fixed_key( + sn.encode("ascii") + ).hex(), + "version": "0", + "encryptedType ": "2", + } + fnm = None + if response := await self._api_request( + endpoint="/v2/luaEncryption/luaGet", data=data + ): + res = await self._session.get(response["url"]) + if res.status == 200: + lua = await res.text() + if lua: + stream = ( + 'local bit = require "bit"\n' + + self._security.aes_decrypt_with_fixed_key(lua) + ) + stream = stream.replace("\r\n", "\n") + fnm = f"{path}/{response['fileName']}" + with open(fnm, "w") as fp: + fp.write(stream) + return fnm + + +class MideaAirCloud(MideaCloud): + def __init__( + self, + cloud_name: str, + session: ClientSession, + account: str, + password: str, + ): + super().__init__( + session=session, + security=MideaAirSecurity(login_key=clouds[cloud_name]["app_key"]), + app_id=clouds[cloud_name]["app_id"], + app_key=clouds[cloud_name]["app_key"], + account=account, + password=password, + api_url=clouds[cloud_name]["api_url"], + ) + self._session_id = None + + def _make_general_data(self): + data = { + "src": self._app_id, + "format": "2", + "stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S"), + "deviceId": self._device_id, + "reqId": token_hex(16), + "clientType": "1", + "appId": self._app_id, + } + if self._session_id is not None: + data.update({"sessionId": self._session_id}) + return data + + async def _api_request(self, endpoint: str, data: dict, header=None) -> dict | None: + header = header or {} + if not data.get("reqId"): + data.update({"reqId": token_hex(16)}) + if not data.get("stamp"): + data.update({"stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S")}) + url = self._api_url + endpoint + + sign = self._security.sign(url, data, "") + data.update({"sign": sign}) + if self._uid is not None: + header.update({"uid": self._uid}) + if self._access_token is not None: + header.update({"accessToken": self._access_token}) + response: dict = {"code": -1} + for i in range(0, 3): + try: + with self._api_lock: + r = await self._session.request( + "POST", url, headers=header, data=data, timeout=10 + ) + raw = await r.read() + _LOGGER.debug( + f"Midea cloud API url: {url}, data: {data}, response: {raw}" + ) + response = json.loads(raw) + break + except Exception as e: + _LOGGER.warning(f"Midea cloud API error, url: {url}, error: {repr(e)}") + if int(response["errorCode"]) == 0 and "result" in response: + return response["result"] + return None + + async def login(self) -> bool: + if login_id := await self._get_login_id(): + self._login_id = login_id + data = self._make_general_data() + data.update( + { + "loginAccount": self._account, + "password": self._security.encrypt_password( + self._login_id, self._password + ), + } + ) + if response := await self._api_request( + endpoint="/v1/user/login", data=data + ): + self._access_token = response["accessToken"] + self._uid = response["userId"] + self._session_id = response["sessionId"] + return True + return False + + async def list_appliances(self, home_id) -> dict | None: + data = self._make_general_data() + if response := await self._api_request( + endpoint="/v1/appliance/user/list/get", data=data + ): + appliances = {} + for appliance in response["list"]: + try: + model_number = int(appliance.get("modelNumber", 0)) + except ValueError: + model_number = 0 + device_info = { + "name": appliance.get("name"), + "type": int(appliance.get("type"), 16), + "sn": appliance.get("sn"), + "sn8": "", + "model_number": model_number, + "manufacturer_code": appliance.get("enterpriseCode", "0000"), + "model": "", + "online": appliance.get("onlineStatus") == "1", + } + device_info["sn8"] = ( + device_info.get("sn")[9:17] if len(device_info["sn"]) > 17 else "" + ) + device_info["model"] = device_info.get("sn8") + appliances[int(appliance["id"])] = device_info + return appliances + return None + + +def get_midea_cloud( + cloud_name: str, session: ClientSession, account: str, password: str +) -> MideaCloud | None: + cloud = None + if cloud_name in clouds.keys(): + cloud = globals()[clouds[cloud_name]["class_name"]]( + cloud_name=cloud_name, session=session, account=account, password=password + ) + return cloud diff --git a/src/crc8.py b/src/crc8.py new file mode 100644 index 00000000..3d56f2d2 --- /dev/null +++ b/src/crc8.py @@ -0,0 +1,270 @@ +crc8_854_table = [ + 0x00, + 0x5E, + 0xBC, + 0xE2, + 0x61, + 0x3F, + 0xDD, + 0x83, + 0xC2, + 0x9C, + 0x7E, + 0x20, + 0xA3, + 0xFD, + 0x1F, + 0x41, + 0x9D, + 0xC3, + 0x21, + 0x7F, + 0xFC, + 0xA2, + 0x40, + 0x1E, + 0x5F, + 0x01, + 0xE3, + 0xBD, + 0x3E, + 0x60, + 0x82, + 0xDC, + 0x23, + 0x7D, + 0x9F, + 0xC1, + 0x42, + 0x1C, + 0xFE, + 0xA0, + 0xE1, + 0xBF, + 0x5D, + 0x03, + 0x80, + 0xDE, + 0x3C, + 0x62, + 0xBE, + 0xE0, + 0x02, + 0x5C, + 0xDF, + 0x81, + 0x63, + 0x3D, + 0x7C, + 0x22, + 0xC0, + 0x9E, + 0x1D, + 0x43, + 0xA1, + 0xFF, + 0x46, + 0x18, + 0xFA, + 0xA4, + 0x27, + 0x79, + 0x9B, + 0xC5, + 0x84, + 0xDA, + 0x38, + 0x66, + 0xE5, + 0xBB, + 0x59, + 0x07, + 0xDB, + 0x85, + 0x67, + 0x39, + 0xBA, + 0xE4, + 0x06, + 0x58, + 0x19, + 0x47, + 0xA5, + 0xFB, + 0x78, + 0x26, + 0xC4, + 0x9A, + 0x65, + 0x3B, + 0xD9, + 0x87, + 0x04, + 0x5A, + 0xB8, + 0xE6, + 0xA7, + 0xF9, + 0x1B, + 0x45, + 0xC6, + 0x98, + 0x7A, + 0x24, + 0xF8, + 0xA6, + 0x44, + 0x1A, + 0x99, + 0xC7, + 0x25, + 0x7B, + 0x3A, + 0x64, + 0x86, + 0xD8, + 0x5B, + 0x05, + 0xE7, + 0xB9, + 0x8C, + 0xD2, + 0x30, + 0x6E, + 0xED, + 0xB3, + 0x51, + 0x0F, + 0x4E, + 0x10, + 0xF2, + 0xAC, + 0x2F, + 0x71, + 0x93, + 0xCD, + 0x11, + 0x4F, + 0xAD, + 0xF3, + 0x70, + 0x2E, + 0xCC, + 0x92, + 0xD3, + 0x8D, + 0x6F, + 0x31, + 0xB2, + 0xEC, + 0x0E, + 0x50, + 0xAF, + 0xF1, + 0x13, + 0x4D, + 0xCE, + 0x90, + 0x72, + 0x2C, + 0x6D, + 0x33, + 0xD1, + 0x8F, + 0x0C, + 0x52, + 0xB0, + 0xEE, + 0x32, + 0x6C, + 0x8E, + 0xD0, + 0x53, + 0x0D, + 0xEF, + 0xB1, + 0xF0, + 0xAE, + 0x4C, + 0x12, + 0x91, + 0xCF, + 0x2D, + 0x73, + 0xCA, + 0x94, + 0x76, + 0x28, + 0xAB, + 0xF5, + 0x17, + 0x49, + 0x08, + 0x56, + 0xB4, + 0xEA, + 0x69, + 0x37, + 0xD5, + 0x8B, + 0x57, + 0x09, + 0xEB, + 0xB5, + 0x36, + 0x68, + 0x8A, + 0xD4, + 0x95, + 0xCB, + 0x29, + 0x77, + 0xF4, + 0xAA, + 0x48, + 0x16, + 0xE9, + 0xB7, + 0x55, + 0x0B, + 0x88, + 0xD6, + 0x34, + 0x6A, + 0x2B, + 0x75, + 0x97, + 0xC9, + 0x4A, + 0x14, + 0xF6, + 0xA8, + 0x74, + 0x2A, + 0xC8, + 0x96, + 0x15, + 0x4B, + 0xA9, + 0xF7, + 0xB6, + 0xE8, + 0x0A, + 0x54, + 0xD7, + 0x89, + 0x6B, + 0x35, +] + + +def calculate(data): + crc_value = 0 + for m in data: + k = crc_value ^ m + if k > 256: + k -= 256 + if k < 0: + k += 256 + crc_value = crc8_854_table[k] + return crc_value diff --git a/src/device.py b/src/device.py new file mode 100644 index 00000000..80c15eda --- /dev/null +++ b/src/device.py @@ -0,0 +1,418 @@ +import logging +import socket +import threading +import time +from enum import IntEnum + +from .backports.enum import StrEnum +from .message import ( + MessageApplianceResponse, + MessageQueryAppliance, + MessageQuestCustom, + MessageType, +) +from .packet_builder import PacketBuilder +from .security import ( + MSGTYPE_ENCRYPTED_REQUEST, + MSGTYPE_HANDSHAKE_REQUEST, + LocalSecurity, +) + +_LOGGER = logging.getLogger(__name__) + + +class AuthException(Exception): + pass + + +class ResponseException(Exception): + pass + + +class RefreshFailed(Exception): + pass + + +class DeviceAttributes(StrEnum): + pass + + +class ParseMessageResult(IntEnum): + SUCCESS = 0 + PADDING = 1 + ERROR = 99 + + +class MideaDevice(threading.Thread): + def __init__( + self, + name: str, + device_id: int, + device_type: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + attributes: dict, + ): + threading.Thread.__init__(self) + self._attributes = attributes if attributes else {} + self._socket = None + self._ip_address = ip_address + self._port = port + self._security = LocalSecurity() + self._token = bytes.fromhex(token) if token else None + self._key = bytes.fromhex(key) if key else None + self._buffer = b"" + self._device_name = name + self._device_id = device_id + self._device_type = device_type + self._protocol = protocol + self._model = model + self._subtype = subtype + self._protocol_version = 0 + self._updates = [] + self._unsupported_protocol = [] + self._is_run = False + self._available = True + self._appliance_query = True + self._refresh_interval = 30 + self._heartbeat_interval = 10 + self._default_refresh_interval = 30 + + @property + def name(self): + return self._device_name + + @property + def available(self): + return self._available + + @property + def device_id(self): + return self._device_id + + @property + def device_type(self): + return self._device_type + + @property + def model(self): + return self._model + + @property + def subtype(self): + return self._subtype + + @staticmethod + def fetch_v2_message(msg): + result = [] + while len(msg) > 0: + factual_msg_len = len(msg) + if factual_msg_len < 6: + break + alleged_msg_len = msg[4] + (msg[5] << 8) + if factual_msg_len >= alleged_msg_len: + result.append(msg[:alleged_msg_len]) + msg = msg[alleged_msg_len:] + else: + break + return result, msg + + def connect(self, refresh_status=True): + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(10) + _LOGGER.debug( + f"[{self._device_id}] Connecting to {self._ip_address}:{self._port}" + ) + self._socket.connect((self._ip_address, self._port)) + _LOGGER.debug(f"[{self._device_id}] Connected") + if self._protocol == 3: + self.authenticate() + _LOGGER.debug(f"[{self._device_id}] Authentication success") + if refresh_status: + self.refresh_status(wait_response=True) + self.enable_device(True) + return True + except socket.timeout: + _LOGGER.debug(f"[{self._device_id}] Connection timed out") + except socket.error: + _LOGGER.debug(f"[{self._device_id}] Connection error") + except AuthException: + _LOGGER.debug(f"[{self._device_id}] Authentication failed") + except ResponseException: + _LOGGER.debug(f"[{self._device_id}] Unexpected response received") + except RefreshFailed: + _LOGGER.debug(f"[{self._device_id}] Refresh status is timed out") + except Exception as e: + _LOGGER.error( + f"[{self._device_id}] Unknown error: {e.__traceback__.tb_frame.f_globals['__file__']}, " + f"{e.__traceback__.tb_lineno}, {repr(e)}" + ) + self.enable_device(False) + return False + + def authenticate(self): + request = self._security.encode_8370(self._token, MSGTYPE_HANDSHAKE_REQUEST) + _LOGGER.debug(f"[{self._device_id}] Handshaking") + self._socket.send(request) + response = self._socket.recv(512) + if len(response) < 20: + raise AuthException() + response = response[8:72] + self._security.tcp_key(response, self._key) + + def send_message(self, data): + if self._protocol == 3: + self.send_message_v3(data, msg_type=MSGTYPE_ENCRYPTED_REQUEST) + else: + self.send_message_v2(data) + + def send_message_v2(self, data): + if self._socket is not None: + self._socket.send(data) + else: + _LOGGER.debug( + f"[{self._device_id}] Send failure, device disconnected, data: {data.hex()}" + ) + + def send_message_v3(self, data, msg_type=MSGTYPE_ENCRYPTED_REQUEST): + data = self._security.encode_8370(data, msg_type) + self.send_message_v2(data) + + def build_send(self, cmd): + data = cmd.serialize() + _LOGGER.debug(f"[{self._device_id}] Sending: {cmd}") + msg = PacketBuilder(self._device_id, data).finalize() + self.send_message(msg) + + def refresh_status(self, wait_response=False): + cmds: list = self.build_query() + if self._appliance_query: + cmds = [MessageQueryAppliance(self.device_type)] + cmds + error_count = 0 + for cmd in cmds: + if cmd.__class__.__name__ not in self._unsupported_protocol: + self.build_send(cmd) + if wait_response: + try: + while True: + msg = self._socket.recv(512) + if len(msg) == 0: + raise socket.error + result = self.parse_message(msg) + if result == ParseMessageResult.SUCCESS: + break + elif result == ParseMessageResult.PADDING: + continue + else: + raise ResponseException + except socket.timeout: + error_count += 1 + self._unsupported_protocol.append(cmd.__class__.__name__) + _LOGGER.debug( + f"[{self._device_id}] Does not supports " + f"the protocol {cmd.__class__.__name__}, ignored" + ) + except ResponseException: + error_count += 1 + else: + error_count += 1 + if error_count == len(cmds): + raise RefreshFailed + + def pre_process_message(self, msg): + if msg[9] == MessageType.query_appliance: + message = MessageApplianceResponse(msg) + self._appliance_query = False + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + self._protocol_version = message.protocol_version + _LOGGER.debug( + f"[{self._device_id}] Device protocol version: {self._protocol_version}" + ) + return False + return True + + def parse_message(self, msg): + if self._protocol == 3: + messages, self._buffer = self._security.decode_8370(self._buffer + msg) + else: + messages, self._buffer = self.fetch_v2_message(self._buffer + msg) + if len(messages) == 0: + return ParseMessageResult.PADDING + for message in messages: + if message == b"ERROR": + return ParseMessageResult.ERROR + payload_len = message[4] + (message[5] << 8) - 56 + payload_type = message[2] + (message[3] << 8) + if payload_type in [0x1001, 0x0001]: + # Heartbeat detected + pass + elif len(message) > 56: + cryptographic = message[40:-16] + if payload_len % 16 == 0: + decrypted = self._security.aes_decrypt(cryptographic) + try: + cont = True + if self._appliance_query: + cont = self.pre_process_message(decrypted) + if cont: + status = self.process_message(decrypted) + if len(status) > 0: + self.update_all(status) + else: + _LOGGER.debug( + f"[{self._device_id}] Unidentified protocol" + ) + except Exception: + _LOGGER.error( + f"[{self._device_id}] Error in process message, msg = {decrypted.hex()}" + ) + else: + _LOGGER.warning( + f"[{self._device_id}] Illegal payload, " + f"original message = {msg.hex()}, buffer = {self._buffer.hex()}, " + f"8370 decoded = {message.hex()}, payload type = {payload_type}, " + f"alleged payload length = {payload_len}, factual payload length = {len(cryptographic)}" + ) + else: + _LOGGER.warning( + f"[{self._device_id}] Illegal message, " + f"original message = {msg.hex()}, buffer = {self._buffer.hex()}, " + f"8370 decoded = {message.hex()}, payload type = {payload_type}, " + f"alleged payload length = {payload_len}, message length = {len(message)}, " + ) + return ParseMessageResult.SUCCESS + + def build_query(self): + raise NotImplementedError + + def process_message(self, msg): + raise NotImplementedError + + def send_command(self, cmd_type, cmd_body: bytearray): + cmd = MessageQuestCustom( + self._device_type, self._protocol_version, cmd_type, cmd_body + ) + try: + self.build_send(cmd) + except socket.error as e: + _LOGGER.debug( + f"[{self._device_id}] Interface send_command failure, {repr(e)}, " + f"cmd_type: {cmd_type}, cmd_body: {cmd_body.hex()}" + ) + + def send_heartbeat(self): + msg = PacketBuilder(self._device_id, bytearray([0x00])).finalize(msg_type=0) + self.send_message(msg) + + def register_update(self, update): + self._updates.append(update) + + def update_all(self, status): + _LOGGER.debug(f"[{self._device_id}] Status update: {status}") + for update in self._updates: + update(status) + + def enable_device(self, available=True): + self._available = available + status = {"available": available} + self.update_all(status) + + def open(self): + if not self._is_run: + self._is_run = True + threading.Thread.start(self) + + def close(self): + if self._is_run: + self._is_run = False + self.close_socket() + + def close_socket(self): + self._unsupported_protocol = [] + self._buffer = b"" + if self._socket: + self._socket.close() + self._socket = None + + def set_ip_address(self, ip_address): + if self._ip_address != ip_address: + _LOGGER.debug(f"[{self._device_id}] Update IP address to {ip_address}") + self._ip_address = ip_address + self.close_socket() + + def set_refresh_interval(self, refresh_interval): + self._refresh_interval = refresh_interval + + def run(self): + while self._is_run: + while self._socket is None: + if self.connect(refresh_status=True) is False: + if not self._is_run: + return + self.close_socket() + time.sleep(5) + timeout_counter = 0 + start = time.time() + previous_refresh = start + previous_heartbeat = start + self._socket.settimeout(1) + while True: + try: + now = time.time() + if 0 < self._refresh_interval <= now - previous_refresh: + self.refresh_status() + previous_refresh = now + if now - previous_heartbeat >= self._heartbeat_interval: + self.send_heartbeat() + previous_heartbeat = now + msg = self._socket.recv(512) + msg_len = len(msg) + if msg_len == 0: + raise socket.error("Connection closed by peer") + result = self.parse_message(msg) + if result == ParseMessageResult.ERROR: + _LOGGER.debug(f"[{self._device_id}] Message 'ERROR' received") + self.close_socket() + break + elif result == ParseMessageResult.SUCCESS: + timeout_counter = 0 + except socket.timeout: + timeout_counter = timeout_counter + 1 + if timeout_counter >= 120: + _LOGGER.debug(f"[{self._device_id}] Heartbeat timed out") + self.close_socket() + break + except socket.error as e: + if self._is_run: + _LOGGER.debug(f"[{self._device_id}] Socket error {repr(e)}") + self.close_socket() + break + except Exception as e: + _LOGGER.error( + f"[{self._device_id}] Unknown error :{e.__traceback__.tb_frame.f_globals['__file__']}, " + f"{e.__traceback__.tb_lineno}, {repr(e)}" + ) + self.close_socket() + break + + def set_attribute(self, attr, value): + raise NotImplementedError + + def get_attribute(self, attr): + return self._attributes.get(attr) + + def set_customize(self, customize): + pass + + @property + def attributes(self): + ret = {} + for status in self._attributes.keys(): + ret[str(status)] = self._attributes[status] + return ret diff --git a/src/devices/__init__.py b/src/devices/__init__.py new file mode 100644 index 00000000..0a7e29d8 --- /dev/null +++ b/src/devices/__init__.py @@ -0,0 +1,37 @@ +from importlib import import_module + + +def device_selector( + name: str, + device_id: int, + device_type: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, +): + try: + if device_type < 0xA0: + device_path = f".{'x%02x' % device_type}.device" + else: + device_path = f".{'%02x' % device_type}.device" + module = import_module(device_path, __package__) + device = module.MideaAppliance( + name=name, + device_id=device_id, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + customize=customize, + ) + except ModuleNotFoundError: + device = None + return device diff --git a/src/devices/a1/device.py b/src/devices/a1/device.py new file mode 100644 index 00000000..b067d10a --- /dev/null +++ b/src/devices/a1/device.py @@ -0,0 +1,182 @@ +import logging +from .message import MessageQuery, MessageA1Response, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from sdevice import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + prompt_tone = "prompt_tone" + child_lock = "child_lock" + mode = "mode" + fan_speed = "fan_speed" + swing = "swing" + target_humidity = "target_humidity" + anion = "anion" + tank = "tank" + water_level_set = "water_level_set" + tank_full = "tank_full" + current_humidity = "current_humidity" + current_temperature = "current_temperature" + + +class MideaA1Device(MideaDevice): + _modes = ["Manual", "Continuous", "Auto", "Clothes-Dry", "Shoes-Dry"] + _speeds = { + 1: "Lowest", + 40: "Low", + 60: "Medium", + 80: "High", + 102: "Auto", + 127: "Off", + } + _water_level_sets = ["25", "50", "75", "100"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xA1, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.prompt_tone: True, + DeviceAttributes.child_lock: False, + DeviceAttributes.mode: None, + DeviceAttributes.fan_speed: 60, + DeviceAttributes.swing: False, + DeviceAttributes.target_humidity: 35, + DeviceAttributes.anion: False, + DeviceAttributes.tank: 0, + DeviceAttributes.water_level_set: 50, + DeviceAttributes.tank_full: None, + DeviceAttributes.current_humidity: None, + DeviceAttributes.current_temperature: None, + }, + ) + + @property + def modes(self): + return MideaA1Device._modes + + @property + def fan_speeds(self): + return list(MideaA1Device._speeds.values()) + + @property + def water_level_sets(self): + return MideaA1Device._water_level_sets + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageA1Response(msg) + self._protocol_version = message.protocol_version + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.mode: + if value <= len(MideaA1Device._modes): + self._attributes[status] = MideaA1Device._modes[value - 1] + else: + self._attributes[status] = None + elif status == DeviceAttributes.fan_speed: + if value in MideaA1Device._speeds.keys(): + self._attributes[status] = MideaA1Device._speeds.get(value) + else: + self._attributes[status] = None + elif status == DeviceAttributes.water_level_set: + self._attributes[status] = str(value) + else: + self._attributes[status] = value + tank_full = self._attributes[DeviceAttributes.tank] >= int( + self._attributes[DeviceAttributes.water_level_set] + ) + if ( + self._attributes[DeviceAttributes.tank_full] is None + or self._attributes[DeviceAttributes.tank_full] != tank_full + ): + self._attributes[DeviceAttributes.tank_full] = tank_full + new_status[str(DeviceAttributes.tank_full)] = tank_full + new_status[str(status)] = self._attributes[status] + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + message.child_lock = self._attributes[DeviceAttributes.child_lock] + if self._attributes[DeviceAttributes.mode] in MideaA1Device._modes: + message.mode = ( + MideaA1Device._modes.index(self._attributes[DeviceAttributes.mode]) + 1 + ) + else: + message.mode = 1 + message.fan_speed = ( + 40 + if self._attributes[DeviceAttributes.fan_speed] is None + else list(MideaA1Device._speeds.keys())[ + list(MideaA1Device._speeds.values()).index( + self._attributes[DeviceAttributes.fan_speed] + ) + ] + ) + message.target_humidity = self._attributes[DeviceAttributes.target_humidity] + message.swing = self._attributes[DeviceAttributes.swing] + message.anion = self._attributes[DeviceAttributes.anion] + message.water_level_set = int( + self._attributes[DeviceAttributes.water_level_set] + ) + return message + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.prompt_tone: + self._attributes[DeviceAttributes.prompt_tone] = value + self.update_all({DeviceAttributes.prompt_tone.value: value}) + else: + message = self.make_message_set() + if attr == DeviceAttributes.mode: + if value in MideaA1Device._modes: + message.mode = MideaA1Device._modes.index(value) + 1 + elif attr == DeviceAttributes.fan_speed: + if value in MideaA1Device._speeds.values(): + message.fan_speed = list(MideaA1Device._speeds.keys())[ + list(MideaA1Device._speeds.values()).index(value) + ] + elif attr == DeviceAttributes.water_level_set: + if value in MideaA1Device._water_level_sets: + message.water_level_set = int(value) + else: + setattr(message, str(attr), value) + self.build_send(message) + + +class MideaAppliance(MideaA1Device): + pass diff --git a/src/devices/a1/message.py b/src/devices/a1/message.py new file mode 100644 index 00000000..200e0116 --- /dev/null +++ b/src/devices/a1/message.py @@ -0,0 +1,221 @@ +from enum import IntEnum +from ...crc8 import calculate +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, + NewProtocolMessageBody, +) + + +class NewProtocolTags(IntEnum): + light = 0x005B + + +class MessageA1Base(MessageRequest): + _message_serial = 0 + + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xA1, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + MessageA1Base._message_serial += 1 + if MessageA1Base._message_serial >= 100: + MessageA1Base._message_serial = 1 + self._message_id = MessageA1Base._message_serial + + @property + def _body(self): + raise NotImplementedError + + @property + def body(self): + body = bytearray([self.body_type]) + self._body + bytearray([self._message_id]) + body.append(calculate(body)) + return body + + +class MessageQuery(MessageA1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x41, + ) + + @property + def _body(self): + return bytearray( + [ + 0x81, + 0x00, + 0xFF, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessageNewProtocolQuery(MessageA1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0xB1, + ) + + @property + def _body(self): + query_params = [NewProtocolTags.light] + _body = bytearray([len(query_params)]) + for param in query_params: + _body.extend([param & 0xFF, param >> 8]) + return _body + + +class MessageSet(MessageA1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x48, + ) + self.power = False + self.prompt_tone = False + self.mode = 1 + self.fan_speed = 40 + self.child_lock = False + self.target_humidity = 40 + self.swing = False + self.anion = False + self.water_level_set = 50 + + @property + def _body(self): + # byte1, power, prompt_tone + power = 0x01 if self.power else 0x00 + prompt_tone = 0x40 if self.prompt_tone else 0x00 + # byte2 mode + mode = self.mode + # byte3 fan_speed + fan_speed = self.fan_speed + # byte7 target_humidity + target_humidity = self.target_humidity + # byte8 child_lock + child_lock = 0x80 if self.child_lock else 0x00 + # byte9 anion + anion = 0x40 if self.anion else 0x00 + # byte10 swing + swing = 0x08 if self.swing else 0x00 + # byte 13 water_level_set + water_level_set = self.water_level_set + return bytearray( + [ + power | prompt_tone | 0x02, + mode, + fan_speed, + 0x00, + 0x00, + 0x00, + target_humidity, + child_lock, + anion, + swing, + 0x00, + 0x00, + water_level_set, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessageNewProtocolSet(MessageA1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0xB0, + ) + self.light = None + + @property + def _body(self): + pack_count = 0 + payload = bytearray([0x00]) + if self.light is not None: + pack_count += 1 + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.light, + value=bytearray([0x01 if self.light else 0x00]), + ) + ) + payload[0] = pack_count + return payload + + +class A1GeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x01) > 0 + self.mode = body[2] & 0x0F + self.fan_speed = body[3] & 0x7F + self.target_humidity = 35 if (body[7] < 35) else body[7] + self.child_lock = (body[8] & 0x80) > 0 + self.anion = (body[9] & 0x40) > 0 + self.tank = body[10] & 0x7F + self.water_level_set = body[15] + self.current_humidity = body[16] + self.current_temperature = (body[17] - 50) / 2 + self.swing = (body[19] & 0x20) > 0 + if self.fan_speed < 5: + self.fan_speed = 1 + + +class A1NewProtocolMessageBody(NewProtocolMessageBody): + def __init__(self, body, bt): + super().__init__(body, bt) + params = self.parse() + if NewProtocolTags.light in params: + self.light = params[NewProtocolTags.light][0] > 0 + + +class MessageA1Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [ + MessageType.query, + MessageType.set, + MessageType.notify1, + ]: + if self.body_type in [0xB0, 0xB1, 0xB5]: + self.set_body(A1NewProtocolMessageBody(super().body, self.body_type)) + else: + self.set_body(A1GeneralMessageBody(super().body)) + elif self.message_type == MessageType.notify2 and self.body_type == 0xA0: + self.set_body(A1GeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/ac/device.py b/src/devices/ac/device.py new file mode 100644 index 00000000..df8483e3 --- /dev/null +++ b/src/devices/ac/device.py @@ -0,0 +1,373 @@ +import logging +import json +from .message import ( + MessageQuery, + MessageToggleDisplay, + MessageNewProtocolQuery, + MessageACResponse, + MessageGeneralSet, + MessageNewProtocolSet, + MessagePowerQuery, + MessageSubProtocolQuery, + MessageSubProtocolSet, +) + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + prompt_tone = "prompt_tone" + power = "power" + mode = "mode" + target_temperature = "target_temperature" + fan_speed = "fan_speed" + swing_vertical = "swing_vertical" + swing_horizontal = "swing_horizontal" + boost_mode = "boost_mode" + smart_eye = "smart_eye" + dry = "dry" + eco_mode = "eco_mode" + aux_heating = "aux_heating" + sleep_mode = "sleep_mode" + natural_wind = "natural_wind" + temp_fahrenheit = "temp_fahrenheit" + screen_display = "screen_display" + screen_display_alternate = "screen_display_alternate" + full_dust = "full_dust" + frost_protect = "frost_protect" + comfort_mode = "comfort_mode" + indoor_temperature = "indoor_temperature" + outdoor_temperature = "outdoor_temperature" + indirect_wind = "indirect_wind" + indoor_humidity = "indoor_humidity" + breezeless = "breezeless" + fresh_air_power = "fresh_air_power" + fresh_air_fan_speed = "fresh_air_fan_speed" + fresh_air_mode = "fresh_air_mode" + fresh_air_1 = "fresh_air_1" + fresh_air_2 = "fresh_air_2" + total_energy_consumption = "total_energy_consumption" + current_energy_consumption = "current_energy_consumption" + realtime_power = "realtime_power" + + +class MideaACDevice(MideaDevice): + _fresh_air_fan_speeds = { + 0: "Off", + 20: "Silent", + 40: "Low", + 60: "Medium", + 80: "High", + 100: "Full", + } + _fresh_air_fan_speeds_rev = dict(reversed(_fresh_air_fan_speeds.items())) + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xAC, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.prompt_tone: True, + DeviceAttributes.power: False, + DeviceAttributes.mode: 0, + DeviceAttributes.target_temperature: 24.0, + DeviceAttributes.fan_speed: 102, + DeviceAttributes.swing_vertical: False, + DeviceAttributes.swing_horizontal: False, + DeviceAttributes.smart_eye: False, + DeviceAttributes.dry: False, + DeviceAttributes.aux_heating: False, + DeviceAttributes.boost_mode: False, + DeviceAttributes.sleep_mode: False, + DeviceAttributes.frost_protect: False, + DeviceAttributes.comfort_mode: False, + DeviceAttributes.eco_mode: False, + DeviceAttributes.natural_wind: False, + DeviceAttributes.temp_fahrenheit: False, + DeviceAttributes.screen_display: False, + DeviceAttributes.screen_display_alternate: False, + DeviceAttributes.full_dust: False, + DeviceAttributes.indoor_temperature: None, + DeviceAttributes.outdoor_temperature: None, + DeviceAttributes.indirect_wind: False, + DeviceAttributes.indoor_humidity: None, + DeviceAttributes.breezeless: False, + DeviceAttributes.total_energy_consumption: None, + DeviceAttributes.current_energy_consumption: None, + DeviceAttributes.realtime_power: None, + DeviceAttributes.fresh_air_power: False, + DeviceAttributes.fresh_air_fan_speed: 0, + DeviceAttributes.fresh_air_mode: None, + DeviceAttributes.fresh_air_1: None, + DeviceAttributes.fresh_air_2: None, + }, + ) + self._fresh_air_version = None + self._default_temperature_step = 0.5 + self._temperature_step = None + self._used_subprotocol = False + self._bb_sn8_flag = False + self._bb_timer = False + self._power_analysis_method = None + self._default_power_analysis_method = 1 + self.set_customize(customize) + + @property + def temperature_step(self): + return self._temperature_step + + @property + def fresh_air_fan_speeds(self): + return list(MideaACDevice._fresh_air_fan_speeds.values()) + + def build_query(self): + if self._used_subprotocol: + return [ + MessageSubProtocolQuery(self._protocol_version, 0x10), + MessageSubProtocolQuery(self._protocol_version, 0x11), + MessageSubProtocolQuery(self._protocol_version, 0x30), + ] + return [ + MessageQuery(self._protocol_version), + MessageNewProtocolQuery(self._protocol_version), + MessagePowerQuery(self._protocol_version), + ] + + def process_message(self, msg): + message = MessageACResponse(msg, self._power_analysis_method) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + has_fresh_air = False + if hasattr(message, "used_subprotocol"): + self._used_subprotocol = True + if hasattr(message, "sn8_flag"): + self._bb_sn8_flag = message.sn8_flag + if hasattr(message, "timer"): + self._bb_timer = message.timer + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.fresh_air_power: + has_fresh_air = True + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + if has_fresh_air: + if self._attributes[DeviceAttributes.fresh_air_power]: + for k, v in MideaACDevice._fresh_air_fan_speeds_rev.items(): + if self._attributes[DeviceAttributes.fresh_air_fan_speed] > k: + break + else: + self._attributes[DeviceAttributes.fresh_air_mode] = v + else: + self._attributes[DeviceAttributes.fresh_air_mode] = "Off" + new_status[DeviceAttributes.fresh_air_mode.value] = self._attributes[ + DeviceAttributes.fresh_air_mode + ] + if not self._attributes[DeviceAttributes.power] or ( + DeviceAttributes.swing_vertical in new_status + and self._attributes[DeviceAttributes.swing_vertical] + ): + self._attributes[DeviceAttributes.indirect_wind] = False + new_status[DeviceAttributes.indirect_wind.value] = False + if not self._attributes[DeviceAttributes.power]: + self._attributes[DeviceAttributes.screen_display] = False + new_status[DeviceAttributes.screen_display.value] = False + if self._attributes[DeviceAttributes.fresh_air_1] is not None: + self._fresh_air_version = DeviceAttributes.fresh_air_1 + elif self._attributes[DeviceAttributes.fresh_air_2] is not None: + self._fresh_air_version = DeviceAttributes.fresh_air_2 + return new_status + + def make_message_set(self): + message = MessageGeneralSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + message.mode = self._attributes[DeviceAttributes.mode] + message.target_temperature = self._attributes[ + DeviceAttributes.target_temperature + ] + message.fan_speed = self._attributes[DeviceAttributes.fan_speed] + message.swing_vertical = self._attributes[DeviceAttributes.swing_vertical] + message.swing_horizontal = self._attributes[DeviceAttributes.swing_horizontal] + message.boost_mode = self._attributes[DeviceAttributes.boost_mode] + message.smart_eye = self._attributes[DeviceAttributes.smart_eye] + message.dry = self._attributes[DeviceAttributes.dry] + message.eco_mode = self._attributes[DeviceAttributes.eco_mode] + message.aux_heating = self._attributes[DeviceAttributes.aux_heating] + message.sleep_mode = self._attributes[DeviceAttributes.sleep_mode] + message.natural_wind = self._attributes[DeviceAttributes.natural_wind] + message.temp_fahrenheit = self._attributes[DeviceAttributes.temp_fahrenheit] + message.frost_protect = self._attributes[DeviceAttributes.frost_protect] + message.comfort_mode = self._attributes[DeviceAttributes.comfort_mode] + return message + + def make_subptotocol_message_set(self): + message = MessageSubProtocolSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + message.aux_heating = self._attributes[DeviceAttributes.aux_heating] + message.mode = self._attributes[DeviceAttributes.mode] + message.target_temperature = self._attributes[ + DeviceAttributes.target_temperature + ] + message.fan_speed = self._attributes[DeviceAttributes.fan_speed] + message.boost_mode = self._attributes[DeviceAttributes.boost_mode] + message.dry = self._attributes[DeviceAttributes.dry] + message.eco_mode = self._attributes[DeviceAttributes.eco_mode] + message.sleep_mode = self._attributes[DeviceAttributes.sleep_mode] + message.sn8_flag = self._bb_sn8_flag + message.timer = self._bb_timer + return message + + def make_message_uniq_set(self): + if self._used_subprotocol: + message = self.make_subptotocol_message_set() + else: + message = self.make_message_set() + return message + + def set_attribute(self, attr, value): + # if nat a sensor + message = None + if attr not in [ + DeviceAttributes.indoor_temperature, + DeviceAttributes.outdoor_temperature, + DeviceAttributes.indoor_humidity, + DeviceAttributes.full_dust, + DeviceAttributes.total_energy_consumption, + DeviceAttributes.current_energy_consumption, + DeviceAttributes.realtime_power, + ]: + if attr == DeviceAttributes.prompt_tone: + self._attributes[DeviceAttributes.prompt_tone] = value + self.update_all({DeviceAttributes.prompt_tone.value: value}) + elif attr == DeviceAttributes.screen_display: + message = MessageToggleDisplay(self._protocol_version) + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + elif attr in [ + DeviceAttributes.indirect_wind, + DeviceAttributes.breezeless, + DeviceAttributes.screen_display_alternate, + ]: + message = MessageNewProtocolSet(self._protocol_version) + setattr(message, str(attr), value) + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + elif attr == DeviceAttributes.fresh_air_power: + if self._fresh_air_version is not None: + message = MessageNewProtocolSet(self._protocol_version) + setattr( + message, + str(self._fresh_air_version), + [value, self._attributes[DeviceAttributes.fresh_air_fan_speed]], + ) + elif attr == DeviceAttributes.fresh_air_mode: + if value in MideaACDevice._fresh_air_fan_speeds.values(): + speed = list(MideaACDevice._fresh_air_fan_speeds.keys())[ + list(MideaACDevice._fresh_air_fan_speeds.values()).index(value) + ] + fresh_air = ( + [True, speed] + if speed > 0 + else [ + False, + self._attributes[DeviceAttributes.fresh_air_fan_speed], + ] + ) + message = MessageNewProtocolSet(self._protocol_version) + setattr(message, str(self._fresh_air_version), fresh_air) + elif not value: + message = MessageNewProtocolSet(self._protocol_version) + setattr( + message, + str(self._fresh_air_version), + [False, self._attributes[DeviceAttributes.fresh_air_fan_speed]], + ) + elif attr == DeviceAttributes.fresh_air_fan_speed: + if self._fresh_air_version is not None: + message = MessageNewProtocolSet(self._protocol_version) + fresh_air = ( + [True, value] + if value > 0 + else [ + False, + self._attributes[DeviceAttributes.fresh_air_fan_speed], + ] + ) + setattr(message, str(self._fresh_air_version), fresh_air) + elif attr in self._attributes.keys(): + message = self.make_message_uniq_set() + if attr in [ + DeviceAttributes.boost_mode, + DeviceAttributes.sleep_mode, + DeviceAttributes.frost_protect, + DeviceAttributes.comfort_mode, + DeviceAttributes.eco_mode, + ]: + message.boost_mode = False + message.sleep_mode = False + message.comfort_mode = False + message.eco_mode = False + message.frost_protect = False + setattr(message, str(attr), value) + if attr == DeviceAttributes.mode: + setattr(message, str(DeviceAttributes.power.value), True) + if message is not None: + self.build_send(message) + + def set_target_temperature(self, target_temperature, mode): + message = self.make_message_uniq_set() + message.target_temperature = target_temperature + if mode is not None: + message.power = True + message.mode = mode + self.build_send(message) + + def set_swing(self, swing_vertical, swing_horizontal): + message = self.make_message_uniq_set() + message.swing_vertical = swing_vertical + message.swing_horizontal = swing_horizontal + self.build_send(message) + + def set_customize(self, customize): + self._temperature_step = self._default_temperature_step + self._power_analysis_method = self._default_power_analysis_method + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "temperature_step" in params: + self._temperature_step = params.get("temperature_step") + if params and "power_analysis_method" in params: + self._power_analysis_method = params.get("power_analysis_method") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"temperature_step": self._temperature_step}) + + +class MideaAppliance(MideaACDevice): + pass diff --git a/src/devices/ac/message.py b/src/devices/ac/message.py new file mode 100644 index 00000000..2217453b --- /dev/null +++ b/src/devices/ac/message.py @@ -0,0 +1,720 @@ +from enum import IntEnum +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, + NewProtocolMessageBody, +) +from ...crc8 import calculate + +BB_AC_MODES = [0, 3, 1, 2, 4, 5] + + +class NewProtocolTags(IntEnum): + indoor_humidity = 0x0015 + screen_display = 0x0017 + breezeless = 0x0018 + prompt_tone = 0x001A + indirect_wind = 0x0042 + fresh_air_1 = 0x0233 + fresh_air_2 = 0x004B + + +class MessageACBase(MessageRequest): + _message_serial = 0 + + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xAC, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + MessageACBase._message_serial += 1 + if MessageACBase._message_serial >= 254: + MessageACBase._message_serial = 1 + self._message_id = MessageACBase._message_serial + + @property + def _body(self): + raise NotImplementedError + + @property + def body(self): + body = bytearray([self.body_type]) + self._body + bytearray([self._message_id]) + body.append(calculate(body)) + return body + + +class MessageQuery(MessageACBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x41, + ) + + @property + def _body(self): + return bytearray( + [ + 0x81, + 0x00, + 0xFF, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessagePowerQuery(MessageACBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x41, + ) + + @property + def _body(self): + return bytearray([0x21, 0x01, 0x44, 0x00, 0x01]) + + @property + def body(self): + body = bytearray([self.body_type]) + self._body + body.append(calculate(body)) + return body + + +class MessageToggleDisplay(MessageACBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x41, + ) + self.prompt_tone = False + + @property + def _body(self): + prompt_tone = 0x40 if self.prompt_tone else 0 + return bytearray( + [ + 0x02 | prompt_tone, + 0x00, + 0xFF, + 0x02, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessageNewProtocolQuery(MessageACBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0xB1, + ) + + @property + def _body(self): + query_params = [ + NewProtocolTags.indirect_wind, + NewProtocolTags.breezeless, + NewProtocolTags.indoor_humidity, + NewProtocolTags.screen_display, + NewProtocolTags.fresh_air_1, + NewProtocolTags.fresh_air_2, + ] + + _body = bytearray([len(query_params)]) + for param in query_params: + _body.extend([param & 0xFF, param >> 8]) + return _body + + +class MessageSubProtocol(MessageACBase): + def __init__(self, protocol_version, message_type, subprotocol_query_type): + super().__init__( + protocol_version=protocol_version, message_type=message_type, body_type=0xAA + ) + self._subprotocol_query_type = subprotocol_query_type + + @property + def _subprotocol_body(self): + return bytes([]) + + @property + def body(self): + body = bytearray([self.body_type]) + self._body + body.append(calculate(body)) + body.append(self.checksum(body)) + return body + + @property + def _body(self): + _subprotocol_body = self._subprotocol_body + _body = bytearray( + [ + 6 + + 2 + + (len(_subprotocol_body) if _subprotocol_body is not None else 0), + 0x00, + 0xFF, + 0xFF, + self._subprotocol_query_type, + ] + ) + if _subprotocol_body is not None: + _body.extend(_subprotocol_body) + return _body + + +class MessageSubProtocolQuery(MessageSubProtocol): + def __init__(self, protocol_version, subprotocol_query_type): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + subprotocol_query_type=subprotocol_query_type, + ) + + +class MessageSubProtocolSet(MessageSubProtocol): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + subprotocol_query_type=0x20, + ) + self.power = False + self.mode = 0 + self.target_temperature = 20.0 + self.fan_speed = 102 + self.boost_mode = False + self.aux_heating = False + self.dry = False + self.eco_mode = False + self.sleep_mode = False + self.sn8_flag = False + self.timer = False + self.prompt_tone = False + + @property + def _subprotocol_body(self): + power = 0x01 if self.power else 0 + dry = 0x10 if self.power and self.dry else 0 + boost_mode = 0x20 if self.boost_mode else 0 + aux_heating = 0x40 if self.aux_heating else 0x80 + sleep_mode = 0x80 if self.sleep_mode else 0 + try: + mode = 0 if self.mode == 0 else BB_AC_MODES[self.mode] - 1 + except IndexError: + mode = 2 # set Auto if invalid mode + target_temperature = int(self.target_temperature * 2 + 30) + water_model_temperature_set = int((self.target_temperature - 1) * 2 + 50) + fan_speed = self.fan_speed + eco = 0x40 if self.eco_mode else 0 + + prompt_tone = 0x01 if self.prompt_tone else 0 + timer = 0x04 if (self.sn8_flag and self.timer) else 0 + return bytearray( + [ + 0x02 | boost_mode | power | dry, + aux_heating, + sleep_mode, + 0x00, + 0x00, + mode, + target_temperature, + fan_speed, + 0x32, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x01, + 0x00, + 0x01, + water_model_temperature_set, + prompt_tone, + target_temperature, + 0x32, + 0x66, + 0x00, + eco | timer, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x08, + ] + ) + + +class MessageGeneralSet(MessageACBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x40, + ) + self.power = False + self.prompt_tone = True + self.mode = 0 + self.target_temperature = 20.0 + self.fan_speed = 102 + self.swing_vertical = False + self.swing_horizontal = False + self.boost_mode = False + self.smart_eye = False + self.dry = False + self.aux_heating = False + self.eco_mode = False + self.temp_fahrenheit = False + self.sleep_mode = False + self.natural_wind = False + self.frost_protect = False + self.comfort_mode = False + + @property + def _body(self): + # Byte1, Power, prompt_tone + power = 0x01 if self.power else 0 + prompt_tone = 0x40 if self.prompt_tone else 0 + # Byte2, mode target_temperature + mode = (self.mode << 5) & 0xE0 + target_temperature = (int(self.target_temperature) & 0xF) | ( + 0x10 if int(round(self.target_temperature * 2)) % 2 != 0 else 0 + ) + # Byte 3, fan_speed + fan_speed = self.fan_speed & 0x7F + # Byte 7, swing_mode + swing_mode = ( + 0x30 + | (0x0C if self.swing_vertical else 0) + | (0x03 if self.swing_horizontal else 0) + ) + # Byte 8, turbo + boost_mode = 0x20 if self.boost_mode else 0 + # Byte 9 aux_heating eco_mode + smart_eye = 0x01 if self.smart_eye else 0 + dry = 0x04 if self.dry else 0 + aux_heating = 0x08 if self.aux_heating else 0 + eco_mode = 0x80 if self.eco_mode else 0 + # Byte 10 temp_fahrenheit + temp_fahrenheit = 0x04 if self.temp_fahrenheit else 0 + sleep_mode = 0x01 if self.sleep_mode else 0 + boost_mode_1 = 0x02 if self.boost_mode else 0 + # Byte 17 natural_wind + natural_wind = 0x40 if self.natural_wind else 0 + # Byte 21 frost_protect + frost_protect = 0x80 if self.frost_protect else 0 + # Byte 22 comfort_mode + comfort_mode = 0x01 if self.comfort_mode else 0 + + return bytearray( + [ + power | prompt_tone, + mode | target_temperature, + fan_speed, + 0x00, + 0x00, + 0x00, + swing_mode, + boost_mode, + smart_eye | dry | aux_heating | eco_mode, + temp_fahrenheit | sleep_mode | boost_mode_1, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + natural_wind, + 0x00, + 0x00, + 0x00, + frost_protect, + comfort_mode, + ] + ) + + +class MessageNewProtocolSet(MessageACBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0xB0, + ) + self.indirect_wind = None + self.prompt_tone = None + self.breezeless = None + self.screen_display_alternate = None + self.fresh_air_1 = None + self.fresh_air_2 = None + + @property + def _body(self): + pack_count = 0 + payload = bytearray([0x00]) + if self.breezeless is not None: + pack_count += 1 + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.breezeless, + value=bytearray([0x01 if self.breezeless else 0x00]), + ) + ) + if self.indirect_wind is not None: + pack_count += 1 + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.indirect_wind, + value=bytearray([0x02 if self.indirect_wind else 0x01]), + ) + ) + if self.prompt_tone is not None: + pack_count += 1 + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.prompt_tone, + value=bytearray([0x01 if self.prompt_tone else 0x00]), + ) + ) + if self.screen_display_alternate is not None: + pack_count += 1 + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.screen_display, + value=bytearray([0x64 if self.screen_display_alternate else 0x00]), + ) + ) + if self.fresh_air_1 is not None and len(self.fresh_air_1) == 2: + pack_count += 1 + fresh_air_power = 2 if self.fresh_air_1[0] > 0 else 1 + fresh_air_fan_speed = self.fresh_air_1[1] + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.fresh_air_1, + value=bytearray( + [ + fresh_air_power, + fresh_air_fan_speed, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ), + ) + ) + if self.fresh_air_2 is not None and len(self.fresh_air_2) == 2: + pack_count += 1 + fresh_air_power = 1 if self.fresh_air_2[0] > 0 else 0 + fresh_air_fan_speed = self.fresh_air_2[1] + payload.extend( + NewProtocolMessageBody.pack( + param=NewProtocolTags.fresh_air_2, + value=bytearray([fresh_air_power, fresh_air_fan_speed, 0xFF]), + ) + ) + payload[0] = pack_count + return payload + + +class XA0MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x1) > 0 + self.target_temperature = ( + ((body[1] & 0x3E) >> 1) - 4 + 16.0 + (0.5 if body[1] & 0x40 > 0 else 0.0) + ) + self.mode = (body[2] & 0xE0) >> 5 + self.fan_speed = body[3] & 0x7F + self.swing_vertical = (body[7] & 0xC) > 0 + self.swing_horizontal = (body[7] & 0x3) > 0 + self.boost_mode = ((body[8] & 0x20) > 0) or ((body[10] & 0x2) > 0) + self.smart_eye = (body[9] & 0x01) > 0 + self.dry = (body[9] & 0x04) > 0 + self.aux_heating = (body[9] & 0x08) > 0 + self.eco_mode = (body[9] & 0x10) > 0 + self.sleep_mode = (body[10] & 0x01) > 0 + self.natural_wind = (body[10] & 0x40) > 0 + self.full_dust = (body[13] & 0x20) > 0 + self.comfort_mode = (body[14] & 0x1) > 0 if len(body) > 16 else False + + +class XA1MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + if body[13] != 0xFF: + temp_integer = int((body[13] - 50) / 2) + temp_decimal = ((body[18] & 0xF) * 0.1) if len(body) > 20 else 0 + if body[13] > 49: + self.indoor_temperature = temp_integer + temp_decimal + else: + self.indoor_temperature = temp_integer - temp_decimal + if body[14] == 0xFF: + self.outdoor_temperature = None + else: + temp_integer = int((body[14] - 50) / 2) + temp_decimal = (((body[18] & 0xF0) >> 4) * 0.1) if len(body) > 20 else 0 + if body[14] > 49: + self.outdoor_temperature = temp_integer + temp_decimal + else: + self.outdoor_temperature = temp_integer - temp_decimal + self.indoor_humidity = body[17] + + +class XBXMessageBody(NewProtocolMessageBody): + def __init__(self, body, bt): + super().__init__(body, bt) + params = self.parse() + if NewProtocolTags.indirect_wind in params: + self.indirect_wind = params[NewProtocolTags.indirect_wind][0] == 0x02 + if NewProtocolTags.indoor_humidity in params: + self.indoor_humidity = params[NewProtocolTags.indoor_humidity][0] + if NewProtocolTags.breezeless in params: + self.breezeless = params[NewProtocolTags.breezeless][0] == 1 + if NewProtocolTags.screen_display in params: + self.screen_display_alternate = ( + params[NewProtocolTags.screen_display][0] > 0 + ) + self.screen_display_new = True + if NewProtocolTags.fresh_air_1 in params: + self.fresh_air_1 = True + data = params[NewProtocolTags.fresh_air_1] + self.fresh_air_power = data[0] == 0x02 + self.fresh_air_fan_speed = data[1] + if NewProtocolTags.fresh_air_2 in params: + self.fresh_air_2 = True + data = params[NewProtocolTags.fresh_air_2] + self.fresh_air_power = data[0] > 0 + self.fresh_air_fan_speed = data[1] + + +class XC0MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x1) > 0 + self.mode = (body[2] & 0xE0) >> 5 + self.target_temperature = ( + (body[2] & 0x0F) + 16.0 + (0.5 if body[0x02] & 0x10 > 0 else 0.0) + ) + self.fan_speed = body[3] & 0x7F + self.swing_vertical = (body[7] & 0x0C) > 0 + self.swing_horizontal = (body[7] & 0x03) > 0 + self.boost_mode = ((body[8] & 0x20) > 0) or ((body[10] & 0x2) > 0) + self.smart_eye = (body[8] & 0x40) > 0 + self.natural_wind = (body[9] & 0x2) > 0 + self.dry = (body[9] & 0x4) > 0 + self.eco_mode = (body[9] & 0x10) > 0 + self.aux_heating = (body[9] & 0x08) > 0 + self.temp_fahrenheit = (body[10] & 0x04) > 0 + self.sleep_mode = (body[10] & 0x01) > 0 + if body[11] != 0xFF: + temp_integer = int((body[11] - 50) / 2) + temp_decimal = (body[15] & 0x0F) * 0.1 + if body[11] > 49: + self.indoor_temperature = temp_integer + temp_decimal + else: + self.indoor_temperature = temp_integer - temp_decimal + if body[12] == 0xFF: + self.outdoor_temperature = None + else: + temp_integer = int((body[12] - 50) / 2) + temp_decimal = ((body[15] & 0xF0) >> 4) * 0.1 + if body[12] > 49: + self.outdoor_temperature = temp_integer + temp_decimal + else: + self.outdoor_temperature = temp_integer - temp_decimal + self.full_dust = (body[13] & 0x20) > 0 + self.screen_display = ((body[14] >> 4 & 0x7) != 0x07) and self.power + self.frost_protect = (body[21] & 0x80) > 0 if len(body) >= 22 else False + self.comfort_mode = (body[22] & 0x1) > 0 if len(body) >= 23 else False + + +class XC1MessageBody(MessageBody): + def __init__(self, body, analysis_method=3): + super().__init__(body) + if body[3] == 0x44: + self.total_energy_consumption = XC1MessageBody.parse_consumption( + analysis_method, body[4], body[5], body[6], body[7] + ) + self.current_energy_consumption = XC1MessageBody.parse_consumption( + analysis_method, body[12], body[13], body[14], body[15] + ) + self.realtime_power = XC1MessageBody.parse_power( + analysis_method, body[16], body[17], body[18] + ) + elif body[3] == 0x40: + pass + + @staticmethod + def parse_value(byte): + return (byte >> 4) * 10 + (byte & 0x0F) + + @staticmethod + def parse_power(analysis_method, byte1, byte2, byte3): + if analysis_method == 1: + return ( + float( + XC1MessageBody.parse_value(byte1) * 10000 + + XC1MessageBody.parse_value(byte2) * 100 + + XC1MessageBody.parse_value(byte3) + ) + / 10 + ) + elif analysis_method == 2: + return float((byte1 << 16) + (byte2 << 8) + byte3) / 10 + else: + return float(byte1 * 10000 + byte2 * 100 + byte3) / 10 + + @staticmethod + def parse_consumption(analysis_method, byte1, byte2, byte3, byte4): + if analysis_method == 1: + return ( + float( + XC1MessageBody.parse_value(byte1) * 1000000 + + XC1MessageBody.parse_value(byte2) * 10000 + + XC1MessageBody.parse_value(byte3) * 100 + + XC1MessageBody.parse_value(byte4) + ) + / 100 + ) + elif analysis_method == 2: + return float((byte1 << 32) + (byte2 << 16) + (byte3 << 8) + byte4) / 10 + else: + return float(byte1 * 1000000 + byte2 * 10000 + byte3 * 100 + byte4) / 100 + + +class XBBMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + subprotocol_head = body[:6] + subprotocol_body = body[6:] + data_type = subprotocol_head[-1] + subprotocol_body_len = len(subprotocol_body) + if data_type == 0x20 or data_type == 0x11: + self.power = (subprotocol_body[0] & 0x1) > 0 + self.dry = (subprotocol_body[0] & 0x10) > 0 + self.boost_mode = (subprotocol_body[0] & 0x20) > 0 + self.aux_heating = (subprotocol_body[1] & 0x40) > 0 + self.sleep_mode = (subprotocol_body[2] & 0x80) > 0 + try: + self.mode = BB_AC_MODES.index(subprotocol_body[5] + 1) + except ValueError: + self.mode = 0 + self.target_temperature = (subprotocol_body[6] - 30) / 2 + self.fan_speed = subprotocol_body[7] + self.timer = ( + (subprotocol_body[25] & 0x04) > 0 + if subprotocol_body_len > 27 + else False + ) + self.eco_mode = ( + (subprotocol_body[25] & 0x40) > 0 + if subprotocol_body_len > 27 + else False + ) + elif data_type == 0x10: + if subprotocol_body[8] & 0x80 == 0x80: + self.indoor_temperature = ( + 0 - (~(subprotocol_body[7] + subprotocol_body[8] * 256) + 1) + & 0xFFFF + ) / 100 + else: + self.indoor_temperature = ( + subprotocol_body[7] + subprotocol_body[8] * 256 + ) / 100 + self.indoor_humidity = subprotocol_body[30] + self.sn8_flag = subprotocol_body[80] == 0x31 + elif data_type == 0x12: + pass + elif data_type == 0x30: + if subprotocol_body[6] & 0x80 == 0x80: + self.outdoor_temperature = ( + 0 - (~(subprotocol_body[5] + subprotocol_body[6] * 256) + 1) + & 0xFFFF + ) / 100 + else: + self.outdoor_temperature = ( + subprotocol_body[5] + subprotocol_body[6] * 256 + ) / 100 + elif data_type == 0x13 or data_type == 0x21: + pass + + +class MessageACResponse(MessageResponse): + def __init__(self, message, power_analysis_method=3): + super().__init__(message) + if self.message_type == MessageType.notify2 and self.body_type == 0xA0: + self.set_body(XA0MessageBody(super().body)) + elif self.message_type == MessageType.notify1 and self.body_type == 0xA1: + self.set_body(XA1MessageBody(super().body)) + elif self.message_type in [ + MessageType.query, + MessageType.set, + MessageType.notify2, + ] and self.body_type in [0xB0, 0xB1, 0xB5]: + self.set_body(XBXMessageBody(super().body, self.body_type)) + elif ( + self.message_type in [MessageType.query, MessageType.set] + and self.body_type == 0xC0 + ): + self.set_body(XC0MessageBody(super().body)) + elif self.message_type == MessageType.query and self.body_type == 0xC1: + self.set_body(XC1MessageBody(super().body, power_analysis_method)) + elif ( + self.message_type + in [MessageType.set, MessageType.query, MessageType.notify2] + and self.body_type == 0xBB + and len(super().body) >= 21 + ): + self.used_subprotocol = True + self.set_body(XBBMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/b0/device.py b/src/devices/b0/device.py new file mode 100644 index 00000000..6e1daadd --- /dev/null +++ b/src/devices/b0/device.py @@ -0,0 +1,95 @@ +import logging +from .message import MessageQuery01, MessageB0Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + door = "door" + status = "status" + time_remaining = "time_remaining" + current_temperature = "current_temperature" + tank_ejected = "tank_ejected" + water_change_reminder = "water_change_reminder" + water_shortage = "water_shortage" + + +class MideaB0Device(MideaDevice): + _status = { + 0x01: "Standby", + 0x02: "Idle", + 0x03: "Working", + 0x04: "Finished", + 0x05: "Delay", + 0x06: "Paused", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xB0, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.door: False, + DeviceAttributes.status: None, + DeviceAttributes.time_remaining: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.tank_ejected: False, + DeviceAttributes.water_change_reminder: False, + DeviceAttributes.water_shortage: False, + }, + ) + + def build_query(self): + return [MessageQuery01(self._protocol_version)] + + def process_message(self, msg): + message = MessageB0Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.status: + if value in MideaB0Device._status.keys(): + self._attributes[DeviceAttributes.status] = ( + MideaB0Device._status.get(value) + ) + else: + self._attributes[DeviceAttributes.status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaB0Device): + pass diff --git a/src/devices/b0/message.py b/src/devices/b0/message.py new file mode 100644 index 00000000..fdab266d --- /dev/null +++ b/src/devices/b0/message.py @@ -0,0 +1,83 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class MessageB0Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xB0, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery00(MessageB0Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x00, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageQuery01(MessageB0Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class B0MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + if len(body) > 15: + self.door = (body[0] & 0x80) > 0 + self.status = body[0] & 0x7F + self.time_remaining = body[2] * 60 + body[3] + self.error_code = body[5] + + +class B0Message01Body(MessageBody): + def __init__(self, body): + super().__init__(body) + if len(body) > 15: + self.door = (body[32] & 0x02) > 0 + self.status = body[31] + self.time_remaining = ( + (0 if body[22] == 0xFF else body[22]) * 3600 + + (0 if body[23] == 0xFF else body[23]) * 60 + + (0 if body[24] == 0xFF else body[24]) + ) + self.current_temperature = (body[25] << 8) + (body[26]) + if self.current_temperature == 0: + self.current_temperature = (body[27] << 8) + body[28] + self.tank_ejected = (body[32] & 0x04) > 0 + self.water_shortage = (body[32] & 0x08) > 0 + self.water_change_reminder = (body[32] & 0x10) > 0 + + +class MessageB0Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [MessageType.notify1, MessageType.query]: + if self.body_type == 0x01: + self.set_body(B0Message01Body(super().body)) + elif self.body_type == 0x04: + pass + else: + self.set_body(B0MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/b1/device.py b/src/devices/b1/device.py new file mode 100644 index 00000000..136d91e4 --- /dev/null +++ b/src/devices/b1/device.py @@ -0,0 +1,95 @@ +import logging +from .message import MessageQuery, MessageB1Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + door = "door" + status = "status" + time_remaining = "time_remaining" + current_temperature = "current_temperature" + tank_ejected = "tank_ejected" + water_change_reminder = "water_change_reminder" + water_shortage = "water_shortage" + + +class MideaB1Device(MideaDevice): + _status = { + 0x01: "Standby", + 0x02: "Idle", + 0x03: "Working", + 0x04: "Finished", + 0x05: "Delay", + 0x06: "Paused", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xB1, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.door: False, + DeviceAttributes.status: None, + DeviceAttributes.time_remaining: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.tank_ejected: False, + DeviceAttributes.water_change_reminder: False, + DeviceAttributes.water_shortage: False, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageB1Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.status: + if value in MideaB1Device._status.keys(): + self._attributes[DeviceAttributes.status] = ( + MideaB1Device._status.get(value) + ) + else: + self._attributes[DeviceAttributes.status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaB1Device): + pass diff --git a/src/devices/b1/message.py b/src/devices/b1/message.py new file mode 100644 index 00000000..92607776 --- /dev/null +++ b/src/devices/b1/message.py @@ -0,0 +1,52 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class MessageB1Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xB1, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageB1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x00, + ) + + @property + def _body(self): + return bytearray([]) + + +class B1MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.door = (body[16] & 0x02) > 0 + self.status = body[1] + self.time_remaining = ( + (0 if body[6] == 0xFF else body[6]) * 3600 + + (0 if body[7] == 0xFF else body[7]) * 60 + + (0 if body[8] == 0xFF else body[8]) + ) + self.current_temperature = body[19] + self.tank_ejected = (body[16] & 0x04) > 0 + self.water_shortage = (body[16] & 0x08) > 0 + self.water_change_reminder = (body[16] & 0x10) > 0 + + +class MessageB1Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [MessageType.notify1, MessageType.query]: + self.set_body(B1MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/b3/device.py b/src/devices/b3/device.py new file mode 100644 index 00000000..317225d9 --- /dev/null +++ b/src/devices/b3/device.py @@ -0,0 +1,126 @@ +import logging +from .message import MessageQuery, MessageB3Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + top_compartment_status = "top_compartment_status" + top_compartment_mode = "top_compartment_mode" + top_compartment_temperature = "top_compartment_temperature" + top_compartment_remaining = "top_compartment_remaining" + top_compartment_door = "top_compartment_door" + top_compartment_preheating = "top_compartment_preheating" + top_compartment_cooling = "top_compartment_cooling" + middle_compartment_status = "middle_compartment_status" + middle_compartment_mode = "middle_compartment_mode" + middle_compartment_temperature = "middle_compartment_temperature" + middle_compartment_remaining = "middle_compartment_remaining" + middle_compartment_door = "middle_compartment_door" + middle_compartment_preheating = "middle_compartment_preheating" + middle_compartment_cooling = "middle_compartment_cooling" + bottom_compartment_status = "bottom_compartment_status" + bottom_compartment_mode = "bottom_compartment_mode" + bottom_compartment_temperature = "bottom_compartment_temperature" + bottom_compartment_remaining = "bottom_compartment_remaining" + bottom_compartment_door = "bottom_compartment_door" + bottom_compartment_preheating = "bottom_compartment_preheating" + bottom_compartment_cooling = "bottom_compartment_cooling" + lock = "lock" + + +class MideaB2Device(MideaDevice): + _status = { + 0x00: "Off", + 0x01: "Standby", + 0x02: "Working", + 0x03: "Delay", + 0x04: "Finished", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xB3, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.top_compartment_status: None, + DeviceAttributes.top_compartment_mode: None, + DeviceAttributes.top_compartment_temperature: None, + DeviceAttributes.top_compartment_remaining: None, + DeviceAttributes.top_compartment_door: False, + DeviceAttributes.top_compartment_preheating: False, + DeviceAttributes.top_compartment_cooling: False, + DeviceAttributes.middle_compartment_status: None, + DeviceAttributes.middle_compartment_mode: None, + DeviceAttributes.middle_compartment_temperature: None, + DeviceAttributes.middle_compartment_remaining: None, + DeviceAttributes.middle_compartment_door: False, + DeviceAttributes.middle_compartment_preheating: False, + DeviceAttributes.middle_compartment_cooling: False, + DeviceAttributes.bottom_compartment_status: None, + DeviceAttributes.bottom_compartment_mode: None, + DeviceAttributes.bottom_compartment_temperature: None, + DeviceAttributes.bottom_compartment_remaining: None, + DeviceAttributes.bottom_compartment_door: False, + DeviceAttributes.bottom_compartment_preheating: False, + DeviceAttributes.bottom_compartment_cooling: False, + DeviceAttributes.lock: False, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageB3Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status in [ + DeviceAttributes.top_compartment_status, + DeviceAttributes.middle_compartment_status, + DeviceAttributes.bottom_compartment_status, + ]: + if value in MideaB2Device._status.keys(): + self._attributes[status] = MideaB2Device._status.get(value) + else: + self._attributes[status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaB2Device): + pass diff --git a/src/devices/b3/message.py b/src/devices/b3/message.py new file mode 100644 index 00000000..6418fcbb --- /dev/null +++ b/src/devices/b3/message.py @@ -0,0 +1,181 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class MessageB3Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xB3, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageB3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x31, + ) + + @property + def _body(self): + return bytearray([]) + + +class B3MessageBody31(MessageBody): + def __init__(self, body): + super().__init__(body) + self.top_compartment_status = body[1] + self.top_compartment_mode = body[2] + self.top_compartment_temperature = body[3] + self.top_compartment_remaining = ( + body[23] * 3600 + if len(body) > 23 and body[23] != 0xFF + else ( + 0 + body[4] * 60 + if body[4] != 0xFF + else 0 + body[5] + if body[5] != 0xFF + else 0 + ) + ) + self.bottom_compartment_status = body[6] + self.bottom_compartment_mode = body[7] + self.bottom_compartment_temperature = body[8] + self.bottom_compartment_remaining = ( + body[24] * 3600 + if len(body) > 24 and body[24] != 0xFF + else ( + 0 + body[9] * 60 + if body[9] != 0xFF + else 0 + body[10] + if body[10] != 0xFF + else 0 + ) + ) + self.middle_compartment_status = body[17] + self.middle_compartment_mode = body[18] + self.middle_compartment_temperature = body[19] + self.middle_compartment_remaining = ( + body[25] * 3600 + if len(body) > 25 and body[25] != 0xFF + else ( + 0 + body[20] * 60 + if body[20] != 0xFF + else 0 + body[21] + if body[21] != 0xFF + else 0 + ) + ) + self.lock = body[11] & 0x01 > 0 + self.bottom_compartment_door = body[11] & 0x02 > 0 + self.top_compartment_door = body[11] & 0x04 > 0 + self.middle_compartment_door = body[11] & 0x10 > 0 + self.bottom_compartment_preheating = body[16] & 0x01 > 0 + self.top_compartment_preheating = body[16] & 0x02 > 0 + self.middle_compartment_preheating = body[16] & 0x10 > 0 + self.bottom_compartment_cooling = body[16] & 0x04 > 0 + self.top_compartment_cooling = body[16] & 0x08 > 0 + self.middle_compartment_cooling = body[16] & 0x20 > 0 + + +class B3MessageBody21(MessageBody): + def __init__(self, body): + super().__init__(body) + self.top_compartment_status = body[1] + self.top_compartment_mode = body[2] + self.top_compartment_temperature = body[3] + self.top_compartment_remaining = ( + body[17] * 3600 + if len(body) > 17 and body[17] != 0xFF + else ( + 0 + body[4] * 60 + if body[4] != 0xFF + else 0 + body[5] + if body[5] != 0xFF + else 0 + ) + ) + self.bottom_compartment_status = body[6] + self.bottom_compartment_mode = body[7] + self.bottom_compartment_temperature = body[8] + self.bottom_compartment_remaining = ( + body[18] * 3600 + if len(body) > 18 and body[18] != 0xFF + else ( + 0 + body[9] * 60 + if body[9] != 0xFF + else 0 + body[10] + if body[10] != 0xFF + else 0 + ) + ) + self.middle_compartment_status = body[12] + self.middle_compartment_mode = body[13] + self.middle_compartment_temperature = body[14] + self.middle_compartment_remaining = ( + body[19] * 3600 + if len(body) > 19 and body[19] != 0xFF + else ( + 0 + body[15] * 60 + if body[15] != 0xFF + else 0 + body[16] + if body[16] != 0xFF + else 0 + ) + ) + self.lock = body[11] & 0x01 > 0 + + +class B3MessageBody24(MessageBody): + def __init__(self, body): + super().__init__(body) + self.top_compartment_status = body[5] + self.top_compartment_mode = body[6] + self.top_compartment_temperature = body[7] + self.top_compartment_remaining = ( + body[8] * 60 if body[8] != 0xFF else 0 + body[9] if body[9] != 0xFF else 0 + ) + self.bottom_compartment_status = body[10] + self.bottom_compartment_mode = body[11] + self.bottom_compartment_temperature = body[12] + self.bottom_compartment_remaining = ( + body[13] * 60 + if body[13] != 0xFF + else 0 + body[14] + if body[14] != 0xFF + else 0 + ) + self.bottom_compartment_status = body[15] + self.bottom_compartment_mode = body[16] + self.bottom_compartment_temperature = body[17] + self.bottom_compartment_remaining = ( + body[18] * 60 + if body[18] != 0xFF + else 0 + body[19] + if body[19] != 0xFF + else 0 + ) + + +class MessageB3Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type == MessageType.query + and self.body_type == 0x31 + or self.message_type == MessageType.notify1 + and self.body_type == 0x41 + ): + self.set_body(B3MessageBody31(super().body)) + elif self.message_type == MessageType.set and self.body_type == 0x21: + self.set_body(B3MessageBody21(super().body)) + elif self.message_type == MessageType.set and self.body_type == 0x24: + self.set_body(B3MessageBody21(super().body)) + self.set_attr() diff --git a/src/devices/b4/device.py b/src/devices/b4/device.py new file mode 100644 index 00000000..259df641 --- /dev/null +++ b/src/devices/b4/device.py @@ -0,0 +1,95 @@ +import logging +from .message import MessageQuery, MessageB4Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + door = "door" + status = "status" + time_remaining = "time_remaining" + current_temperature = "current_temperature" + tank_ejected = "tank_ejected" + water_change_reminder = "water_change_reminder" + water_shortage = "water_shortage" + + +class MideaB4Device(MideaDevice): + _status = { + 0x01: "Standby", + 0x02: "Idle", + 0x03: "Working", + 0x04: "Finished", + 0x05: "Delay", + 0x06: "Paused", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xB4, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.door: False, + DeviceAttributes.status: None, + DeviceAttributes.time_remaining: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.tank_ejected: False, + DeviceAttributes.water_change_reminder: False, + DeviceAttributes.water_shortage: False, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageB4Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.status: + if value in MideaB4Device._status.keys(): + self._attributes[DeviceAttributes.status] = ( + MideaB4Device._status.get(value) + ) + else: + self._attributes[DeviceAttributes.status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaB4Device): + pass diff --git a/src/devices/b4/message.py b/src/devices/b4/message.py new file mode 100644 index 00000000..6d0d1351 --- /dev/null +++ b/src/devices/b4/message.py @@ -0,0 +1,59 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class MessageB4Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xB4, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageB4Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class B4MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.time_remaining = ( + (0 if body[22] == 0xFF else body[22]) * 3600 + + (0 if body[23] == 0xFF else body[23]) * 60 + + (0 if body[24] == 0xFF else body[24]) + ) + self.current_temperature = (body[25] << 8) + body[26] + if self.current_temperature == 0: + self.current_temperature = (body[27] << 8) + body[28] + self.status = body[31] + self.door = (body[32] & 0x02) > 0 + self.tank_ejected = (body[16] & 0x04) > 0 + self.water_shortage = (body[16] & 0x08) > 0 + self.water_change_reminder = (body[16] & 0x10) > 0 + + +class MessageB4Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [ + MessageType.notify1, + MessageType.query, + MessageType.set, + ]: + if self.body_type == 0x01: + self.set_body(B4MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/b6/device.py b/src/devices/b6/device.py new file mode 100644 index 00000000..b51701c0 --- /dev/null +++ b/src/devices/b6/device.py @@ -0,0 +1,168 @@ +import logging +import json +from .message import MessageQuery, MessageB6Response, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + light = "light" + mode = "mode" + fan_level = "fan_level" + fan_speed = "fan_speed" + oilcup_full = "oilcup_full" + cleaning_reminder = "cleaning_reminder" + + +class MideaB6Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xB6, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.light: None, + DeviceAttributes.mode: None, + DeviceAttributes.fan_level: 0, + DeviceAttributes.fan_speed: 0, + DeviceAttributes.oilcup_full: False, + DeviceAttributes.cleaning_reminder: False, + }, + ) + self._default_speeds = {0: "Off", 1: "Level 1", 2: "Level 2"} + self._default_power_speed = 2 + self._power_speed = self._default_power_speed + self._speeds = self._default_speeds + self.set_customize(customize) + + @property + def speed_count(self): + return len(self._speeds) - 1 + + @property + def preset_modes(self): + return list(self._speeds.values()) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageB6Response(msg) + self._protocol_version = message.protocol_version + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.fan_level: + if value in self._speeds.keys(): + self._attributes[DeviceAttributes.mode] = self._speeds.get( + value + ) + self._attributes[DeviceAttributes.fan_speed] = list( + self._speeds.keys() + ).index(value) + else: + self._attributes[DeviceAttributes.mode] = None + self._attributes[DeviceAttributes.fan_speed] = 0 + new_status[DeviceAttributes.mode.value] = self._attributes[ + DeviceAttributes.mode + ] + new_status[DeviceAttributes.fan_speed.value] = self._attributes[ + DeviceAttributes.fan_speed + ] + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + message = None + if attr == DeviceAttributes.fan_speed: + if value < len(self._speeds): + message = MessageSet(self._protocol_version) + message.fan_level = list(self._speeds.keys())[value] + elif attr == DeviceAttributes.mode: + if value in self._speeds.values(): + message = MessageSet(self._protocol_version) + message.fan_level = list(self._speeds.keys())[ + list(self._speeds.values()).index(value) + ] + elif not value: + message = MessageSet(self._protocol_version) + message.power = False + elif attr == DeviceAttributes.power: + message = MessageSet(self._protocol_version) + message.power = value + message.fan_level = self._power_speed + elif attr == DeviceAttributes.light: + message = MessageSet(self._protocol_version) + message.light = value + if message is not None: + self.build_send(message) + + def turn_on(self, fan_speed=None, mode=None): + message = MessageSet(self._protocol_version) + message.power = True + if fan_speed is not None and fan_speed < len(self._speeds): + message.fan_level = list(self._speeds.keys())[fan_speed] + else: + message.fan_level = self._power_speed + if mode is not None in self._speeds.values(): + message.fan_level = list(self._speeds.keys())[ + list(self._speeds.values()).index(mode) + ] + self.build_send(message) + + def set_customize(self, customize): + self._speeds = self._default_speeds + self._power_speed = self._default_power_speed + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params: + if "default_speed" in params: + self._power_speed = int(params.get("default_speed")) + if "speeds" in params: + self._speeds = {} + speeds = {} + for k, v in params.get("speeds").items(): + speeds[int(k)] = v + keys = sorted(speeds.keys()) + for k in keys: + self._speeds[k] = speeds[k] + self.update_all( + {"speeds": self._speeds, "default_speed": self._power_speed} + ) + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + + +class MideaAppliance(MideaB6Device): + pass diff --git a/src/devices/b6/message.py b/src/devices/b6/message.py new file mode 100644 index 00000000..08a26589 --- /dev/null +++ b/src/devices/b6/message.py @@ -0,0 +1,224 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageB6Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xB6, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageB6Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x11 if protocol_version == 2 else 0x31, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageQueryTips(MessageB6Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x02, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessageSet(MessageB6Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x22 if protocol_version in [0x00, 0x01] else 0x11, + ) + self.light = None + self.power = None + self.fan_level = None + + @property + def _body(self): + if self.protocol_version in [0x00, 0x01]: + light = 0xFF + value2 = 0xFF + value3 = 0xFF + if self.light is not None: + if self.light: + light = 0x1A + else: + light = 0 + elif self.power is not None: + if self.power: + value2 = 0x02 + if self.fan_level is not None: + value3 = self.fan_level + else: + value3 = 0x01 + else: + value2 = 0x03 + elif self.fan_level is not None: + if self.fan_level == 0: + value2 = 0x03 + else: + value2 = 0x02 + value3 = self.fan_level + return bytearray( + [0x01, light, value2, value3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] + ) + else: + value13 = 0xFF + value14 = 0xFF + value15 = 0xFF + value16 = 0xFF + if self.power is not None: + value13 = 0x01 + if self.power: + value15 = 0x02 + if self.fan_level is not None: + value16 = self.fan_level + else: + value16 = 0x01 + else: + value15 = 0x01 + elif self.fan_level is not None: + value13 = 0x01 + if self.fan_level == 0: + value15 = 0x01 + else: + value15 = 0x02 + value16 = self.fan_level + elif self.light is not None: + value13 = 0x02 + value14 = 0x02 + value15 = 0x01 if self.light else 0x00 + return bytearray([0x01, value13, value14, value15, value16, 0xFF, 0xFF]) + + +class B6FeedbackBody(MessageBody): + def __init__(self, body): + super().__init__(body) + + +class B6GeneralBody(MessageBody): + def __init__(self, body): + super().__init__(body) + if body[1] != 0xFF: + self.light = body[1] > 0x00 + self.power = False + fan_level = None + if body[2] != 0xFF: + self.power = body[2] in [0x02, 0x06, 0x07, 0x14, 0x15, 0x16] + if body[2] in [0x14, 0x16]: + fan_level = 0x16 + if fan_level is None and body[3] != 0xFF: + fan_level = body[3] + if fan_level > 100: + if fan_level < 130: + fan_level = 1 + elif fan_level < 140: + fan_level = 2 + elif fan_level < 170: + fan_level = 3 + else: + fan_level = 4 + else: + self.fan_level = fan_level + self.fan_level = 0 if fan_level is None else fan_level + self.oilcup_full = (body[5] & 0x01) > 0 + self.cleaning_reminder = (body[5] & 0x02) > 0 + + +class B6NewProtocolBody(MessageBody): + def __init__(self, body): + super().__init__(body) + if body[1] == 0x01: + pack_bytes = body[3 : 3 + body[2]] + if pack_bytes[1] != 0xFF: + self.power = True + self.power = pack_bytes[1] not in [0x00, 0x01, 0x05, 0x07] + if pack_bytes[2] != 0xFF: + self.fan_level = pack_bytes[2] + if pack_bytes[6] != 0xFF: + self.light = pack_bytes[6] > 0 + self.oilcup_full = (pack_bytes[18] & 0x02) > 0 + self.cleaning_reminder = (pack_bytes[18] & 0x04) > 0 + + +class B6SpecialBody(MessageBody): + def __init__(self, body): + super().__init__(body) + if body[2] != 0xFF: + self.light = body[2] > 0x00 + self.power = False + if body[3] != 0xFF: + self.power = body[3] in [0x00, 0x02, 0x04] + if body[4] != 0xFF: + self.fan_level = body[4] + + +class B6ExceptionBody(MessageBody): + def __init__(self, body): + super().__init__(body) + + +class MessageB6Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type == MessageType.set + and self.body_type == 0x22 + and super().body[1] == 0x01 + ): + self.set_body(B6SpecialBody(super().body)) + elif ( + self.message_type == MessageType.set + and self.body_type == 0x11 + and super().body[1] == 0x01 + ): + ############################# + pass + elif self.message_type == MessageType.query: + if self.body_type in [0x11, 0x31]: + if self.protocol_version in [0, 1]: + self.set_body(B6GeneralBody(super().body)) + else: + self.set_body(B6NewProtocolBody(super().body)) + elif self.body_type == 0x32 and super().body[1] == 0x01: + self.set_body(B6ExceptionBody(super().body)) + elif self.message_type == MessageType.notify1: + if self.body_type in [0x11, 0x41]: + if self.protocol_version in [0, 1]: + self.set_body(B6GeneralBody(super().body)) + else: + self.set_body(B6NewProtocolBody(super().body)) + elif self.body_type == 0x0A: + if super().body[1] == 0xA1: + self.set_body(B6ExceptionBody(super().body)) + elif super().body[1] == 0xA2: + self.oilcup_full = (super().body[2] & 0x01) > 0 + self.cleaning_reminder = (super().body[2] & 0x02) > 0 + elif self.message_type == MessageType.exception2 and self.body_type == 0xA1: + pass + + self.set_attr() diff --git a/src/devices/bf/device.py b/src/devices/bf/device.py new file mode 100644 index 00000000..e92e33c1 --- /dev/null +++ b/src/devices/bf/device.py @@ -0,0 +1,95 @@ +import logging +from .message import MessageQuery, MessageBFResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + door = "door" + status = "status" + time_remaining = "time_remaining" + current_temperature = "current_temperature" + tank_ejected = "tank_ejected" + water_change_reminder = "water_change_reminder" + water_shortage = "water_shortage" + + +class MideaBFDevice(MideaDevice): + _status = { + 0x01: "PowerSave", + 0x02: "Standby", + 0x03: "Working", + 0x04: "Finished", + 0x05: "Delay", + 0x06: "Paused", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xBF, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.door: None, + DeviceAttributes.status: None, + DeviceAttributes.time_remaining: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.tank_ejected: None, + DeviceAttributes.water_change_reminder: None, + DeviceAttributes.water_shortage: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageBFResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.status: + if value in MideaBFDevice._status.keys(): + self._attributes[DeviceAttributes.status] = ( + MideaBFDevice._status.get(value) + ) + else: + self._attributes[DeviceAttributes.status] = "Unknown" + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaBFDevice): + pass diff --git a/src/devices/bf/message.py b/src/devices/bf/message.py new file mode 100644 index 00000000..77221372 --- /dev/null +++ b/src/devices/bf/message.py @@ -0,0 +1,79 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class MessageBFBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xBF, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageBFBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(MessageBFBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x02, + ) + self.power = None + self.child_lock = None + + @property + def _body(self): + power = 0xFF if self.power is None else 0x11 if self.power else 0x01 + child_lock = ( + 0xFF if self.child_lock is None else 0x01 if self.child_lock else 0x00 + ) + return bytearray([power, child_lock] + [0xFF] * 7) + + +class MessageBFBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.status = body[31] + self.time_remaining = ( + (0 if body[22] == 0xFF else body[22]) * 3600 + + (0 if body[23] == 0xFF else body[23]) * 60 + + (0 if body[24] == 0xFF else body[24]) + ) + cur_temperature = body[25] * 256 + body[26] + if cur_temperature == 0: + cur_temperature = body[27] * 256 + body[28] + self.current_temperature = cur_temperature + self.child_lock = (body[32] & 0x01) > 0 + self.door = (body[32] & 0x02) > 0 + self.tank_ejected = (body[32] & 0x04) > 0 + self.water_state = (body[32] & 0x08) > 0 + self.water_change_reminder = (body[32] & 0x10) > 0 + + +class MessageBFResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type + in [MessageType.set, MessageType.notify1, MessageType.query] + and self.body_type == 0x01 + ): + self.set_body(MessageBFBody(super().body)) + self.set_attr() diff --git a/src/devices/c2/device.py b/src/devices/c2/device.py new file mode 100644 index 00000000..176b2713 --- /dev/null +++ b/src/devices/c2/device.py @@ -0,0 +1,151 @@ +import logging +import json +from .message import MessageQuery, MessageC2Response, MessageSet, MessagePower + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + child_lock = "child_lock" + sensor_light = "sensor_light" + foam_shield = "foam_shield" + seat_status = "seat_status" + lid_status = "lid_status" + light_status = "light_status" + dry_level = "dry_level" + water_temp_level = "water_temp_level" + seat_temp_level = "seat_temp_level" + water_temperature = "water_temperature" + seat_temperature = "seat_temperature" + filter_life = "filter_life" + + +class MideaC2Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xC2, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.child_lock: False, + DeviceAttributes.sensor_light: False, + DeviceAttributes.foam_shield: False, + DeviceAttributes.light_status: None, + DeviceAttributes.seat_status: None, + DeviceAttributes.lid_status: None, + DeviceAttributes.dry_level: 0, + DeviceAttributes.water_temp_level: 0, + DeviceAttributes.seat_temp_level: 0, + DeviceAttributes.water_temperature: None, + DeviceAttributes.seat_temperature: None, + DeviceAttributes.filter_life: None, + }, + ) + self._max_dry_level = None + self._max_water_temp_level = None + self._max_seat_temp_level = None + self._default_max_dry_level = 3 + self._default_max_water_temp_level = 5 + self._default_max_seat_temp_level = 5 + self.set_customize(customize) + + @property + def max_dry_level(self): + return self._max_dry_level + + @property + def max_water_temp_level(self): + return self._max_water_temp_level + + @property + def max_seat_temp_level(self): + return self._max_seat_temp_level + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageC2Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = getattr(message, str(status)) + return new_status + + def set_attribute(self, attr, value): + message = None + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + elif attr in [ + DeviceAttributes.child_lock, + DeviceAttributes.sensor_light, + DeviceAttributes.foam_shield, + DeviceAttributes.water_temp_level, + DeviceAttributes.seat_temp_level, + DeviceAttributes.dry_level, + ]: + message = MessageSet(self._protocol_version) + setattr(message, attr, value) + if message: + self.build_send(message) + + def set_customize(self, customize): + self._max_dry_level = self._default_max_dry_level + self._max_water_temp_level = self._default_max_water_temp_level + self._max_seat_temp_level = self._default_max_seat_temp_level + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "max_dry_level" in params: + self._max_dry_level = params.get("max_dry_level") + if params and "max_water_temp_level" in params: + self._max_water_temp_level = params.get("max_water_temp_level") + if params and "max_seat_temp_level" in params: + self._max_seat_temp_level = params.get("max_seat_temp_level") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all( + { + "dry_level": {"max_dry_level": self._max_dry_level}, + "water_temp_level": { + "max_water_temp_level": self._max_water_temp_level + }, + "seat_temp_level": { + "max_seat_temp_level": self._max_seat_temp_level + }, + } + ) + + +class MideaAppliance(MideaC2Device): + pass diff --git a/src/devices/c2/message.py b/src/devices/c2/message.py new file mode 100644 index 00000000..0923dea1 --- /dev/null +++ b/src/devices/c2/message.py @@ -0,0 +1,175 @@ +from enum import IntEnum +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class C2MessageEnum(IntEnum): + sensor_light = 0x01 + child_lock = 0x10 + foam_shield = 0x1F + water_temp_level = 0x09 + seat_temp_level = 0x0A + dry_level = 0x0C + + +C2_MESSAGE_KEYS = { + C2MessageEnum.child_lock: {True: 0x01 << 4, False: 0x00}, + C2MessageEnum.sensor_light: {True: 0x01 << 1, False: 0x00}, + C2MessageEnum.foam_shield: {True: 0x01 << 2, False: 0x00}, + C2MessageEnum.dry_level: {0: 0x00, 1: 0x01 << 1, 2: 0x02 << 1, 3: 0x03 << 1}, + C2MessageEnum.seat_temp_level: { + 0: 0x00, + 1: 0x01 << 3, + 2: 0x02 << 3, + 3: 0x03 << 3, + 4: 0x04 << 3, + 5: 0x05 << 3, + }, + C2MessageEnum.water_temp_level: { + 0: 0x00, + 1: 0x01, + 2: 0x02, + 3: 0x03, + 4: 0x04, + 5: 0x05, + }, +} + + +class MessageC2Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xC2, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageC2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessagePower(MessageC2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x00, + ) + self.power = False + + @property + def _body(self): + if self.power: + self.body_type = 0x01 + else: + self.body_type = 0x02 + return bytearray([0x01]) + + +class MessagePowerOff(MessageC2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessageSet(MessageC2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x00, + ) + + self.child_lock = None + self.sensor_light = None + self.water_temp_level = None + self.seat_temp_level = None + self.dry_level = None + self.foam_shield = None + + @property + def _body(self): + self.body_type = 0x14 + key = 0x00 + value = 0x00 + if self.child_lock is not None: + key = C2MessageEnum.child_lock + value = self.child_lock + elif self.sensor_light is not None: + key = C2MessageEnum.sensor_light + value = self.sensor_light + elif self.water_temp_level is not None: + key = C2MessageEnum.water_temp_level + value = self.water_temp_level + elif self.seat_temp_level is not None: + key = C2MessageEnum.seat_temp_level + value = self.seat_temp_level + elif self.dry_level is not None: + key = C2MessageEnum.dry_level + value = self.dry_level + elif self.foam_shield is not None: + key = C2MessageEnum.foam_shield + value = self.foam_shield + value = C2_MESSAGE_KEYS.get(key).get(value) + return bytearray([key, value]) + + +class C2MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[2] & 0x01) > 0 + self.seat_status = (body[3] & 0x01) > 0 + self.dry_level = (body[6] & 0x7E) >> 1 + self.water_temp_level = body[9] & 0x07 + self.seat_temp_level = (body[9] & 0x38) >> 3 + self.lid_status = (body[12] & 0x40) > 0 + self.foam_shield = (body[13] & 0x80) > 0 + self.sensor_light = (body[14] & 0x01) > 0 + self.light_status = (body[14] & 0x02) > 0 + self.child_lock = (body[14] & 0x04) > 0 + self.water_temperature = body[11] + self.seat_temperature = body[11] + self.filter_life = 100 - body[19] + + +class C2Notify1MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + + +class MessageC2Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [ + MessageType.notify1, + MessageType.query, + MessageType.set, + ]: + self.set_body(C2MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/c3/device.py b/src/devices/c3/device.py new file mode 100644 index 00000000..ee473dec --- /dev/null +++ b/src/devices/c3/device.py @@ -0,0 +1,284 @@ +import logging +from .message import ( + MessageQuery, + MessageSetSilent, + MessageSetECO, + MessageC3Response, + MessageSet, +) + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + zone1_power = "zone1_power" + zone2_power = "zone2_power" + dhw_power = "dhw_power" + zone1_curve = "zone1_curve" + zone2_curve = "zone2_curve" + disinfect = "disinfect" + fast_dhw = "fast_dhw" + zone_temp_type = "zone_temp_type" + zone1_room_temp_mode = "zone1_room_temp_mode" + zone2_room_temp_mode = "zone2_room_temp_mode" + zone1_water_temp_mode = "zone1_water_temp_mode" + zone2_water_temp_mode = "zone2_water_temp_mode" + mode = "mode" + mode_auto = "mode_auto" + zone_target_temp = "zone_target_temp" + dhw_target_temp = "dhw_target_temp" + room_target_temp = "room_target_temp" + zone_heating_temp_max = "zone_heating_temp_max" + zone_heating_temp_min = "zone_heating_temp_min" + zone_cooling_temp_max = "zone_cooling_temp_max" + zone_cooling_temp_min = "zone_cooling_temp_min" + tank_actual_temperature = "tank_actual_temperature" + room_temp_max = "room_temp_max" + room_temp_min = "room_temp_min" + dhw_temp_max = "dhw_temp_max" + dhw_temp_min = "dhw_temp_min" + target_temperature = "target_temperature" + temperature_max = "temperature_max" + temperature_min = "temperature_min" + status_heating = "status_heating" + status_dhw = "status_dhw" + status_tbh = "status_tbh" + status_ibh = "status_ibh" + total_energy_consumption = "total_energy_consumption" + total_produced_energy = "total_produced_energy" + outdoor_temperature = "outdoor_temperature" + silent_mode = "silent_mode" + eco_mode = "eco_mode" + tbh = "tbh" + error_code = "error_code" + + +class MideaC3Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xC3, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.zone1_power: False, + DeviceAttributes.zone2_power: False, + DeviceAttributes.dhw_power: False, + DeviceAttributes.zone1_curve: False, + DeviceAttributes.zone2_curve: False, + DeviceAttributes.disinfect: False, + DeviceAttributes.fast_dhw: False, + DeviceAttributes.zone_temp_type: [False, False], + DeviceAttributes.zone1_room_temp_mode: False, + DeviceAttributes.zone2_room_temp_mode: False, + DeviceAttributes.zone1_water_temp_mode: False, + DeviceAttributes.zone2_water_temp_mode: False, + DeviceAttributes.silent_mode: False, + DeviceAttributes.eco_mode: False, + DeviceAttributes.tbh: False, + DeviceAttributes.mode: 1, + DeviceAttributes.mode_auto: 1, + DeviceAttributes.zone_target_temp: [25, 25], + DeviceAttributes.dhw_target_temp: 25, + DeviceAttributes.room_target_temp: 30, + DeviceAttributes.zone_heating_temp_max: [55, 55], + DeviceAttributes.zone_heating_temp_min: [25, 25], + DeviceAttributes.zone_cooling_temp_max: [25, 25], + DeviceAttributes.zone_cooling_temp_min: [5, 5], + DeviceAttributes.room_temp_max: 60, + DeviceAttributes.room_temp_min: 34, + DeviceAttributes.dhw_temp_max: 60, + DeviceAttributes.dhw_temp_min: 20, + DeviceAttributes.tank_actual_temperature: None, + DeviceAttributes.target_temperature: [25, 25], + DeviceAttributes.temperature_max: [0, 0], + DeviceAttributes.temperature_min: [0, 0], + DeviceAttributes.total_energy_consumption: None, + DeviceAttributes.status_heating: None, + DeviceAttributes.status_dhw: None, + DeviceAttributes.status_tbh: None, + DeviceAttributes.status_ibh: None, + DeviceAttributes.total_produced_energy: None, + DeviceAttributes.outdoor_temperature: None, + DeviceAttributes.error_code: 0, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageC3Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = getattr(message, str(status)) + if "zone_temp_type" in new_status: + for zone in [0, 1]: + if self._attributes[DeviceAttributes.zone_temp_type][ + zone + ]: # Water temp mode + self._attributes[DeviceAttributes.target_temperature][zone] = ( + self._attributes[DeviceAttributes.zone_target_temp][zone] + ) + if ( + self._attributes[DeviceAttributes.mode_auto] == 2 + ): # cooling mode + self._attributes[DeviceAttributes.temperature_max][zone] = ( + self._attributes[ + DeviceAttributes.zone_cooling_temp_max + ][zone] + ) + self._attributes[DeviceAttributes.temperature_min][zone] = ( + self._attributes[ + DeviceAttributes.zone_cooling_temp_min + ][zone] + ) + elif self._attributes[DeviceAttributes.mode] == 3: # heating mode + self._attributes[DeviceAttributes.temperature_max][zone] = ( + self._attributes[ + DeviceAttributes.zone_heating_temp_max + ][zone] + ) + self._attributes[DeviceAttributes.temperature_min][zone] = ( + self._attributes[ + DeviceAttributes.zone_heating_temp_min + ][zone] + ) + else: # Room temp mode + self._attributes[DeviceAttributes.target_temperature][zone] = ( + self._attributes[DeviceAttributes.room_target_temp] + ) + self._attributes[DeviceAttributes.temperature_max][zone] = ( + self._attributes[DeviceAttributes.room_temp_max] + ) + self._attributes[DeviceAttributes.temperature_min][zone] = ( + self._attributes[DeviceAttributes.room_temp_min] + ) + if self._attributes[DeviceAttributes.zone1_power]: + if self._attributes[DeviceAttributes.zone_temp_type][zone]: + self._attributes[DeviceAttributes.zone1_water_temp_mode] = True + self._attributes[DeviceAttributes.zone1_room_temp_mode] = False + else: + self._attributes[DeviceAttributes.zone1_water_temp_mode] = False + self._attributes[DeviceAttributes.zone1_room_temp_mode] = True + else: + self._attributes[DeviceAttributes.zone1_water_temp_mode] = False + self._attributes[DeviceAttributes.zone1_room_temp_mode] = False + if self._attributes[DeviceAttributes.zone2_power]: + if self._attributes[DeviceAttributes.zone_temp_type][zone]: + self._attributes[DeviceAttributes.zone2_water_temp_mode] = True + self._attributes[DeviceAttributes.zone2_room_temp_mode] = False + else: + self._attributes[DeviceAttributes.zone2_water_temp_mode] = False + self._attributes[DeviceAttributes.zone2_room_temp_mode] = True + else: + self._attributes[DeviceAttributes.zone2_water_temp_mode] = False + self._attributes[DeviceAttributes.zone2_room_temp_mode] = False + new_status[DeviceAttributes.zone1_water_temp_mode.value] = self._attributes[ + DeviceAttributes.zone1_water_temp_mode + ] + new_status[DeviceAttributes.zone2_water_temp_mode.value] = self._attributes[ + DeviceAttributes.zone2_water_temp_mode + ] + new_status[DeviceAttributes.zone1_room_temp_mode.value] = self._attributes[ + DeviceAttributes.zone1_room_temp_mode + ] + new_status[DeviceAttributes.zone2_room_temp_mode.value] = self._attributes[ + DeviceAttributes.zone2_room_temp_mode + ] + + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.zone1_power = self._attributes[DeviceAttributes.zone1_power] + message.zone2_power = self._attributes[DeviceAttributes.zone2_power] + message.dhw_power = self._attributes[DeviceAttributes.dhw_power] + message.mode = self._attributes[DeviceAttributes.mode] + message.zone_target_temp = self._attributes[DeviceAttributes.zone_target_temp] + message.dhw_target_temp = self._attributes[DeviceAttributes.dhw_target_temp] + message.room_target_temp = self._attributes[DeviceAttributes.room_target_temp] + message.zone1_curve = self._attributes[DeviceAttributes.zone1_curve] + message.zone2_curve = self._attributes[DeviceAttributes.zone2_curve] + message.disinfect = self._attributes[DeviceAttributes.disinfect] + message.tbh = self._attributes[DeviceAttributes.tbh] + message.fast_dhw = self._attributes[DeviceAttributes.fast_dhw] + return message + + def set_attribute(self, attr, value): + message = None + if attr in [ + DeviceAttributes.zone1_power, + DeviceAttributes.zone2_power, + DeviceAttributes.dhw_power, + DeviceAttributes.zone1_curve, + DeviceAttributes.zone2_curve, + DeviceAttributes.disinfect, + DeviceAttributes.fast_dhw, + DeviceAttributes.dhw_target_temp, + DeviceAttributes.tbh, + ]: + message = self.make_message_set() + setattr(message, str(attr), value) + elif attr == DeviceAttributes.eco_mode: + message = MessageSetECO(self._protocol_version) + setattr(message, str(attr), value) + elif attr == DeviceAttributes.silent_mode: + message = MessageSetSilent(self._protocol_version) + setattr(message, str(attr), value) + if message is not None: + self.build_send(message) + + def set_mode(self, zone, mode): + message = self.make_message_set() + if zone == 0: + message.zone1_power = True + else: + message.zone2_power = True + message.mode = mode + self.build_send(message) + + def set_target_temperature(self, zone, target_temperature, mode): + message = self.make_message_set() + if self._attributes[DeviceAttributes.zone_temp_type][zone]: + message.zone_target_temp[zone] = target_temperature + else: + message.room_target_temp = target_temperature + if mode is not None: + if zone == 0: + message.zone1_power = True + else: + message.zone2_power = True + message.mode = mode + self.build_send(message) + + +class MideaAppliance(MideaC3Device): + pass diff --git a/src/devices/c3/message.py b/src/devices/c3/message.py new file mode 100644 index 00000000..364501bf --- /dev/null +++ b/src/devices/c3/message.py @@ -0,0 +1,190 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageC3Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xC3, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageC3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(MessageC3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x01, + ) + self.zone1_power = False + self.zone2_power = False + self.dhw_power = False + self.mode = 0 + self.zone_target_temp = [25, 25] + self.dhw_target_temp = 40 + self.room_target_temp = 25 + self.zone1_curve = False + self.zone2_curve = False + self.disinfect = False + self.fast_dhw = False + self.tbh = False + + @property + def _body(self): + # Byte 1 + zone1_power = 0x01 if self.zone1_power else 0x00 + zone2_power = 0x02 if self.zone2_power else 0x00 + dhw_power = 0x04 if self.dhw_power else 0x00 + # Byte 7 + zone1_curve = 0x01 if self.zone1_curve else 0x00 + zone2_curve = 0x02 if self.zone2_curve else 0x00 + disinfect = 0x04 if self.disinfect or self.tbh else 0x00 + fast_dhw = 0x08 if self.fast_dhw else 0x00 + room_target_temp = int(self.room_target_temp * 2) + zone1_target_temp = int(self.zone_target_temp[0]) + zone2_target_temp = int(self.zone_target_temp[1]) + dhw_target_temp = int(self.dhw_target_temp) + return bytearray( + [ + zone1_power | zone2_power | dhw_power, + self.mode, + zone1_target_temp, + zone2_target_temp, + dhw_target_temp, + room_target_temp, + zone1_curve | zone2_curve | disinfect | fast_dhw, + ] + ) + + +class MessageSetSilent(MessageC3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x05, + ) + self.silent_mode = False + self.super_silent = False + + @property + def _body(self): + silent_mode = 0x01 if self.silent_mode else 0 + super_silent = 0x02 if self.super_silent else 0 + + return bytearray( + [silent_mode | super_silent, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ) + + +class MessageSetECO(MessageC3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x07, + ) + self.eco_mode = False + + @property + def _body(self): + eco_mode = 0x01 if self.eco_mode else 0 + + return bytearray([eco_mode, 0x00, 0x00, 0x00, 0x00, 0x00]) + + +class C3MessageBody(MessageBody): + def __init__(self, body, data_offset=0): + super().__init__(body) + self.zone1_power = body[data_offset + 0] & 0x01 > 0 + self.zone2_power = body[data_offset + 0] & 0x02 > 0 + self.dhw_power = body[data_offset + 0] & 0x04 > 0 + self.zone1_curve = body[data_offset + 0] & 0x08 > 0 + self.zone2_curve = body[data_offset + 0] & 0x10 > 0 + self.disinfect = body[data_offset + 0] & 0x20 > 0 + self.tbh = body[data_offset + 0] & 0x20 > 0 + self.fast_dhw = body[data_offset + 0] & 0x40 > 0 + self.zone_temp_type = [ + body[data_offset + 1] & 0x10 > 0, + body[data_offset + 1] & 0x20 > 0, + ] + self.silent_mode = body[data_offset + 2] & 0x02 > 0 + self.eco_mode = body[data_offset + 2] & 0x08 > 0 + self.mode = body[data_offset + 3] + self.mode_auto = body[data_offset + 4] + self.zone_target_temp = [body[data_offset + 5], body[data_offset + 6]] + self.dhw_target_temp = body[data_offset + 7] + self.room_target_temp = body[data_offset + 8] / 2 + self.zone_heating_temp_max = [body[data_offset + 9], body[data_offset + 13]] + self.zone_heating_temp_min = [body[data_offset + 10], body[data_offset + 14]] + self.zone_cooling_temp_max = [body[data_offset + 11], body[data_offset + 15]] + self.zone_cooling_temp_min = [body[data_offset + 12], body[data_offset + 16]] + self.room_temp_max = body[data_offset + 17] / 2 + self.room_temp_min = body[data_offset + 18] / 2 + self.dhw_temp_max = body[data_offset + 19] + self.dhw_temp_min = body[data_offset + 20] + self.tank_actual_temperature = body[data_offset + 21] + self.error_code = body[data_offset + 22] + + +class C3Notify1MessageBody(MessageBody): + def __init__(self, body, data_offset=0): + super().__init__(body) + status_byte = body[data_offset] + self.status_tbh = (status_byte & 0x08) > 0 + self.status_dhw = (status_byte & 0x04) > 0 + self.status_ibh = (status_byte & 0x02) > 0 + self.status_heating = (status_byte & 0x01) > 0 + + self.total_energy_consumption = ( + (body[data_offset + 1] << 32) + + (body[data_offset + 2] << 16) + + (body[data_offset + 3] << 8) + + (body[data_offset + 4]) + ) + + self.total_produced_energy = ( + (body[data_offset + 5] << 32) + + (body[data_offset + 6] << 16) + + (body[data_offset + 7] << 8) + + (body[data_offset + 8]) + ) + self.outdoor_temperature = int(body[data_offset + 9]) + + +class MessageC3Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type + in [MessageType.set, MessageType.notify1, MessageType.query] + and self.body_type == 0x01 + ) or self.message_type == MessageType.notify2: + self.set_body(C3MessageBody(super().body, data_offset=1)) + elif self.message_type == MessageType.notify1 and self.body_type == 0x04: + self.set_body(C3Notify1MessageBody(super().body, data_offset=1)) + self.set_attr() diff --git a/src/devices/ca/device.py b/src/devices/ca/device.py new file mode 100644 index 00000000..9bf46d1f --- /dev/null +++ b/src/devices/ca/device.py @@ -0,0 +1,99 @@ +import logging +from .message import MessageQuery, MessageCAResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + mode = "mode" + energy_consumption = "energy_consumption" + refrigerator_actual_temp = "refrigerator_actual_temp" + freezer_actual_temp = "freezer_actual_temp" + flex_zone_actual_temp = "flex_zone_actual_temp" + right_flex_zone_actual_temp = "right_flex_zone_actual_temp" + refrigerator_setting_temp = "refrigerator_setting_temp" + freezer_setting_temp = "freezer_setting_temp" + flex_zone_setting_temp = "flex_zone_setting_temp" + right_flex_zone_setting_temp = "right_flex_zone_setting_temp" + refrigerator_door_overtime = "refrigerator_door_overtime" + freezer_door_overtime = "freezer_door_overtime" + bar_door_overtime = "bar_door_overtime" + flex_zone_door_overtime = "flex_zone_door_overtime" + refrigerator_door = "refrigerator_door" + freezer_door = "freezer_door" + bar_door = "bar_door" + flex_zone_door = "flex_zone_door" + + +class MideaCADevice(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xCA, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.energy_consumption: None, + DeviceAttributes.refrigerator_actual_temp: None, + DeviceAttributes.freezer_actual_temp: None, + DeviceAttributes.flex_zone_actual_temp: None, + DeviceAttributes.right_flex_zone_actual_temp: None, + DeviceAttributes.refrigerator_setting_temp: None, + DeviceAttributes.freezer_setting_temp: None, + DeviceAttributes.flex_zone_setting_temp: None, + DeviceAttributes.right_flex_zone_setting_temp: None, + DeviceAttributes.refrigerator_door_overtime: False, + DeviceAttributes.freezer_door_overtime: False, + DeviceAttributes.bar_door_overtime: False, + DeviceAttributes.flex_zone_door_overtime: False, + DeviceAttributes.refrigerator_door: False, + DeviceAttributes.freezer_door: False, + DeviceAttributes.bar_door: False, + DeviceAttributes.flex_zone_door: False, + }, + ) + self._modes = [""] + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageCAResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = getattr(message, str(status)) + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaCADevice): + pass diff --git a/src/devices/ca/message.py b/src/devices/ca/message.py new file mode 100644 index 00000000..14b788c7 --- /dev/null +++ b/src/devices/ca/message.py @@ -0,0 +1,126 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageCABase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xCA, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageCABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x00, + ) + + @property + def _body(self): + return bytearray([]) + + +class CAGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.refrigerator_setting_temp = body[2] & 0x0F + self.freezer_setting_temp = -12 - ((body[2] & 0xF0) >> 4) + flex_zone_setting_temp = body[3] + right_flex_zone_setting_temp = body[4] + + if 1 <= flex_zone_setting_temp <= 29: + self.flex_zone_setting_temp = flex_zone_setting_temp - 19 + elif 49 <= flex_zone_setting_temp <= 54: + self.flex_zone_setting_temp = 30 - flex_zone_setting_temp + else: + self.flex_zone_setting_temp = 0 + if 1 <= right_flex_zone_setting_temp <= 29: + self.right_flex_zone_setting_temp = right_flex_zone_setting_temp - 19 + elif 49 <= right_flex_zone_setting_temp <= 54: + self.right_flex_zone_setting_temp = 30 - right_flex_zone_setting_temp + else: + self.right_flex_zone_setting_temp = 0 + + self.energy_consumption = (body[13] << 8) + body[12] + self.refrigerator_actual_temp = (body[17] - 100) / 2 + self.freezer_actual_temp = (body[18] - 100) / 2 + self.flex_zone_actual_temp = (body[19] - 100) / 2 + self.right_flex_zone_actual_temp = (body[20] - 100) / 2 + + +class CAExceptionMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.refrigerator_door_overtime = (body[1] & 0x01) > 0 + self.freezer_door_overtime = (body[1] & 0x02) > 0 + self.bar_door_overtime = (body[1] & 0x04) > 0 + self.flex_zone_door_overtime = (body[1] & 0x08) > 0 + + +class CANotify00MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.refrigerator_door = (body[1] & 0x01) > 0 + self.freezer_door = (body[1] & 0x02) > 0 + self.bar_door = (body[1] & 0x04) > 0 + self.flex_zone_door = (body[1] & 0x010) > 0 + + +class CANotify01MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.refrigerator_setting_temp = body[37] + self.freezer_setting_temp = -12 - body[38] + flex_zone_setting_temp = body[39] + right_flex_zone_setting_temp = body[40] + + if 1 <= flex_zone_setting_temp <= 29: + self.flex_zone_setting_temp = flex_zone_setting_temp - 19 + elif 49 <= flex_zone_setting_temp <= 54: + self.flex_zone_setting_temp = 30 - flex_zone_setting_temp + else: + self.flex_zone_setting_temp = 0 + if 1 <= right_flex_zone_setting_temp <= 29: + self.right_flex_zone_setting_temp = right_flex_zone_setting_temp - 19 + elif 49 <= right_flex_zone_setting_temp <= 54: + self.right_flex_zone_setting_temp = 30 - right_flex_zone_setting_temp + else: + self.right_flex_zone_setting_temp = 0 + + +class MessageCAResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + ( + self.message_type in [MessageType.query, MessageType.set] + and self.body_type == 0x00 + ) + or (self.message_type == MessageType.notify1 and self.body_type == 0x02) + ) and len(super().body) > 20: + self.set_body(CAGeneralMessageBody(super().body)) + elif ( + self.message_type == MessageType.exception and self.body_type == 0x01 + ) or (self.message_type == 0x03 and self.body_type == 0x02): + self.set_body(CAExceptionMessageBody(super().body)) + elif self.message_type == MessageType.notify1 and self.body_type == 0x00: + self.set_body(CANotify00MessageBody(super().body)) + elif ( + self.message_type in [MessageType.query, MessageType.notify1] + and self.body_type == 0x01 + ): + self.set_body(CANotify01MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/cc/device.py b/src/devices/cc/device.py new file mode 100644 index 00000000..3fea8830 --- /dev/null +++ b/src/devices/cc/device.py @@ -0,0 +1,198 @@ +import logging +from .message import MessageQuery, MessageSet, MessageCCResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + mode = "mode" + target_temperature = "target_temperature" + fan_speed = "fan_speed" + eco_mode = "eco_mode" + sleep_mode = "sleep_mode" + night_light = "night_light" + aux_heating = "aux_heating" + swing = "swing" + ventilation = "ventilation" + temperature_precision = "temperature_precision" + fan_speed_level = "fan_speed_level" + indoor_temperature = "indoor_temperature" + aux_heat_status = "aux_heat_status" + auto_aux_heat_running = "auto_aux_heat_running" + temp_fahrenheit = "temp_fahrenheit" + + +class MideaCCDevice(MideaDevice): + _fan_speeds_7level = { + 0x01: "Level 1", + 0x02: "Level 2", + 0x04: "Level 3", + 0x08: "Level 4", + 0x10: "Level 5", + 0x20: "Level 6", + 0x40: "Level 7", + 0x80: "Auto", + } + _fan_speeds_3level = {0x01: "Low", 0x08: "Medium", 0x40: "High", 0x80: "Auto"} + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xCC, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.mode: 1, + DeviceAttributes.target_temperature: 26.0, + DeviceAttributes.fan_speed: 0x80, + DeviceAttributes.sleep_mode: False, + DeviceAttributes.eco_mode: False, + DeviceAttributes.night_light: False, + DeviceAttributes.ventilation: False, + DeviceAttributes.aux_heating: False, + DeviceAttributes.aux_heat_status: 0, + DeviceAttributes.auto_aux_heat_running: False, + DeviceAttributes.swing: False, + DeviceAttributes.fan_speed_level: None, + DeviceAttributes.indoor_temperature: None, + DeviceAttributes.temperature_precision: 1, + DeviceAttributes.temp_fahrenheit: False, + }, + ) + self._fan_speeds = None + + @property + def fan_modes(self): + return None if self._fan_speeds is None else list(self._fan_speeds.values()) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageCCResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + fan_speed = None + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.fan_speed: + fan_speed = value + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = getattr(message, str(status)) + if ( + fan_speed is not None + and self._attributes[DeviceAttributes.fan_speed_level] is not None + ): + if self._fan_speeds is None: + if self._attributes[DeviceAttributes.fan_speed_level]: + self._fan_speeds = MideaCCDevice._fan_speeds_3level + else: + self._fan_speeds = MideaCCDevice._fan_speeds_7level + if fan_speed in self._fan_speeds.keys(): + self._attributes[DeviceAttributes.fan_speed] = self._fan_speeds.get( + fan_speed + ) + else: + self._attributes[DeviceAttributes.fan_speed] = None + new_status[DeviceAttributes.fan_speed.value] = self._attributes[ + DeviceAttributes.fan_speed + ] + aux_heating = ( + self._attributes[DeviceAttributes.aux_heat_status] == 1 + or self._attributes[DeviceAttributes.auto_aux_heat_running] + ) + if self._attributes[DeviceAttributes.aux_heating] != aux_heating: + self._attributes[DeviceAttributes.aux_heating] = aux_heating + new_status[DeviceAttributes.aux_heating.value] = self._attributes[ + DeviceAttributes.aux_heating + ] + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.mode = self._attributes[DeviceAttributes.mode] + message.target_temperature = self._attributes[ + DeviceAttributes.target_temperature + ] + message.fan_speed = list(self._fan_speeds.keys())[ + list(self._fan_speeds.values()).index( + self._attributes[DeviceAttributes.fan_speed] + ) + ] + message.eco_mode = self._attributes[DeviceAttributes.eco_mode] + message.sleep_mode = self._attributes[DeviceAttributes.sleep_mode] + message.night_light = self._attributes[DeviceAttributes.night_light] + message.aux_heat_status = self._attributes[DeviceAttributes.aux_heat_status] + message.swing = self._attributes[DeviceAttributes.swing] + return message + + def set_target_temperature(self, target_temperature, mode): + message = self.make_message_set() + message.target_temperature = target_temperature + if mode is not None: + message.power = True + message.mode = mode + self.build_send(message) + + def set_attribute(self, attr, value): + # if nat a sensor + if attr not in [ + DeviceAttributes.indoor_temperature, + DeviceAttributes.temperature_precision, + DeviceAttributes.fan_speed_level, + DeviceAttributes.aux_heat_status, + DeviceAttributes.auto_aux_heat_running, + ]: + message = self.make_message_set() + if attr == DeviceAttributes.fan_speed: + if value in self._fan_speeds.values(): + message.fan_speed = list(self._fan_speeds.keys())[ + list(self._fan_speeds.values()).index(value) + ] + else: + setattr(message, str(attr), value) + if attr == DeviceAttributes.mode: + setattr(message, str(DeviceAttributes.power.value), True) + elif attr == DeviceAttributes.eco_mode and value: + setattr(message, str(DeviceAttributes.sleep_mode.value), False) + elif attr == DeviceAttributes.sleep_mode and value: + setattr(message, str(DeviceAttributes.eco_mode.value), False) + elif attr == DeviceAttributes.aux_heating: + if value: + setattr(message, DeviceAttributes.aux_heat_status, 1) + else: + setattr(message, DeviceAttributes.aux_heat_status, 2) + self.build_send(message) + + +class MideaAppliance(MideaCCDevice): + pass diff --git a/src/devices/cc/message.py b/src/devices/cc/message.py new file mode 100644 index 00000000..5c2cf00f --- /dev/null +++ b/src/devices/cc/message.py @@ -0,0 +1,148 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageCCBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xCC, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageCCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([0x00] * 23) + + +class MessageSet(MessageCCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0xC3, + ) + self.power = False + self.mode = 4 + self.fan_speed = 0x80 + self.target_temperature = 26 + self.eco_mode = False + self.sleep_mode = False + self.night_light = False + self.ventilation = False + self.aux_heat_status = 0 + self.auto_aux_heat_running = False + self.swing = False + + @property + def _body(self): + # Byte1, Power Mode + power = 0x80 if self.power else 0 + mode = 1 << (self.mode - 1) + # Byte2 fan_speed + fan_speed = self.fan_speed + # Byte3 Integer of target_temperature + temperature_integer = int(self.target_temperature) & 0xFF + # Byte6 eco_mode ventilation aux_heating + eco_mode = 0x01 if self.eco_mode else 0 + if self.aux_heat_status == 1: + aux_heating = 0x10 + elif self.aux_heat_status == 2: + aux_heating = 0x20 + else: + aux_heating = 0 + swing = 0x04 if self.swing else 0 + ventilation = 0x08 if self.ventilation else 0 + # Byte8 sleep_mode night_light + sleep_mode = 0x10 if self.sleep_mode else 0 + night_light = 0x08 if self.night_light else 0 + # Byte11 Dot of target_temperature + temperature_dot = ( + int((self.target_temperature - temperature_integer) * 10) & 0xFF + ) + return bytearray( + [ + power | mode, + fan_speed, + temperature_integer, + # timer + 0x00, + 0x00, + eco_mode | ventilation | swing | aux_heating, + # non-stepless fan speed + 0xFF, + sleep_mode | night_light, + 0x00, + 0x00, + temperature_dot, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class CCGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x80) > 0 + mode = body[1] & 0x1F + self.mode = 0 + while mode >= 1: + mode /= 2 + self.mode += 1 + self.fan_speed = body[2] + self.target_temperature = body[3] + body[19] / 10 + self.indoor_temperature = (body[4] - 40) / 2 + self.eco_mode = (body[13] & 0x01) > 0 + self.sleep_mode = (body[14] & 0x10) > 0 + self.night_light = (body[14] & 0x08) > 0 + self.ventilation = (body[13] & 0x08) > 0 + self.aux_heat_status = (body[14] & 0x60) >> 5 + self.auto_aux_heat_running = (body[13] & 0x02) > 0 + self.fan_speed_level = (body[13] & 0x40) > 0 + self.temperature_precision = 1 if (body[14] & 0x80) > 0 else 0.5 + self.swing = (body[13] & 0x04) > 0 + self.temp_fahrenheit = (body[20] & 0x80) > 0 + + +class MessageCCResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + (self.message_type == MessageType.query and self.body_type == 0x01) + or ( + self.message_type in [MessageType.notify1, MessageType.notify2] + and self.body_type == 0x01 + ) + or (self.message_type == MessageType.set and self.body_type == 0xC3) + ): + self.set_body(CCGeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/cd/device.py b/src/devices/cd/device.py new file mode 100644 index 00000000..70ae4ba1 --- /dev/null +++ b/src/devices/cd/device.py @@ -0,0 +1,134 @@ +import logging +import json +from .message import MessageQuery, MessageSet, MessageCDResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + mode = "mode" + max_temperature = "max_temperature" + min_temperature = "min_temperature" + target_temperature = "target_temperature" + current_temperature = "current_temperature" + outdoor_temperature = "outdoor_temperature" + condenser_temperature = "condenser_temperature" + compressor_temperature = "compressor_temperature" + compressor_status = "compressor_status" + + +class MideaCDDevice(MideaDevice): + _modes = ["Energy-save", "Standard", "Dual", "Smart"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xCD, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.mode: None, + DeviceAttributes.max_temperature: 65, + DeviceAttributes.min_temperature: 35, + DeviceAttributes.target_temperature: 40, + DeviceAttributes.current_temperature: None, + DeviceAttributes.outdoor_temperature: None, + DeviceAttributes.condenser_temperature: None, + DeviceAttributes.compressor_temperature: None, + DeviceAttributes.compressor_status: None, + }, + ) + self._fields = {} + self._temperature_step = None + self._default_temperature_step = 1 + self.set_customize(customize) + + @property + def temperature_step(self): + return self._temperature_step + + @property + def preset_modes(self): + return MideaCDDevice._modes + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageCDResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + if hasattr(message, "fields"): + self._fields = getattr(message, "fields") + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.mode: + self._attributes[status] = MideaCDDevice._modes[value] + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr in [ + DeviceAttributes.mode, + DeviceAttributes.power, + DeviceAttributes.target_temperature, + ]: + message = MessageSet(self._protocol_version) + message.fields = self._fields + message.mode = MideaCDDevice._modes.index( + self._attributes[DeviceAttributes.mode] + ) + message.power = self._attributes[DeviceAttributes.power] + message.target_temperature = self._attributes[ + DeviceAttributes.target_temperature + ] + if attr == DeviceAttributes.mode: + if value in MideaCDDevice._modes: + setattr(message, str(attr), MideaCDDevice._modes.index(value)) + else: + setattr(message, str(attr), value) + self.build_send(message) + + def set_customize(self, customize): + self._temperature_step = self._default_temperature_step + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "temperature_step" in params: + self._temperature_step = params.get("temperature_step") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"temperature_step": self._temperature_step}) + + +class MideaAppliance(MideaCDDevice): + pass diff --git a/src/devices/cd/message.py b/src/devices/cd/message.py new file mode 100644 index 00000000..9105d010 --- /dev/null +++ b/src/devices/cd/message.py @@ -0,0 +1,114 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageCDBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xCD, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageCDBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessageSet(MessageCDBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x01, + ) + self.power = False + self.target_temperature = 0 + self.aux_heating = False + self.fields = {} + self.mode = 1 + + def read_field(self, field): + value = self.fields.get(field, 0) + return value if value else 0 + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + mode = self.mode + 1 + target_temperature = round(self.target_temperature * 2 + 30) + return bytearray( + [ + 0x01, + power, + mode, + target_temperature, + self.read_field("trValue"), + self.read_field("openPTC"), + self.read_field("ptcTemp"), + 0, # self.read_field("byte8") + ] + ) + + +class CDGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[2] & 0x01) > 0 + self.target_temperature = round((body[3] - 30) / 2) + if (body[2] & 0x02) > 0: + self.mode = 0 + elif (body[2] & 0x04) > 0: + self.mode = 1 + elif (body[2] & 0x08) > 0: + self.mode = 2 + self.current_temperature = round((body[4] - 30) / 2) + self.condenser_temperature = (body[7] - 30) / 2 + self.outdoor_temperature = (body[8] - 30) / 2 + self.compressor_temperature = (body[9] - 30) / 2 + self.max_temperature = round((body[10] - 30) / 2) + self.min_temperature = round((body[11] - 30) / 2) + self.compressor_status = (body[27] & 0x08) > 0 + if (body[28] & 0x20) > 0: + self.mode = 3 + + +class CD02MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.fields = {} + self.power = (body[2] & 0x01) > 0 + self.mode = body[3] + self.target_temperature = round((body[4] - 30) / 2) + self.fields["trValue"] = body[5] + self.fields["openPTC"] = body[5] + self.fields["ptcTemp"] = body[7] + self.fields["byte8"] = body[8] + + +class MessageCDResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [MessageType.query, MessageType.notify2]: + self.set_body(CDGeneralMessageBody(super().body)) + elif self.message_type == MessageType.set and self.body_type == 0x01: + self.set_body(CD02MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/ce/device.py b/src/devices/ce/device.py new file mode 100644 index 00000000..aef0b698 --- /dev/null +++ b/src/devices/ce/device.py @@ -0,0 +1,157 @@ +import logging +import json +from .message import MessageQuery, MessageCEResponse, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + mode = "mode" + child_lock = "child_lock" + scheduled = "scheduled" + fan_speed = "fan_speed" + pm25 = "pm25" + co2 = "co2" + current_humidity = "current_humidity" + current_temperature = "current_temperature" + hcho = "hcho" + link_to_ac = "link_to_ac" + sleep_mode = "sleep_mode" + eco_mode = "eco_mode" + aux_heating = "aux_heating'" + powerful_purify = "powerful_purify" + filter_cleaning_reminder = "filter_cleaning_reminder" + filter_change_reminder = "filter_change_reminder" + error_code = "error_code" + + +class MideaCEDevice(MideaDevice): + _modes = ["Normal", "Sleep mode", "ECO mode"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xCE, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.mode: None, + DeviceAttributes.child_lock: False, + DeviceAttributes.scheduled: False, + DeviceAttributes.fan_speed: 0, + DeviceAttributes.pm25: None, + DeviceAttributes.co2: None, + DeviceAttributes.current_humidity: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.hcho: None, + DeviceAttributes.link_to_ac: False, + DeviceAttributes.sleep_mode: False, + DeviceAttributes.eco_mode: False, + DeviceAttributes.aux_heating: None, + DeviceAttributes.powerful_purify: False, + DeviceAttributes.filter_cleaning_reminder: False, + DeviceAttributes.filter_change_reminder: False, + DeviceAttributes.error_code: 0, + }, + ) + self._default_speed_count = 7 + self._speed_count = self._default_speed_count + self.set_customize(customize) + + @property + def speed_count(self): + return self._speed_count + + @property + def preset_modes(self): + return self._modes + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageCEResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + if self._attributes[DeviceAttributes.sleep_mode]: + self._attributes[DeviceAttributes.mode] = "Sleep mode" + elif self._attributes[DeviceAttributes.eco_mode]: + self._attributes[DeviceAttributes.mode] = "ECO mode" + else: + self._attributes[DeviceAttributes.mode] = "None" + new_status[DeviceAttributes.mode.value] = self._attributes[ + DeviceAttributes.mode + ] + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.fan_speed = self._attributes[DeviceAttributes.fan_speed] + message.link_to_ac = self._attributes[DeviceAttributes.link_to_ac] + message.sleep_mode = self._attributes[DeviceAttributes.sleep_mode] + message.eco_mode = self._attributes[DeviceAttributes.eco_mode] + message.aux_heating = self._attributes[DeviceAttributes.aux_heating] + message.powerful_purify = self._attributes[DeviceAttributes.powerful_purify] + message.scheduled = self._attributes[DeviceAttributes.scheduled] + message.child_lock = self._attributes[DeviceAttributes.child_lock] + return message + + def set_attribute(self, attr, value): + message = self.make_message_set() + if attr == DeviceAttributes.mode: + message.sleep_mode = False + message.eco_mode = False + if value == "Sleep mode": + message.sleep_mode = True + elif value == "ECO mode": + message.eco_mode = True + else: + setattr(message, str(attr), value) + self.build_send(message) + + def set_customize(self, customize): + self._speed_count = self._default_speed_count + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "speed_count" in params: + self._speed_count = params.get("speed_count") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"speed_count": self._speed_count}) + + +class MideaAppliance(MideaCEDevice): + pass diff --git a/src/devices/ce/message.py b/src/devices/ce/message.py new file mode 100644 index 00000000..a36ae164 --- /dev/null +++ b/src/devices/ce/message.py @@ -0,0 +1,140 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageFABase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xCE, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageFABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(MessageFABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x01, + ) + + self.power = False + self.fan_speed = 0 + self.link_to_ac = False + self.sleep_mode = False + self.eco_mode = False + self.aux_heating = False + self.powerful_purify = False + self.scheduled = False + self.child_lock = False + + @property + def _body(self): + power = 0x80 if self.power else 0x00 + link_to_ac = 0x01 if self.link_to_ac else 0x00 + sleep_mode = 0x02 if self.sleep_mode else 0x00 + eco_mode = 0x04 if self.eco_mode else 0x00 + aux_heating = 0x08 if self.aux_heating else 0x00 + powerful_purify = 0x10 if self.powerful_purify else 0x00 + scheduled = 0x01 if self.scheduled else 0x00 + child_lock = 0x7F if self.child_lock else 0x00 + return bytearray( + [ + power | 0x01, + self.fan_speed, + link_to_ac | sleep_mode | eco_mode | aux_heating | powerful_purify, + scheduled, + 0x00, + child_lock, + ] + ) + + +class CEGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x80) > 0 + self.child_lock = (body[1] & 0x20) > 0 + self.scheduled = (body[1] & 0x40) > 0 + self.fan_speed = body[2] + self.pm25 = (body[3] << 8) + body[4] + self.co2 = (body[5] << 8) + body[6] + if body[7] != 0xFF: + self.current_humidity = (body[7] << 8) + body[8] / 10 + else: + self.current_humidity = None + if body[9] != 0xFF: + self.current_temperature = (body[9] << 8) + (body[10] - 60) / 2 + else: + self.current_temperature = None + if body[11] != 0xFF: + self.hcho = (body[11] << 8) + body[12] / 1000 + else: + self.hcho = None + self.link_to_ac = (body[17] & 0x01) > 0 + self.sleep_mode = (body[17] & 0x02) > 0 + self.eco_mode = (body[17] & 0x04) > 0 + if (body[19] & 0x02) > 0: + self.aux_heating = (body[17] & 0x08) > 0 + else: + self.aux_heating = None + self.powerful_purify = (body[17] & 0x10) > 0 + self.filter_cleaning_reminder = (body[18] & 0x01) > 0 + self.filter_change_reminder = (body[18] & 0x02) > 0 + self.error_code = body[24] + + +class CENotifyMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.pm25 = (body[1] << 8) + body[2] + self.co2 = (body[3] << 8) + body[4] + if body[5] != 0xFF: + self.current_humidity = (body[5] << 8) + body[6] / 10 + else: + self.current_humidity = None + if body[7] != 0xFF: + self.current_temperature = (body[7] << 8) + (body[8] - 60) / 2 + else: + self.current_temperature = None + if body[9] != 0xFF: + self.hcho = (body[9] << 8) + body[10] / 1000 + else: + self.hcho = None + self.error_code = body[12] + + +class MessageCEResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type in [MessageType.query, MessageType.set] + and self.body_type == 0x01 + ) or (self.message_type == MessageType.notify1 and self.body_type == 0x02): + self.set_body(CEGeneralMessageBody(super().body)) + elif self.message_type == MessageType.notify1 and self.body_type == 0x01: + self.set_body(CENotifyMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/cf/device.py b/src/devices/cf/device.py new file mode 100644 index 00000000..16eab04d --- /dev/null +++ b/src/devices/cf/device.py @@ -0,0 +1,98 @@ +import logging +from .message import MessageQuery, MessageCFResponse, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + mode = "mode" + target_temperature = "target_temperature" + aux_heating = "aux_heating" + current_temperature = "current_temperature" + max_temperature = "max_temperature" + min_temperature = "min_temperature" + + +class MideaCFDevice(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xCF, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.mode: 0, + DeviceAttributes.target_temperature: None, + DeviceAttributes.aux_heating: False, + DeviceAttributes.current_temperature: 0, + DeviceAttributes.max_temperature: 55, + DeviceAttributes.min_temperature: 5, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageCFResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = getattr(message, str(status)) + return new_status + + def set_target_temperature(self, target_temperature, mode): + message = MessageSet(self._protocol_version) + message.power = True + message.mode = self._attributes[DeviceAttributes.mode] + message.target_temperature = target_temperature + if mode is not None: + message.mode = mode + self.build_send(message) + + def set_attribute(self, attr, value): + message = MessageSet(self._protocol_version) + message.power = True + message.mode = self._attributes[DeviceAttributes.mode] + if attr == DeviceAttributes.power: + message.power = value + elif attr == DeviceAttributes.mode: + message.power = True + message.mode = value + elif attr == DeviceAttributes.target_temperature: + message.target_temperature = value + elif attr == DeviceAttributes.aux_heating: + message.aux_heating = value + self.build_send(message) + + +class MideaAppliance(MideaCFDevice): + pass diff --git a/src/devices/cf/message.py b/src/devices/cf/message.py new file mode 100644 index 00000000..31baa64d --- /dev/null +++ b/src/devices/cf/message.py @@ -0,0 +1,93 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageCFBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xCF, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageCFBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(MessageCFBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x01, + ) + self.power = False + self.mode = 0 # 1 自动 2 制冷 3 制热 + self.target_temperature = None + self.aux_heating = None + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + mode = self.mode + target_temperature = ( + 0xFF + if self.target_temperature is None + else (int(self.target_temperature) & 0xFF) + ) + aux_heating = ( + 0xFF if self.aux_heating is None else (0x01 if self.aux_heating else 0x00) + ) + return bytearray([power, mode, target_temperature, aux_heating]) + + +class CFMessageBody(MessageBody): + def __init__(self, body, data_offset=0): + super().__init__(body) + self.power = (body[data_offset + 0] & 0x01) > 0 + self.aux_heating = (body[data_offset + 0] & 0x02) > 0 + self.silent = (body[data_offset + 0] & 0x04) > 0 + self.mode = body[data_offset + 3] + self.target_temperature = body[data_offset + 4] + self.current_temperature = body[data_offset + 5] + if self.mode == 2: + self.max_temperature = body[data_offset + 8] + self.min_temperature = body[data_offset + 9] + elif self.mode == 3: + self.max_temperature = body[data_offset + 6] + self.min_temperature = body[data_offset + 7] + else: + self.max_temperature = body[data_offset + 6] + self.min_temperature = body[data_offset + 9] + + +class MessageCFResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type in [MessageType.query, MessageType.set] + and self.body_type == 0x01 + ): + self.set_body(CFMessageBody(super().body, data_offset=1)) + elif self.message_type in [MessageType.notify1, MessageType.notify2]: + self.set_body(CFMessageBody(super().body, data_offset=0)) + self.set_attr() diff --git a/src/devices/da/device.py b/src/devices/da/device.py new file mode 100644 index 00000000..c12720b6 --- /dev/null +++ b/src/devices/da/device.py @@ -0,0 +1,169 @@ +import logging +from .message import MessageQuery, MessagePower, MessageStart, MessageDAResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + start = "start" + washing_data = "washing_data" + program = "program" + progress = "progress" + time_remaining = "time_remaining" + wash_time = "wash_time" + soak_time = "soak_time" + dehydration_time = "dehydration_time" + dehydration_speed = "dehydration_speed" + error_code = "error_code" + rinse_count = "rinse_count" + rinse_level = "rinse_level" + wash_level = "wash_level" + wash_strength = "wash_strength" + softener = "softener" + detergent = "detergent" + + +class MideaDADevice(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xDA, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.start: False, + DeviceAttributes.error_code: None, + DeviceAttributes.washing_data: bytearray([]), + DeviceAttributes.program: None, + DeviceAttributes.progress: "Unknown", + DeviceAttributes.time_remaining: None, + DeviceAttributes.wash_time: None, + DeviceAttributes.soak_time: None, + DeviceAttributes.dehydration_time: None, + DeviceAttributes.dehydration_speed: None, + DeviceAttributes.rinse_count: None, + DeviceAttributes.rinse_level: None, + DeviceAttributes.wash_level: None, + DeviceAttributes.wash_strength: None, + DeviceAttributes.softener: None, + DeviceAttributes.detergent: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageDAResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + progress = ["Idle", "Spin", "Rinse", "Wash", "Weight", "Unknown", "Dry", "Soak"] + program = [ + "Standard", + "Fast", + "Blanket", + "Wool", + "embathe", + "Memory", + "Child", + "Down Jacket", + "Stir", + "Mute", + "Bucket Self Clean", + "Air Dry", + ] + speed = ["-", "Low", "Medium", "High"] + strength = ["-", "Week", "Medium", "Strong"] + detergent = [ + "No", + "Less", + "Medium", + "More", + "4", + "5", + "6", + "7", + "8", + "Insufficient", + ] + softener = [ + "No", + "Intelligent", + "Programed", + "3", + "4", + "5", + "6", + "7", + "8", + "Insufficient", + ] + for status in self._attributes.keys(): + if hasattr(message, str(status)): + if status == DeviceAttributes.progress: + self._attributes[status] = progress[getattr(message, str(status))] + elif status == DeviceAttributes.program: + self._attributes[status] = program[getattr(message, str(status))] + elif status == DeviceAttributes.rinse_level: + temp_rinse_level = getattr(message, str(status)) + if temp_rinse_level == 15: + self._attributes[status] = "-" + else: + self._attributes[status] = temp_rinse_level + elif status == DeviceAttributes.dehydration_speed: + temp_speed = getattr(message, str(status)) + if temp_speed == 15: + self._attributes[status] = "-" + else: + self._attributes[status] = speed[temp_speed] + elif status == DeviceAttributes.detergent: + self._attributes[status] = detergent[getattr(message, str(status))] + elif status == DeviceAttributes.softener: + self._attributes[status] = softener[getattr(message, str(status))] + elif status == DeviceAttributes.wash_strength: + self._attributes[status] = strength[getattr(message, str(status))] + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + self.build_send(message) + elif attr == DeviceAttributes.start: + message = MessageStart(self._protocol_version) + message.start = value + message.washing_data = self._attributes[DeviceAttributes.washing_data] + self.build_send(message) + + +class MideaAppliance(MideaDADevice): + pass diff --git a/src/devices/da/message.py b/src/devices/da/message.py new file mode 100644 index 00000000..e3c863c8 --- /dev/null +++ b/src/devices/da/message.py @@ -0,0 +1,106 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageDABase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xDA, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageDABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x03, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessagePower(MessageDABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.power = False + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + return bytearray([power, 0xFF]) + + +class MessageStart(MessageDABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.start = False + self.washing_data = bytearray([]) + + @property + def _body(self): + if self.start: + return bytearray([0xFF, 0x01]) + self.washing_data + else: + # Stop + return bytearray([0xFF, 0x00]) + + +class DAGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = body[1] > 0 + self.start = True if body[2] in [2, 6] else False + self.error_code = body[24] + self.program = body[4] + self.wash_time = body[9] + self.soak_time = body[12] + self.dehydration_time = (body[10] & 0xF0) >> 4 + self.dehydration_speed = (body[6] & 0xF0) >> 4 + self.rinse_count = body[10] & 0xF + self.rinse_level = (body[5] & 0xF0) >> 4 + self.wash_level = body[5] & 0xF + self.wash_strength = body[6] & 0xF + self.softener = (body[8] & 0xF0) >> 4 + self.detergent = body[8] & 0x0F + self.washing_data = body[3:15] + self.progress = 0 + for i in range(1, 7): + if (body[16] & (1 << i)) > 0: + self.progress = i + break + if self.power: + self.time_remaining = body[17] + body[18] * 60 + else: + self.time_remaining = None + + +class MessageDAResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [MessageType.query, MessageType.set] or ( + self.message_type == MessageType.notify1 and self.body_type == 0x04 + ): + self.set_body(DAGeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/db/device.py b/src/devices/db/device.py new file mode 100644 index 00000000..8527aa07 --- /dev/null +++ b/src/devices/db/device.py @@ -0,0 +1,95 @@ +import logging +from .message import MessageQuery, MessagePower, MessageStart, MessageDBResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + start = "start" + washing_data = "washing_data" + progress = "progress" + time_remaining = "time_remaining" + + +class MideaDBDevice(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xDB, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.start: False, + DeviceAttributes.washing_data: bytearray([]), + DeviceAttributes.progress: "Unknown", + DeviceAttributes.time_remaining: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageDBResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + progress = [ + "Idle", + "Spin", + "Rinse", + "Wash", + "Pre-wash", + "Dry", + "Weight", + "Hi-speed Spin", + "Unknown", + ] + for status in self._attributes.keys(): + if hasattr(message, str(status)): + if status == DeviceAttributes.progress: + self._attributes[status] = progress[getattr(message, str(status))] + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + self.build_send(message) + elif attr == DeviceAttributes.start: + message = MessageStart(self._protocol_version) + message.start = value + message.washing_data = self._attributes[DeviceAttributes.washing_data] + self.build_send(message) + + +class MideaAppliance(MideaDBDevice): + pass diff --git a/src/devices/db/message.py b/src/devices/db/message.py new file mode 100644 index 00000000..8ef738f2 --- /dev/null +++ b/src/devices/db/message.py @@ -0,0 +1,118 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageDBBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xDB, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageDBBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x03, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessagePower(MessageDBBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.power = False + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + return bytearray( + [ + power, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + ] + ) + + +class MessageStart(MessageDBBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.start = False + self.washing_data = bytearray([]) + + @property + def _body(self): + if self.start: # Pause + return bytearray([0xFF, 0x01]) + self.washing_data + else: + # Pause + return bytearray([0xFF, 0x00]) + + +class DBGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = body[1] > 0 + self.start = True if body[2] in [2, 6] else False + self.washing_data = body[3:16] + self.progress = 0 + for i in range(0, 7): + if (body[16] & (1 << i)) > 0: + self.progress = i + 1 + break + if self.power: + self.time_remaining = body[17] + (body[18] << 8) + else: + self.time_remaining = None + + +class MessageDBResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [MessageType.query, MessageType.set] or ( + self.message_type == MessageType.notify1 and self.body_type == 0x04 + ): + self.set_body(DBGeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/dc/device.py b/src/devices/dc/device.py new file mode 100644 index 00000000..cbbb8e2a --- /dev/null +++ b/src/devices/dc/device.py @@ -0,0 +1,94 @@ +import logging +from .message import MessageQuery, MessagePower, MessageStart, MessageDCResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + start = "start" + washing_data = "washing_data" + progress = "progress" + time_remaining = "time_remaining" + + +class MideaDADevice(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xDC, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.start: False, + DeviceAttributes.washing_data: bytearray([]), + DeviceAttributes.progress: "Unknown", + DeviceAttributes.time_remaining: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageDCResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + progress = [ + "Prog0", + "Prog1", + "Prog2", + "Prog3", + "Prog4", + "Prog5", + "Prog6", + "Prog7", + ] + for status in self._attributes.keys(): + if hasattr(message, str(status)): + if status == DeviceAttributes.progress: + self._attributes[status] = progress[getattr(message, str(status))] + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + self.build_send(message) + elif attr == DeviceAttributes.start: + message = MessageStart(self._protocol_version) + message.start = value + message.washing_data = self._attributes[DeviceAttributes.washing_data] + self.build_send(message) + + +class MideaAppliance(MideaDADevice): + pass diff --git a/src/devices/dc/message.py b/src/devices/dc/message.py new file mode 100644 index 00000000..f2a1d38d --- /dev/null +++ b/src/devices/dc/message.py @@ -0,0 +1,94 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageDCBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xDC, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageDCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x03, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessagePower(MessageDCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.power = False + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + return bytearray([power, 0xFF]) + + +class MessageStart(MessageDCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.start = False + self.washing_data = bytearray([]) + + @property + def _body(self): + if self.start: + return bytearray([0xFF, 0x01]) + self.washing_data + else: + # Stop + return bytearray([0xFF, 0x00]) + + +class DCGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = body[1] > 0 + self.start = True if body[2] in [2, 6] else False + self.washing_data = body[3:15] + self.progress = 0 + for i in range(0, 7): + if (body[16] & (1 << i)) > 0: + self.progress = i + 1 + break + if self.power: + self.time_remaining = body[17] + body[18] * 60 + else: + self.time_remaining = None + + +class MessageDCResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [MessageType.query, MessageType.set] or ( + self.message_type == MessageType.notify1 and self.body_type == 0x04 + ): + self.set_body(DCGeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/e1/device.py b/src/devices/e1/device.py new file mode 100644 index 00000000..d3b6d931 --- /dev/null +++ b/src/devices/e1/device.py @@ -0,0 +1,170 @@ +import logging +from .message import ( + MessageQuery, + MessagePower, + MessageStorage, + MessageLock, + MessageE1Response, +) + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + status = "status" + mode = "mode" + additional = "additional" + door = "door" + rinse_aid = "rinse_aid" + salt = "salt" + child_lock = "child_lock" + uv = "uv" + dry = "dry" + dry_status = "dry_status" + storage = "storage" + storage_status = "storage_status" + time_remaining = "time_remaining" + progress = "progress" + storage_remaining = "storage_remaining" + temperature = "temperature" + humidity = "humidity" + waterswitch = "waterswitch" + water_lack = "water_lack" + error_code = "error_code" + softwater = "softwater" + wrong_operation = "wrong_operation" + bright = "bright" + + +class MideaE1Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xE1, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.status: None, + DeviceAttributes.mode: 0, + DeviceAttributes.additional: 0, + DeviceAttributes.uv: False, + DeviceAttributes.dry: False, + DeviceAttributes.dry_status: False, + DeviceAttributes.door: False, + DeviceAttributes.rinse_aid: False, + DeviceAttributes.salt: False, + DeviceAttributes.child_lock: False, + DeviceAttributes.storage: False, + DeviceAttributes.storage_status: False, + DeviceAttributes.time_remaining: None, + DeviceAttributes.progress: None, + DeviceAttributes.storage_remaining: None, + DeviceAttributes.temperature: None, + DeviceAttributes.humidity: None, + DeviceAttributes.waterswitch: False, + DeviceAttributes.water_lack: False, + DeviceAttributes.error_code: None, + DeviceAttributes.softwater: 0, + DeviceAttributes.wrong_operation: None, + DeviceAttributes.bright: 0, + }, + ) + self._modes = { + 0x0: "Neutral Gear", # BYTE_MODE_NEUTRAL_GEAR + 0x1: "Auto", # BYTE_MODE_AUTO_WASH + 0x2: "Heavy", # BYTE_MODE_STRONG_WASH + 0x3: "Normal", # BYTE_MODE_STANDARD_WASH + 0x4: "Energy Saving", # BYTE_MODE_ECO_WASH + 0x5: "Delicate", # BYTE_MODE_GLASS_WASH + 0x6: "Hour", # BYTE_MODE_HOUR_WASH + 0x7: "Quick", # BYTE_MODE_FAST_WASH + 0x8: "Rinse", # BYTE_MODE_SOAK_WASH + 0x9: "90min", # BYTE_MODE_90MIN_WASH + 0xA: "Self Clean", # BYTE_MODE_SELF_CLEAN + 0xB: "Fruit Wash", # BYTE_MODE_FRUIT_WASH + 0xC: "Self Define", # BYTE_MODE_SELF_DEFINE + 0xD: "Germ", # BYTE_MODE_GERM ??? + 0xE: "Bowl Wash", # BYTE_MODE_BOWL_WASH + 0xF: "Kill Germ", # BYTE_MODE_KILL_GERM + 0x10: "Sea Food Wash", # BYTE_MODE_SEA_FOOD_WASH + 0x12: "Hot Pot Wash", # BYTE_MODE_HOT_POT_WASH + 0x13: "Quiet", # BYTE_MODE_QUIET_NIGHT_WASH + 0x14: "Less Wash", # BYTE_MODE_LESS_WASH + 0x16: "Oil Net Wash", # BYTE_MODE_OIL_NET_WASH + 0x19: "Cloud Wash", # BYTE_MODE_CLOUD_WASH + } + self._status = ["Off", "Idle", "Delay", "Running", "Error"] + self._progress = ["Idle", "Pre-wash", "Wash", "Rinse", "Dry", "Complete"] + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageE1Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + if status == DeviceAttributes.status: + v = getattr(message, str(status)) + if v < len(self._status): + self._attributes[status] = self._status[v] + else: + self._attributes[status] = None + elif status == DeviceAttributes.progress: + v = getattr(message, str(status)) + if v < len(self._progress): + self._attributes[status] = self._progress[v] + else: + self._attributes[status] = None + elif status == DeviceAttributes.mode: + v = getattr(message, str(status)) + self._attributes[status] = self._modes[v] + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + self.build_send(message) + elif attr == DeviceAttributes.child_lock: + message = MessageLock(self._protocol_version) + message.lock = value + self.build_send(message) + elif attr == DeviceAttributes.storage: + message = MessageStorage(self._protocol_version) + message.storage = value + self.build_send(message) + + +class MideaAppliance(MideaE1Device): + pass diff --git a/src/devices/e1/message.py b/src/devices/e1/message.py new file mode 100644 index 00000000..8fae78c3 --- /dev/null +++ b/src/devices/e1/message.py @@ -0,0 +1,127 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageE1Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xE1, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessagePower(MessageE1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x08, + ) + self.power = False + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + return bytearray([power, 0x00, 0x00, 0x00]) + + +class MessageLock(MessageE1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x83, + ) + self.lock = False + + @property + def _body(self): + lock = 0x03 if self.lock else 0x04 + return bytearray([lock]) + bytearray([0x00] * 36) + + +class MessageStorage(MessageE1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x81, + ) + self.storage = False + + @property + def _body(self): + storage = 0x01 if self.storage else 0x00 + return ( + bytearray([0x00, 0x00, 0x00, storage]) + + bytearray([0xFF] * 6) + + bytearray([0x00] * 27) + ) + + +class MessageQuery(MessageE1Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x00, + ) + + @property + def _body(self): + return bytearray([]) + + +class E1GeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = body[1] > 0 + self.status = body[1] + self.mode = body[2] + self.additional = body[3] + self.door = (body[5] & 0x01) == 0 # 0 - open, 1 - close + self.rinse_aid = (body[5] & 0x02) > 0 # 0 - enough, 1 - shortage + self.salt = (body[5] & 0x04) > 0 # 0 - enough, 1 - shortage + start_pause = (body[5] & 0x08) > 0 + if start_pause: + self.start = True + elif self.status in [2, 3]: + self.start = False + self.child_lock = (body[5] & 0x10) > 0 + self.uv = (body[4] & 0x2) > 0 + self.dry = (body[4] & 0x10) > 0 + self.dry_status = (body[4] & 0x20) > 0 + self.storage = (body[5] & 0x20) > 0 + self.storage_status = (body[5] & 0x40) > 0 + self.time_remaining = body[6] + self.progress = body[9] + self.storage_remaining = body[18] if len(body) > 18 else False + self.temperature = body[11] + self.humidity = body[33] if len(body) > 33 else None + self.waterswitch = (body[4] & 0x4) > 0 + self.water_lack = (body[5] & 0x80) > 0 + self.error_code = body[10] + self.softwater = body[13] + self.wrong_operation = body[16] + self.bright = body[24] if len(body) > 24 else None + + +class MessageE1Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if (self.message_type == MessageType.set and 0 <= self.body_type <= 7) or ( + self.message_type in [MessageType.query, MessageType.notify1] + and self.body_type == 0 + ): + self.set_body(E1GeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/e2/device.py b/src/devices/e2/device.py new file mode 100644 index 00000000..5c9abc4c --- /dev/null +++ b/src/devices/e2/device.py @@ -0,0 +1,139 @@ +import logging +import json +from .message import ( + MessageQuery, + MessageSet, + MessageE2Response, + MessagePower, + MessageNewProtocolSet, +) + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + heating = "heating" + keep_warm = "keep_warm" + protection = "protection" + current_temperature = "current_temperature" + target_temperature = "target_temperature" + whole_tank_heating = "whole_tank_heating" + variable_heating = "variable_heating" + heating_time_remaining = "heating_time_remaining" + water_consumption = "water_consumption" + heating_power = "heating_power" + + +class MideaE2Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xE2, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.heating: False, + DeviceAttributes.keep_warm: False, + DeviceAttributes.protection: False, + DeviceAttributes.current_temperature: None, + DeviceAttributes.target_temperature: 40, + DeviceAttributes.whole_tank_heating: False, + DeviceAttributes.variable_heating: False, + DeviceAttributes.heating_time_remaining: 0, + DeviceAttributes.water_consumption: None, + DeviceAttributes.heating_power: None, + }, + ) + self._default_old_protocol = "auto" + self._old_protocol = self._default_old_protocol + self.set_customize(customize) + + def old_protocol(self): + return self.subtype <= 82 or self.subtype == 85 or self.subtype == 36353 + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageE2Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = getattr(message, str(status)) + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.protection = self._attributes[DeviceAttributes.protection] + message.whole_tank_heating = self._attributes[ + DeviceAttributes.whole_tank_heating + ] + message.target_temperature = self._attributes[ + DeviceAttributes.target_temperature + ] + message.variable_heating = self._attributes[DeviceAttributes.variable_heating] + return message + + def set_attribute(self, attr, value): + if attr not in [ + DeviceAttributes.heating, + DeviceAttributes.keep_warm, + DeviceAttributes.current_temperature, + ]: + if self._old_protocol is not None and self._old_protocol != "auto": + old_protocol = self._old_protocol + else: + old_protocol = self.old_protocol() + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + elif old_protocol: + message = self.make_message_set() + setattr(message, str(attr), value) + else: + message = MessageNewProtocolSet(self._protocol_version) + setattr(message, str(attr), value) + self.build_send(message) + + def set_customize(self, customize): + self._old_protocol = self._default_old_protocol + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "old_protocol" in params: + self._old_protocol = params.get("old_protocol") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"old_protocol": self._old_protocol}) + + +class MideaAppliance(MideaE2Device): + pass diff --git a/src/devices/e2/message.py b/src/devices/e2/message.py new file mode 100644 index 00000000..b09657de --- /dev/null +++ b/src/devices/e2/message.py @@ -0,0 +1,155 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageE2Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xE2, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageE2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessagePower(MessageE2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.power = False + + @property + def _body(self): + if self.power: + self.body_type = 0x01 + else: + self.body_type = 0x02 + return bytearray([0x01]) + + +class MessageNewProtocolSet(MessageE2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x14, + ) + self.target_temperature = None + self.variable_heating = None + self.whole_tank_heating = None + + @property + def _body(self): + byte1 = 0x00 + byte2 = 0x00 + if self.target_temperature is not None: + byte1 = 0x07 + byte2 = int(self.target_temperature) & 0xFF + elif self.whole_tank_heating is not None: + byte1 = 0x04 + byte2 = 0x02 if self.whole_tank_heating else 0x01 + elif self.variable_heating is not None: + byte1 = 0x10 + byte2 = 0x01 if self.variable_heating else 0x00 + return bytearray([byte1, byte2]) + + +class MessageSet(MessageE2Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x04, + ) + self.target_temperature = 0 + self.variable_heating = False + self.whole_tank_heating = False + self.protection = False + + @property + def _body(self): + # Byte 4 whole_tank_heating, protection + protection = 0x04 if self.protection else 0x00 + whole_tank_heating = 0x02 if self.whole_tank_heating else 0x01 + # Byte 5 target_temperature + target_temperature = self.target_temperature & 0xFF + # Byte 9 variable_heating + variable_heating = 0x10 if self.variable_heating else 0x00 + return bytearray( + [ + 0x01, + 0x00, + 0x80, + whole_tank_heating | protection, + target_temperature, + 0x00, + 0x00, + 0x00, + variable_heating, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class E2GeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[2] & 0x01) > 0 + self.heating = (body[2] & 0x04) > 0 + self.keep_warm = (body[2] & 0x08) > 0 + self.variable_heating = (body[2] & 0x80) > 0 + self.current_temperature = body[4] + self.whole_tank_heating = (body[7] & 0x08) > 0 + self.heating_time_remaining = body[9] * 60 + body[10] + self.target_temperature = body[11] + self.protection = ((body[22] & 0x02) > 0) if len(body) > 22 else False + if len(body) > 25: + self.water_consumption = body[24] + (body[25] << 8) + if len(body) > 34: + self.heating_power = body[34] * 100 + + +class MessageE2Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type in [MessageType.query, MessageType.notify1] + and self.body_type == 0x01 + ) or ( + self.message_type == MessageType.set + and self.body_type in [0x01, 0x02, 0x04, 0x14] + ): + self.set_body(E2GeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/e3/device.py b/src/devices/e3/device.py new file mode 100644 index 00000000..4a61c5a6 --- /dev/null +++ b/src/devices/e3/device.py @@ -0,0 +1,140 @@ +import logging +import json +from .message import ( + MessageQuery, + MessageSet, + MessageNewProtocolSet, + MessagePower, + MessageE3Response, +) + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + burning_state = "burning_state" + zero_cold_water = "zero_cold_water" + protection = "protection" + zero_cold_pulse = "zero_cold_pulse" + smart_volume = "smart_volume" + current_temperature = "current_temperature" + target_temperature = "target_temperature" + + +class MideaE3Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xE3, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.burning_state: False, + DeviceAttributes.zero_cold_water: False, + DeviceAttributes.protection: False, + DeviceAttributes.zero_cold_pulse: False, + DeviceAttributes.smart_volume: False, + DeviceAttributes.current_temperature: None, + DeviceAttributes.target_temperature: 40, + }, + ) + self._old_subtypes = [32, 33, 34, 35, 36, 37, 40, 43, 48, 49, 80] + self._precision_halves = None + self._default_precision_halves = False + self.set_customize(customize) + + @property + def precision_halves(self): + return self._precision_halves + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageE3Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + if self._precision_halves and status in [ + DeviceAttributes.current_temperature, + DeviceAttributes.target_temperature, + ]: + self._attributes[status] = getattr(message, str(status)) / 2 + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.zero_cold_water = self._attributes[DeviceAttributes.zero_cold_water] + message.protection = self._attributes[DeviceAttributes.protection] + message.zero_clod_pulse = self._attributes[DeviceAttributes.zero_cold_pulse] + message.smart_volume = self._attributes[DeviceAttributes.smart_volume] + message.target_temperature = self._attributes[ + DeviceAttributes.target_temperature + ] + return message + + def set_attribute(self, attr, value): + if attr not in [ + DeviceAttributes.burning_state, + DeviceAttributes.current_temperature, + DeviceAttributes.protection, + ]: + if self._precision_halves and attr == DeviceAttributes.target_temperature: + value = int(value * 2) + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + elif self.subtype in self._old_subtypes: + message = self.make_message_set() + setattr(message, str(attr), value) + else: + message = MessageNewProtocolSet(self._protocol_version) + setattr(message, "key", str(attr)) + setattr(message, "value", value) + self.build_send(message) + + def set_customize(self, customize): + self._precision_halves = self._default_precision_halves + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "precision_halves" in params: + self._precision_halves = params.get("precision_halves") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"precision_halves": self._precision_halves}) + + +class MideaAppliance(MideaE3Device): + pass diff --git a/src/devices/e3/message.py b/src/devices/e3/message.py new file mode 100644 index 00000000..eab85b08 --- /dev/null +++ b/src/devices/e3/message.py @@ -0,0 +1,179 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +NEW_PROTOCOL_PARAMS = { + "zero_cold_water": 0x03, + # "zero_cold_master": 0x12, + "zero_cold_pulse": 0x04, + "smart_volume": 0x07, + "target_temperature": 0x08, +} + + +class MessageE3Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xE3, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageE3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessagePower(MessageE3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x02, + ) + self.power = False + + @property + def _body(self): + if self.power: + self.body_type = 0x01 + else: + self.body_type = 0x02 + return bytearray([0x01]) + + +class MessageSet(MessageE3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x04, + ) + + self.target_temperature = 0 + self.zero_cold_water = False + self.bathtub_volume = 0 + self.protection = False + self.zero_cold_pulse = False + self.smart_volume = False + + @property + def _body(self): + # Byte 2 zero_cold_water mode + zero_cold_water = 0x01 if self.zero_cold_water else 0x00 + # Byte 3 + protection = 0x08 if self.protection else 0x00 + zero_cold_pulse = 0x10 if self.zero_cold_pulse else 0x00 + smart_volume = 0x20 if self.smart_volume else 0x00 + # Byte 5 target_temperature + target_temperature = self.target_temperature & 0xFF + + return bytearray( + [ + 0x01, + zero_cold_water | 0x02, + protection | zero_cold_pulse | smart_volume, + 0x00, + target_temperature, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessageNewProtocolSet(MessageE3Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x14, + ) + self.key = None + self.value = None + + @property + def _body(self): + key = NEW_PROTOCOL_PARAMS.get(self.key) + if self.key == "target_temperature": + value = self.value + else: + value = 0x01 if self.value else 0x00 + return bytearray( + [ + key, + value, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class E3GeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[2] & 0x01) > 0 + self.burning_state = (body[2] & 0x02) > 0 + self.zero_cold_water = (body[2] & 0x04) > 0 + self.current_temperature = body[5] + self.target_temperature = body[6] + self.protection = (body[8] & 0x08) > 0 + self.zero_cold_pulse = (body[20] & 0x01) > 0 if len(body) > 20 else False + self.smart_volume = (body[20] & 0x02) > 0 if len(body) > 20 else False + + +class MessageE3Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + (self.message_type == MessageType.query and self.body_type == 0x01) + or ( + self.message_type == MessageType.set + and self.body_type in [0x01, 0x02, 0x04, 0x14] + ) + or ( + self.message_type == MessageType.notify1 + and self.body_type in [0x00, 0x01] + ) + ): + self.set_body(E3GeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/e6/device.py b/src/devices/e6/device.py new file mode 100644 index 00000000..e7a42415 --- /dev/null +++ b/src/devices/e6/device.py @@ -0,0 +1,91 @@ +import logging +from .message import MessageQuery, MessageSet, MessageE6Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + main_power = "main_power" + heating_power = "heating_power" + heating_working = "heating_working" + bathing_working = "bathing_working" + min_temperature = "temperature_min" + max_temperature = "temperature_max" + heating_temperature = "heating_temperature" + bathing_temperature = "bathing_temperature" + heating_leaving_temperature = "heating_leaving_temperature" + bathing_leaving_temperature = "bathing_leaving_temperature" + + +class MideaE6Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xE6, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.main_power: False, + DeviceAttributes.heating_power: True, + DeviceAttributes.heating_working: None, + DeviceAttributes.bathing_working: None, + DeviceAttributes.min_temperature: [30, 35], + DeviceAttributes.max_temperature: [80, 60], + DeviceAttributes.heating_temperature: 50, + DeviceAttributes.bathing_temperature: 40, + DeviceAttributes.heating_leaving_temperature: None, + DeviceAttributes.bathing_leaving_temperature: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageE6Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr in [ + DeviceAttributes.main_power, + DeviceAttributes.heating_power, + DeviceAttributes.heating_temperature, + DeviceAttributes.bathing_temperature, + ]: + message = MessageSet(self._protocol_version) + setattr(message, str(attr), value) + self.build_send(message) + + +class MideaAppliance(MideaE6Device): + pass diff --git a/src/devices/e6/message.py b/src/devices/e6/message.py new file mode 100644 index 00000000..489770a5 --- /dev/null +++ b/src/devices/e6/message.py @@ -0,0 +1,84 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageE6Base(MessageRequest): + def __init__(self, protocol_version, message_type): + super().__init__( + device_type=0xE6, + protocol_version=protocol_version, + message_type=message_type, + body_type=None, + ) + + @property + def body(self): + return self._body + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageE6Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, message_type=MessageType.query + ) + + @property + def _body(self): + return bytearray([0x01, 0x01] + [0] * 28) + + +class MessageSet(MessageE6Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, message_type=MessageType.set + ) + self.main_power = None + self.heating_temperature = None + self.bathing_temperature = None + self.heating_power = None + + @property + def _body(self): + body = [] + if self.main_power is not None: + main_power = 0x01 if self.main_power else 0x02 + body = [main_power, 0x01] + elif self.heating_temperature is not None: + body = [0x04, 0x13, self.heating_temperature] + elif self.bathing_temperature is not None: + body = [0x04, 0x12, self.bathing_temperature] + elif self.heating_power is not None: + heating_power = 0x01 if self.heating_power else 0x02 + body = [0x04, 0x01, heating_power] + body_len = len(body) + return bytearray(body + [0] * (30 - body_len)) + + +class E6GeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.main_power = (body[2] & 0x04) > 0 + self.heating_working = (body[2] & 0x10) > 0 + self.bathing_working = (body[2] & 0x20) > 0 + self.heating_power = (body[4] & 0x01) > 0 + self.min_temperature = [body[16], body[11]] + self.max_temperature = [body[15], body[10]] + self.heating_temperature = body[17] + self.bathing_temperature = body[12] + self.heating_leaving_temperature = body[14] + self.bathing_leaving_temperature = body[8] + + +class MessageE6Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + self.set_body(E6GeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/e8/device.py b/src/devices/e8/device.py new file mode 100644 index 00000000..75ca2940 --- /dev/null +++ b/src/devices/e8/device.py @@ -0,0 +1,97 @@ +import logging +from .message import MessageQuery, MessageE8Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + status = "status" + time_remaining = "time_remaining" + keep_warm_remaining = "keep_warm_remaining" + working_time = "working_time" + target_temperature = "target_temperature" + current_temperature = "current_temperature" + finished = "finished" + water_shortage = "water_shortage" + + +class MideaE8Device(MideaDevice): + _status = { + 0x00: "Standby", + 0x01: "Delay", + 0x02: "Working", + 0x03: "Paused", + 0x04: "Keep-Warming", + 0xFF: "Error", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xB1, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.status: None, + DeviceAttributes.time_remaining: None, + DeviceAttributes.keep_warm_remaining: None, + DeviceAttributes.working_time: None, + DeviceAttributes.target_temperature: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.finished: None, + DeviceAttributes.water_shortage: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageE8Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.status: + if value in MideaE8Device._status.keys(): + self._attributes[DeviceAttributes.status] = ( + MideaE8Device._status.get(value) + ) + else: + self._attributes[DeviceAttributes.status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaE8Device): + pass diff --git a/src/devices/e8/message.py b/src/devices/e8/message.py new file mode 100644 index 00000000..960382e8 --- /dev/null +++ b/src/devices/e8/message.py @@ -0,0 +1,55 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class MessageE8Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xE8, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageE8Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0xAA, + ) + + @property + def _body(self): + return bytearray([0x55, 0x00, 0x01, 0x00, 0x00]) + + +class E8MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.status = body[11] + self.time_remaining = body[16] * 3600 + body[17] * 60 + body[18] + self.keep_warm_remaining = body[19] * 3600 + body[20] * 60 + body[21] + self.working_time = body[28] * 3600 + body[29] * 60 + body[30] + self.target_temperature = body[39] + self.current_temperature = body[39] + self.finished = (body[41] & 0x01) > 0 + self.water_shortage = body[43] > 0 + + +class MessageE8Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if len(super().body) > 6: + sub_cmd = super().body[6] + if ( + (self.message_type == MessageType.set and sub_cmd in [0x02, 0x04, 0x06]) + or self.message_type in [MessageType.query, MessageType.notify1] + and sub_cmd == 2 + ): + self.set_body(E8MessageBody(super().body)) + self.set_attr() diff --git a/src/devices/ea/device.py b/src/devices/ea/device.py new file mode 100644 index 00000000..2262b4f8 --- /dev/null +++ b/src/devices/ea/device.py @@ -0,0 +1,195 @@ +import logging +from .message import MessageQuery, MessageEAResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + cooking = "cooking" + keep_warm = "keep_warm" + mode = "mode" + time_remaining = "time_remaining" + keep_warm_time = "keep_warm_time" + top_temperature = "top_temperature" + bottom_temperature = "bottom_temperature" + progress = "progress" + + +class MideaEADevice(MideaDevice): + _mode_list = ( + [ + "smart", + "reserve", + "cook_rice", + "fast_cook_rice", + "standard_cook_rice", + "gruel", + "cook_congee", + "stew_soup", + "stewing", + "heat_rice", + "make_cake", + "yoghourt", + "soup_rice", + "coarse_rice", + "five_ceeals_rice", + "eight_treasures_rice", + "crispy_rice", + "shelled_rice", + "eight_treasures_congee", + "infant_congee", + "older_rice", + "rice_soup", + "rice_paste", + "egg_custard", + "warm_milk", + "hot_spring_egg", + "millet_congee", + "firewood_rice", + "few_rice", + "red_potato", + "corn", + "quick_freeze_bun", + "steam_ribs", + "steam_egg", + "coarse_congee", + "steep_rice", + "appetizing_congee", + "corn_congee", + "sprout_rice", + "luscious_rice", + "luscious_boiled", + "fast_rice", + "fast_boil", + "bean_rice_congee", + "fast_congee", + "baby_congee", + "cook_soup", + "congee_coup", + "steam_corn", + "steam_red_potato", + "boil_congee", + "delicious_steam", + "boil_egg", + "rice_wine", + "fruit_vegetable_paste", + "vegetable_porridge", + "pork_porridge", + "fragrant_rice", + "assorte_rice", + "steame_fish", + "baby_rice", + "essence_rice", + "fragrant_dense_congee", + "one_two_cook", + "original_steame", + "hot_fast_rice", + "online_celebrity_rice", + "sushi_rice", + "stone_bowl_rice", + "no_water_treat", + "keep_fresh", + "low_sugar_rice", + "black_buckwheat_rice", + "resveratrol_rice", + "yellow_wheat_rice", + "green_buckwheat_rice", + "roughage_rice", + "millet_mixed_rice", + "iron_pan_rice", + "olla_pan_rice", + "vegetable_rice", + "baby_side", + "regimen_congee", + "earthen_pot_congee", + "regimen_soup", + "pottery_jar_soup", + "canton_soup", + "nutrition_stew", + "northeast_stew", + "uncap_boil", + "trichromatic_coarse_grain", + "four_color_vegetables", + "egg", + "chop", + ] + + ["unknown"] * 98 + + ["clean"] + + ["unknown"] * 5 + + ["keep_warm"] + ) + _progress = ["Idle", "Delay", "Cooking", "Keep-warm"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xEA, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.cooking: False, + DeviceAttributes.keep_warm: False, + DeviceAttributes.mode: 0, + DeviceAttributes.time_remaining: None, + DeviceAttributes.top_temperature: None, + DeviceAttributes.bottom_temperature: None, + DeviceAttributes.keep_warm_time: None, + DeviceAttributes.progress: "Unknown", + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageEAResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.progress: + if value < len(MideaEADevice._progress): + self._attributes[status] = MideaEADevice._progress[value] + else: + self._attributes[status] = "Unknown" + elif status == DeviceAttributes.mode: + if value < len(MideaEADevice._mode_list): + self._attributes[status] = MideaEADevice._mode_list[value] + else: + self._attributes[status] = "Cloud" + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaEADevice): + pass diff --git a/src/devices/ea/message.py b/src/devices/ea/message.py new file mode 100644 index 00000000..8660b2d8 --- /dev/null +++ b/src/devices/ea/message.py @@ -0,0 +1,121 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageEABase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xEA, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageEABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=None, + ) + + @property + def body(self): + return bytearray([0xAA, 0x55, 0x01, 0x03, 0x00]) + + @property + def _body(self): + return bytearray([]) + + +class EABody1(MessageBody): + def __init__(self, body): + super().__init__(body) + self.mode = body[6] + (body[7] << 8) + self.progress = body[14] + self.cooking = self.progress == 2 + self.keep_warm = self.progress == 3 + self.top_temperature = body[18] + self.bottom_temperature = body[19] + self.time_remaining = body[22] * 60 + body[23] + self.keep_warm_time = body[26] * 60 + body[27] + + +class EABody2(MessageBody): + def __init__(self, body): + super().__init__(body) + self.progress = body[9] + self.cooking = self.progress == 2 + self.keep_warm = self.progress == 3 + self.mode = body[58] + (body[59] << 8) + self.time_remaining = body[50] * 60 + body[51] + self.keep_warm_time = body[54] * 60 + body[55] + self.top_temperature = body[21] + self.bottom_temperature = body[20] + + +class EABody3(MessageBody): + def __init__(self, body): + super().__init__(body) + self.mode = body[4] + (body[5] << 8) + self.progress = body[8] + self.cooking = self.progress == 2 + self.keep_warm = self.progress == 3 + self.time_remaining = body[12] * 60 + body[13] + self.top_temperature = body[20] + self.bottom_temperature = body[21] + self.keep_warm_time = body[22] * 60 + body[23] + + +class EABodyNew(MessageBody): + def __init__(self, body): + super().__init__(body) + if body[6] in [2, 4, 6, 8, 10, 0x62]: + self.mode = body[7] + (body[8] << 8) + self.progress = body[11] + self.cooking = self.progress == 2 + self.keep_warm = self.progress == 3 + self.time_remaining = body[16] * 60 + body[17] + self.top_temperature = body[60] + self.bottom_temperature = body[61] + self.keep_warm_time = body[19] * 60 + body[20] + + +class MessageEAResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type == MessageType.notify1 and super().body[3] == 0x01: + self.set_body(EABodyNew(super().body)) + elif self.protocol_version == 0: + if self.message_type == MessageType.set and super().body[5] == 0x16: # 381 + self.set_body(EABody1(super().body)) + elif self.message_type == MessageType.query: + if super().body[6] == 0x52 and super().body[7] == 0xC3: # 404 + self.set_body(EABody2(super().body)) + elif super().body[5] == 0x3D: # 420 + self.set_body(EABody1(super().body)) + elif ( + self.message_type == MessageType.notify1 and super().body[5] == 0x3D + ): # 463 + self.set_body(EABody1(super().body)) + else: + if ( + (self.message_type == MessageType.set and super().body[3] == 0x02) + or (self.message_type == MessageType.query and super().body[3] == 0x03) + or ( + self.message_type == MessageType.notify1 and super().body[3] == 0x04 + ) + ): # 351 + self.set_body(EABody3(super().body)) + elif self.message_type == MessageType.notify1 and super().body[3] == 0x06: + self.mode = super().body[4] + (super().body[5] << 8) + self.set_attr() diff --git a/src/devices/ec/device.py b/src/devices/ec/device.py new file mode 100644 index 00000000..0986cc74 --- /dev/null +++ b/src/devices/ec/device.py @@ -0,0 +1,209 @@ +import logging +from .message import MessageQuery, MessageECResponse + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + cooking = "cooking" + mode = "mode" + time_remaining = "time_remaining" + keep_warm_time = "keep_warm_time" + top_temperature = "top_temperature" + bottom_temperature = "bottom_temperature" + progress = "progress" + with_pressure = "with_pressure" + + +class MideaECDevice(MideaDevice): + _mode_list = ( + [ + "smart", + "reserve", + "cook_rice", + "fast_cook_rice", + "standard_cook_rice", + "gruel", + "cook_congee", + "stew_soup", + "stewing", + "heat_rice", + "make_cake", + "yoghourt", + "soup_rice", + "coarse_rice", + "five_ceeals_rice", + "eight_treasures_rice", + "crispy_rice", + "shelled_rice", + "eight_treasures_congee", + "infant_congee", + "older_rice", + "rice_soup", + "rice_paste", + "egg_custard", + "warm_milk", + "hot_spring_egg", + "millet_congee", + "firewood_rice", + "few_rice", + "red_potato", + "corn", + "quick_freeze_bun", + "steam_ribs", + "steam_egg", + "coarse_congee", + "steep_rice", + "appetizing_congee", + "corn_congee", + "sprout_rice", + "luscious_rice", + "luscious_boiled", + "fast_rice", + "fast_boil", + "bean_rice_congee", + "fast_congee", + "baby_congee", + "cook_soup", + "congee_coup", + "steam_corn", + "steam_red_potato", + "boil_congee", + "delicious_steam", + "boil_egg", + "rice_wine", + "fruit_vegetable_paste", + "vegetable_porridge", + "pork_porridge", + "fragrant_rice", + "assorte_rice", + "steame_fish", + "baby_rice", + "essence_rice", + "fragrant_dense_congee", + "one_two_cook", + "original_steame", + "hot_fast_rice", + "online_celebrity_rice", + "sushi_rice", + "stone_bowl_rice", + "no_water_treat", + "keep_fresh", + "low_sugar_rice", + "black_buckwheat_rice", + "resveratrol_rice", + "yellow_wheat_rice", + "green_buckwheat_rice", + "roughage_rice", + "millet_mixed_rice", + "iron_pan_rice", + "olla_pan_rice", + "vegetable_rice", + "baby_side", + "regimen_congee", + "earthen_pot_congee", + "regimen_soup", + "pottery_jar_soup", + "canton_soup", + "nutrition_stew", + "northeast_stew", + "uncap_boil", + "trichromatic_coarse_grain", + "four_color_vegetables", + "egg", + "chop", + ] + + ["unknown"] * 98 + + ["clean"] + + ["unknown"] * 5 + + ["keep_warm", "diy"] + ) + _progress = [ + "Idle", + "Cooking", + "Delay", + "Keep-warm", + "Lid-open", + "Relieving", + "Keep-pressure", + "Relieving", + "Cooking", + "Relieving", + "Lid-open", + ] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xEC, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.cooking: False, + DeviceAttributes.mode: 0, + DeviceAttributes.time_remaining: None, + DeviceAttributes.top_temperature: None, + DeviceAttributes.bottom_temperature: None, + DeviceAttributes.keep_warm_time: None, + DeviceAttributes.progress: "Unknown", + DeviceAttributes.with_pressure: None, + }, + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageECResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.progress: + if value < len(MideaECDevice._progress): + self._attributes[status] = MideaECDevice._progress[ + getattr(message, str(status)) + ] + else: + self._attributes[status] = "Unknown" + elif status == DeviceAttributes.mode: + if value < len(MideaECDevice._mode_list): + self._attributes[status] = MideaECDevice._mode_list[value] + else: + self._attributes[status] = "Cloud" + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + pass + + +class MideaAppliance(MideaECDevice): + pass diff --git a/src/devices/ec/message.py b/src/devices/ec/message.py new file mode 100644 index 00000000..0e6fb41a --- /dev/null +++ b/src/devices/ec/message.py @@ -0,0 +1,79 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageECBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xEC, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageECBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=None, + ) + + @property + def body(self): + return bytearray([0xAA, 0x55, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + + @property + def _body(self): + return bytearray([]) + + +class ECGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.mode = body[4] + (body[5] << 8) + self.progress = body[8] + self.cooking = self.progress == 1 + self.time_remaining = body[12] * 60 + body[13] + self.keep_warm_time = body[16] * 60 + body[17] + self.top_temperature = body[21] + self.bottom_temperature = body[22] + self.with_pressure = (body[23] & 0x04) > 0 + + +class ECBodyNew(MessageBody): + def __init__(self, body): + super().__init__(body) + self.progress = body[11] + self.cooking = self.progress == 1 + self.time_remaining = body[16] * 60 + body[17] + self.keep_warm_time = body[19] * 60 + body[20] + self.top_temperature = body[48] + self.bottom_temperature = body[49] + self.with_pressure = body[33] > 0 + + +class MessageECResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type == MessageType.notify1 and super().body[3] == 0x01: + self.set_body(ECBodyNew(super().body)) + elif ( + (self.message_type == MessageType.set and super().body[3] == 0x02) + or (self.message_type == MessageType.query and super().body[3] == 0x03) + or (self.message_type == MessageType.notify1 and super().body[3] == 0x04) + or (self.message_type == MessageType.notify1 and super().body[3] == 0x3D) + ): + self.set_body(ECGeneralMessageBody(super().body)) + elif self.message_type == MessageType.notify1 and super().body[3] == 0x06: + self.mode = super().body[4] + (super().body[5] << 8) + self.set_attr() diff --git a/src/devices/ed/device.py b/src/devices/ed/device.py new file mode 100644 index 00000000..deecd968 --- /dev/null +++ b/src/devices/ed/device.py @@ -0,0 +1,100 @@ +import logging +from .message import MessageQuery, MessageEDResponse, MessageNewSet, MessageOldSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + water_consumption = "water_consumption" + in_tds = "in_tds" + out_tds = "out_tds" + filter1 = "filter1" + filter2 = "filter2" + filter3 = "filter3" + life1 = "life1" + life2 = "life2" + life3 = "life3" + child_lock = "child_lock" + + +class MideaEDDevice(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xED, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.water_consumption: None, + DeviceAttributes.in_tds: None, + DeviceAttributes.out_tds: None, + DeviceAttributes.filter1: None, + DeviceAttributes.filter2: None, + DeviceAttributes.filter3: None, + DeviceAttributes.life1: None, + DeviceAttributes.life2: None, + DeviceAttributes.life3: None, + DeviceAttributes.child_lock: False, + }, + ) + self._device_class = 0 + + def _use_new_set(self): + return True # if (self.sub_type > 342 or self.sub_type == 340) else False + + def build_query(self): + return [MessageQuery(self._protocol_version, self._device_class)] + + def process_message(self, msg): + message = MessageEDResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + if hasattr(message, "device_class"): + self._device_class = message.device_class + for status in self._attributes.keys(): + if hasattr(message, str(status)): + new_status[str(status)] = getattr(message, str(status)) + self._attributes[status] = getattr(message, str(status)) + return new_status + + def set_attribute(self, attr, value): + message = None + if self._use_new_set(): + if attr in [DeviceAttributes.power, DeviceAttributes.child_lock]: + message = MessageNewSet(self._protocol_version) + else: + if attr in []: + message = MessageOldSet(self._protocol_version) + if message is not None: + setattr(message, str(attr), value) + self.build_send(message) + + +class MideaAppliance(MideaEDDevice): + pass diff --git a/src/devices/ed/message.py b/src/devices/ed/message.py new file mode 100644 index 00000000..3c8a14d4 --- /dev/null +++ b/src/devices/ed/message.py @@ -0,0 +1,206 @@ +from enum import IntEnum +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class NewSetTags(IntEnum): + power = 0x0100 + lock = 0x0201 + + +class EDNewSetParamPack: + @staticmethod + def pack(param, value, addition=0): + return bytearray( + [param & 0xFF, param >> 8, value, addition & 0xFF, addition >> 8] + ) + + +class MessageEDBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xED, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageEDBase): + def __init__(self, protocol_version, device_class): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=device_class, + ) + + @property + def _body(self): + return bytearray([0x01]) + + +class MessageNewSet(MessageEDBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x15, + ) + self.power = None + self.lock = None + + @property + def _body(self): + pack_count = 0 + payload = bytearray([0x01, 0x00]) + if self.power is not None: + pack_count += 1 + payload.extend( + EDNewSetParamPack.pack( + param=NewSetTags.power, # power + value=0x01 if self.power else 0x00, + ) + ) + if self.lock is not None: + pack_count += 1 + payload.extend( + EDNewSetParamPack.pack( + param=NewSetTags.lock, # lock + value=0x01 if self.lock else 0x00, + ) + ) + payload[1] = pack_count + return payload + + +class MessageOldSet(MessageEDBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=None, + ) + + @property + def body(self): + return bytearray([]) + + @property + def _body(self): + return bytearray([]) + + +class EDMessageBody01(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[2] & 0x01) > 0 + self.water_consumption = body[7] + (body[8] << 8) + self.in_tds = body[36] + (body[37] << 8) + self.out_tds = body[38] + (body[39] << 8) + self.child_lock = body[15] > 0 + self.filter1 = round((body[25] + (body[26] << 8)) / 24) + self.filter2 = round((body[27] + (body[28] << 8)) / 24) + self.filter3 = round((body[29] + (body[30] << 8)) / 24) + self.life1 = body[16] + self.life2 = body[17] + self.life3 = body[18] + + +class EDMessageBody03(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[51] & 0x01) > 0 + self.child_lock = (body[51] & 0x08) > 0 + self.water_consumption = body[20] + (body[21] << 8) + self.life1 = body[22] + self.life2 = body[23] + self.life3 = body[24] + self.in_tds = body[27] + (body[28] << 8) + self.out_tds = body[29] + (body[30] << 8) + + +class EDMessageBody05(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[51] & 0x01) > 0 + self.child_lock = (body[51] & 0x08) > 0 + self.water_consumption = body[20] + (body[21] << 8) + + +class EDMessageBody06(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[51] & 0x01) > 0 + self.child_lock = (body[51] & 0x08) > 0 + self.water_consumption = body[25] + (body[26] << 8) + + +class EDMessageBody07(MessageBody): + def __init__(self, body): + super().__init__(body) + self.water_consumption = (body[21] << 8) + body[20] + self.power = (body[51] & 0x01) > 0 + self.child_lock = (body[51] & 0x08) > 0 + + +class EDMessageBodyFF(MessageBody): + def __init__(self, body): + super().__init__(body) + data_offset = 2 + while True: + length = (body[data_offset + 2] >> 4) + 2 + attr = ((body[data_offset + 2] % 16) << 8) + body[data_offset + 1] + if attr == 0x000: + self.child_lock = (body[data_offset + 5] & 0x01) > 0 + self.power = (body[data_offset + 6] & 0x01) > 0 + elif attr == 0x011: + self.water_consumption = ( + float( + ( + body[data_offset + 3] + + (body[data_offset + 4] << 8) + + (body[data_offset + 5] << 16) + + (body[data_offset + 6] << 24) + ) + ) + / 1000 + ) + elif attr == 0x013: + self.in_tds = body[data_offset + 3] + (body[data_offset + 4] << 8) + self.out_tds = body[data_offset + 5] + (body[data_offset + 6] << 8) + elif attr == 0x10: + self.life1 = body[data_offset + 3] + self.life2 = body[data_offset + 4] + self.life3 = body[data_offset + 5] + # fix index out of range error + if data_offset + length + 6 > len(body): + break + data_offset += length + + +class MessageEDResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self._message_type in [MessageType.query, MessageType.notify1]: + self.device_class = self._body_type + if self._body_type in [0x00, 0xFF]: + self.set_body(EDMessageBodyFF(super().body)) + if self.body_type == 0x01: + self.set_body(EDMessageBody01(super().body)) + elif self.body_type in [0x03, 0x04]: + self.set_body(EDMessageBody03(super().body)) + elif self.body_type == 0x05: + self.set_body(EDMessageBody05(super().body)) + elif self.body_type == 0x06: + self.set_body(EDMessageBody06(super().body)) + elif self.body_type == 0x07: + self.set_body(EDMessageBody07(super().body)) + self.set_attr() diff --git a/src/devices/fa/device.py b/src/devices/fa/device.py new file mode 100644 index 00000000..28634530 --- /dev/null +++ b/src/devices/fa/device.py @@ -0,0 +1,325 @@ +import logging +import json +from .message import MessageQuery, MessageFAResponse, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + child_lock = "child_lock" + mode = "mode" + fan_speed = "fan_speed" + oscillate = "oscillate" + oscillation_angle = "oscillation_angle" + tilting_angle = "tilting_angle" + oscillation_mode = "oscillation_mode" + + +class MideaFADevice(MideaDevice): + _oscillation_angles = ["Off", "30", "60", "90", "120", "180", "360"] + _tilting_angles = ["Off", "30", "60", "90", "120", "180", "360", "+60", "-60", "40"] + _oscillation_modes = [ + "Off", + "Oscillation", + "Tilting", + "Curve-W", + "Curve-8", + "Reserved", + "Both", + ] + _modes = [ + "Normal", + "Natural", + "Sleep", + "Comfort", + "Silent", + "Baby", + "Induction", + "Circulation", + "Strong", + "Soft", + "Customize", + "Warm", + "Smart", + ] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xFA, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.child_lock: False, + DeviceAttributes.mode: 0, + DeviceAttributes.fan_speed: 0, + DeviceAttributes.oscillate: False, + DeviceAttributes.oscillation_angle: None, + DeviceAttributes.tilting_angle: None, + DeviceAttributes.oscillation_mode: None, + }, + ) + self._default_speed_count = 3 + self._speed_count = self._default_speed_count + self.set_customize(customize) + + @property + def speed_count(self): + return self._speed_count + + @property + def oscillation_angles(self): + return MideaFADevice._oscillation_angles + + @property + def tilting_angles(self): + return MideaFADevice._tilting_angles + + @property + def oscillation_modes(self): + return MideaFADevice._oscillation_modes + + @property + def preset_modes(self): + return self._modes + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageFAResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.oscillation_angle: + if value < len(MideaFADevice._oscillation_angles): + self._attributes[status] = MideaFADevice._oscillation_angles[ + value + ] + else: + self._attributes[status] = None + elif status == DeviceAttributes.tilting_angle: + if value < len(MideaFADevice._tilting_angles): + self._attributes[status] = MideaFADevice._tilting_angles[value] + else: + self._attributes[status] = None + elif status == DeviceAttributes.oscillation_mode: + if value < len(MideaFADevice._oscillation_modes): + self._attributes[status] = MideaFADevice._oscillation_modes[ + value + ] + else: + self._attributes[status] = None + elif status == DeviceAttributes.mode: + if value < len(MideaFADevice._modes): + self._attributes[status] = MideaFADevice._modes[value] + else: + self._attributes[status] = None + elif status == DeviceAttributes.power: + self._attributes[status] = value + if not value: + self._attributes[DeviceAttributes.fan_speed] = 0 + elif ( + status == DeviceAttributes.fan_speed + and not self._attributes[DeviceAttributes.power] + ): + self._attributes[status] = 0 + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_oscillation(self, attr, value): + message = None + if self._attributes[attr] != value: + if attr == DeviceAttributes.oscillate: + message = MessageSet(self._protocol_version, self.subtype) + message.oscillate = value + if value: + message.oscillation_angle = 3 # 90 + message.oscillation_mode = 1 # Oscillation + elif attr == DeviceAttributes.oscillation_mode and ( + value in MideaFADevice._oscillation_modes or not value + ): + message = MessageSet(self._protocol_version, self.subtype) + if value == "Off" or not value: + message.oscillate = False + else: + message.oscillate = True + message.oscillation_mode = MideaFADevice._oscillation_modes.index( + value + ) + if value == "Oscillation": + if ( + self._attributes[DeviceAttributes.oscillation_angle] + == "Off" + ): + message.oscillation_angle = 3 # 90 + else: + message.oscillation_angle = ( + MideaFADevice._oscillation_angles.index( + self._attributes[DeviceAttributes.oscillation_angle] + ) + ) + elif value == "Tilting": + if self._attributes[DeviceAttributes.tilting_angle] == "Off": + message.tilting_angle = 3 # 90 + else: + message.tilting_angle = MideaFADevice._tilting_angles.index( + self._attributes[DeviceAttributes.tilting_angle] + ) + else: + if ( + self._attributes[DeviceAttributes.oscillation_angle] + == "Off" + ): + message.oscillation_angle = 3 # 90 + else: + message.oscillation_angle = ( + MideaFADevice._oscillation_angles.index( + self._attributes[DeviceAttributes.oscillation_angle] + ) + ) + if self._attributes[DeviceAttributes.tilting_angle] == "Off": + message.tilting_angle = 3 # 90 + else: + message.tilting_angle = MideaFADevice._tilting_angles.index( + self._attributes[DeviceAttributes.tilting_angle] + ) + elif attr == DeviceAttributes.oscillation_angle and ( + value in MideaFADevice._oscillation_angles or not value + ): + message = MessageSet(self._protocol_version, self.subtype) + if value == "Off" or not value: + if self._attributes[DeviceAttributes.tilting_angle] == "Off": + message.oscillate = False + else: + message.oscillate = True + message.oscillation_mode = 2 + message.tilting_angle = MideaFADevice._tilting_angles.index( + self._attributes[DeviceAttributes.tilting_angle] + ) + else: + message.oscillation_angle = MideaFADevice._oscillation_angles.index( + value + ) + message.oscillate = True + if self._attributes[DeviceAttributes.tilting_angle] == "Off": + message.oscillation_mode = 1 + elif ( + self._attributes[DeviceAttributes.oscillation_mode] == "Tilting" + ): + message.oscillation_mode = 6 + message.tilting_angle = MideaFADevice._tilting_angles.index( + self._attributes[DeviceAttributes.tilting_angle] + ) + elif attr == DeviceAttributes.tilting_angle and ( + value in MideaFADevice._tilting_angles or not value + ): + message = MessageSet(self._protocol_version, self.subtype) + if value == "Off" or not value: + if self._attributes[DeviceAttributes.oscillation_angle] == "Off": + message.oscillate = False + else: + message.oscillate = True + message.oscillation_mode = 1 + message.oscillation_angle = ( + MideaFADevice._oscillation_angles.index( + self._attributes[DeviceAttributes.oscillation_angle] + ) + ) + else: + message.tilting_angle = MideaFADevice._tilting_angles.index(value) + message.oscillate = True + if self._attributes[DeviceAttributes.oscillation_angle] == "Off": + message.oscillation_mode = 2 + elif ( + self._attributes[DeviceAttributes.oscillation_mode] + == "Oscillation" + ): + message.oscillation_mode = 6 + message.oscillation_angle = ( + MideaFADevice._oscillation_angles.index( + self._attributes[DeviceAttributes.oscillation_angle] + ) + ) + return message + + def set_attribute(self, attr, value): + message = None + if attr in [ + DeviceAttributes.oscillate, + DeviceAttributes.oscillation_mode, + DeviceAttributes.oscillation_angle, + DeviceAttributes.tilting_angle, + ]: + message = self.set_oscillation(attr, value) + elif ( + attr == DeviceAttributes.fan_speed + and value > 0 + and not self._attributes[DeviceAttributes.power] + ): + message = MessageSet(self._protocol_version, self.subtype) + message.fan_speed = value + message.power = True + elif attr == DeviceAttributes.mode: + if value in MideaFADevice._modes: + message = MessageSet(self._protocol_version, self.subtype) + message.mode = MideaFADevice._modes.index(value) + elif not (attr == DeviceAttributes.fan_speed and value == 0): + message = MessageSet(self._protocol_version, self.subtype) + setattr(message, str(attr), value) + if message is not None: + self.build_send(message) + + def turn_on(self, fan_speed=None, mode=None): + message = MessageSet(self._protocol_version, self.subtype) + message.power = True + if fan_speed is not None: + message.fan_speed = fan_speed + if mode is None: + message.mode = mode + self.build_send(message) + + def set_customize(self, customize): + self._speed_count = self._default_speed_count + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "speed_count" in params: + self._speed_count = params.get("speed_count") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"speed_count": self._speed_count}) + + +class MideaAppliance(MideaFADevice): + pass diff --git a/src/devices/fa/message.py b/src/devices/fa/message.py new file mode 100644 index 00000000..fbfad28c --- /dev/null +++ b/src/devices/fa/message.py @@ -0,0 +1,202 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageFABase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xFA, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageFABase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=None, + ) + + @property + def body(self): + return bytearray([]) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(MessageFABase): + def __init__(self, protocol_version, subtype): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x00, + ) + self._subtype = subtype + self.power = None + self.lock = None + self.mode = None + self.fan_speed = None + self.oscillate = None + self.oscillation_angle = None + self.oscillation_mode = None + self.tilting_angle = None + + @property + def _body(self): + if 1 <= self._subtype <= 10 or self._subtype == 161: + _body_return = bytearray( + [ + 0x00, + 0x00, + 0x00, + 0x80, + 0x00, + 0x00, + 0x00, + 0x80, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + if self._subtype != 10: + _body_return[13] = 0xFF + else: + _body_return = bytearray( + [ + 0x00, + 0x00, + 0x00, + 0x80, + 0x00, + 0x00, + 0x00, + 0x80, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + if self.power is not None: + if self.power: + _body_return[3] = 1 + else: + _body_return[3] = 0 + if self.lock is not None: + if self.lock: + _body_return[2] = 1 + else: + _body_return[2] = 2 + if self.mode is not None: + _body_return[3] = 1 | (((self.mode + 1) << 1) & 0x1E) + if self.fan_speed is not None and 1 <= self.fan_speed <= 26: + _body_return[4] = self.fan_speed + if self.oscillate is not None: + if self.oscillate: + _body_return[7] = 1 + else: + _body_return[7] = 0 + if self.oscillation_angle is not None: + _body_return[7] = ( + 1 | _body_return[7] | ((self.oscillation_angle << 4) & 0x70) + ) + if self.oscillation_mode is not None: + _body_return[7] = ( + 1 | _body_return[7] | ((self.oscillation_mode << 1) & 0x0E) + ) + if self.tilting_angle is not None and len(_body_return) > 24: + _body_return[24] = self.tilting_angle + return _body_return + + +class FAGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + lock = body[3] & 0x03 + if lock == 1: + self.child_lock = True + else: + self.child_lock = False + self.power = (body[4] & 0x01) > 0 + mode = (body[4] & 0x1E) >> 1 + if mode > 0: + self.mode = mode - 1 + fan_speed = body[5] + if 1 <= fan_speed <= 26: + self.fan_speed = fan_speed + else: + self.fan_speed = 0 + self.oscillate = (body[8] & 0x01) > 0 + self.oscillation_angle = (body[8] & 0x70) >> 4 + self.oscillation_mode = (body[8] & 0x0E) >> 1 + self.tilting_angle = body[25] if len(body) > 25 else 0 + + +class MessageFAResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [ + MessageType.query, + MessageType.set, + MessageType.notify1, + ]: + self.set_body(FAGeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/fb/device.py b/src/devices/fb/device.py new file mode 100644 index 00000000..26ffc863 --- /dev/null +++ b/src/devices/fb/device.py @@ -0,0 +1,107 @@ +import logging +from .message import MessageQuery, MessageFBResponse, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + mode = "mode" + heating_level = "heating_level" + target_temperature = "target_temperature" + current_temperature = "current_temperature" + child_lock = "child_lock" + + +class MideaFBDevice(MideaDevice): + _modes = { + 0x01: "Auto", + 0x02: "ECO", + 0x03: "Sleep", + 0x04: "Anti-freezing", + 0x05: "Comfort", + 0x06: "Constant-temperature", + 0x07: "Normal", + 0x08: "Fast-heating", + 0x10: "Standby", + } + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xFB, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.mode: None, + DeviceAttributes.heating_level: 0, + DeviceAttributes.target_temperature: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.child_lock: False, + }, + ) + + @property + def modes(self): + return list(MideaFBDevice._modes.values()) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageFBResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.mode: + if value in MideaFBDevice._modes.keys(): + self._attributes[status] = MideaFBDevice._modes.get(value) + else: + self._attributes[status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.mode: + message = MessageSet(self._protocol_version, self.subtype) + if value in MideaFBDevice._modes.values(): + message.mode = list(MideaFBDevice._modes.keys())[ + list(MideaFBDevice._modes.values()).index(value) + ] + else: + message = MessageSet(self._protocol_version, self.subtype) + setattr(message, str(attr), value) + self.build_send(message) + + +class MideaAppliance(MideaFBDevice): + pass diff --git a/src/devices/fb/message.py b/src/devices/fb/message.py new file mode 100644 index 00000000..8bc503ea --- /dev/null +++ b/src/devices/fb/message.py @@ -0,0 +1,139 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageFBBase(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xFB, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(MessageFBBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=None, + ) + + @property + def body(self): + return bytearray([]) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(MessageFBBase): + def __init__(self, protocol_version, subtype): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x00, + ) + self._subtype = subtype + self.power = None + self.mode = None + self.heating_level = None + self.target_temperature = None + self.child_lock = None + + @property + def body(self): + power = 0 if self.power is None else (0x01 if self.power else 0x02) + mode = 0 if self.mode is None else self.mode + heating_level = ( + 0 + if self.heating_level is None + else ( + int(self.heating_level if 1 <= self.heating_level <= 10 else 0) & 0xFF + ) + ) + target_temperature = ( + 0 + if self.target_temperature is None + else ( + int( + (self.target_temperature + 41) + if -40 <= self.target_temperature <= 50 + else (0x80 if self.target_temperature in [0x80, 87] else 0) + ) + & 0xFF + ) + ) + child_lock = ( + 0xFF if self.child_lock is None else (0x01 if self.child_lock else 0x00) + ) + _return_body = bytearray( + [ + power, + 0x00, + 0x00, + 0x00, + mode, + heating_level, + target_temperature, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + child_lock, + 0x00, + ] + ) + if self._subtype > 5: + _return_body += bytearray([0x00, 0x00, 0x00]) + return _return_body + + @property + def _body(self): + return bytearray([]) + + +class FBGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[0] & 0x01) not in [0, 2] + self.mode = body[4] + self.heating_level = body[5] + self.target_temperature = body[6] - 41 + if 1 <= body[7] <= 100: + self.target_humidity = body[7] + self.current_humidity = body[12] + self.current_temperature = body[13] - 20 + if len(body) > 18: + self.child_lock = (body[18] & 0x01) > 0 + if len(body) > 21: + self.energy_consumption = (body[21] << 8) + body[20] + + +class MessageFBResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [ + MessageType.query, + MessageType.set, + MessageType.notify1, + ]: + self.set_body(FBGeneralMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/fc/device.py b/src/devices/fc/device.py new file mode 100644 index 00000000..b2a969a5 --- /dev/null +++ b/src/devices/fc/device.py @@ -0,0 +1,235 @@ +import logging +import json +from .message import MessageQuery, MessageFCResponse, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + mode = "mode" + fan_speed = "fan_speed" + anion = "anion" + screen_display = "screen_display" + detect_mode = "detect_mode" + pm25 = "pm25" + tvoc = "tvoc" + hcho = "hcho" + child_lock = "child_lock" + prompt_tone = "prompt_tone" + filter1_life = "filter1_life" + filter2_life = "filter2_life" + standby = "standby" + + +class MideaFCDevice(MideaDevice): + _modes = { + 0x00: "Standby", + 0x10: "Auto", + 0x20: "Manual", + 0x30: "Sleep", + 0x40: "Fast", + 0x50: "Smoke", + } + _speeds = {1: "Auto", 4: "Standby", 39: "Low", 59: "Medium", 80: "High"} + _screen_displays = {0: "Bright", 6: "Dim", 7: "Off"} + _detect_modes = ["Off", "PM 2.5", "Methanal"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xFC, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.mode: None, + DeviceAttributes.fan_speed: None, + DeviceAttributes.anion: False, + DeviceAttributes.standby: False, + DeviceAttributes.screen_display: None, + DeviceAttributes.detect_mode: None, + DeviceAttributes.pm25: None, + DeviceAttributes.tvoc: None, + DeviceAttributes.hcho: None, + DeviceAttributes.child_lock: False, + DeviceAttributes.prompt_tone: True, + DeviceAttributes.filter1_life: None, + DeviceAttributes.filter2_life: None, + }, + ) + + self._standby_detect_default = [40, 20] + self._standby_detect = self._standby_detect_default + self.set_customize(customize) + + @property + def modes(self): + return list(MideaFCDevice._modes.values()) + + @property + def fan_speeds(self): + return list(MideaFCDevice._speeds.values()) + + @property + def screen_displays(self): + return list(MideaFCDevice._screen_displays.values()) + + @property + def detect_modes(self): + return self._detect_modes + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageFCResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.mode: + if value in MideaFCDevice._modes.keys(): + self._attributes[status] = MideaFCDevice._modes.get(value) + else: + self._attributes[status] = None + elif status == DeviceAttributes.fan_speed: + if value in MideaFCDevice._speeds.keys(): + self._attributes[status] = MideaFCDevice._speeds.get(value) + else: + self._attributes[status] = None + elif status == DeviceAttributes.screen_display: + if value in MideaFCDevice._screen_displays.keys(): + self._attributes[status] = MideaFCDevice._screen_displays.get( + value + ) + else: + self._attributes[status] = None + elif status == DeviceAttributes.detect_mode: + if value < len(MideaFCDevice._detect_modes): + self._attributes[status] = MideaFCDevice._detect_modes[value] + else: + self._attributes[status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.child_lock = self._attributes[DeviceAttributes.child_lock] + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + message.anion = self._attributes[DeviceAttributes.anion] + message.standby = self._attributes[DeviceAttributes.standby] + message.screen_display = self._attributes[DeviceAttributes.screen_display] + message.detect_mode = ( + 0 + if self._attributes[DeviceAttributes.detect_mode] is None + else MideaFCDevice._detect_modes.index( + self._attributes[DeviceAttributes.detect_mode] + ) + ) + message.mode = ( + 0x10 + if self._attributes[DeviceAttributes.mode] is None + else list(MideaFCDevice._modes.keys())[ + list(MideaFCDevice._modes.values()).index( + self._attributes[DeviceAttributes.mode] + ) + ] + ) + message.fan_speed = ( + 39 + if self._attributes[DeviceAttributes.fan_speed] is None + else list(MideaFCDevice._speeds.keys())[ + list(MideaFCDevice._speeds.values()).index( + self._attributes[DeviceAttributes.fan_speed] + ) + ] + ) + message.screen_display = ( + 0 + if self._attributes[DeviceAttributes.screen_display] is None + else list(MideaFCDevice._screen_displays.keys())[ + list(MideaFCDevice._screen_displays.values()).index( + self._attributes[DeviceAttributes.screen_display] + ) + ] + ) + message.standby_detect = self._standby_detect + return message + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.prompt_tone: + self._attributes[DeviceAttributes.prompt_tone] = value + self.update_all({DeviceAttributes.prompt_tone.value: value}) + else: + message = self.make_message_set() + if attr == DeviceAttributes.mode: + if value in MideaFCDevice._modes.values(): + message.mode = list(MideaFCDevice._modes.keys())[ + list(MideaFCDevice._modes.values()).index(value) + ] + elif attr == DeviceAttributes.fan_speed: + if value in MideaFCDevice._speeds.values(): + message.fan_speed = list(MideaFCDevice._speeds.keys())[ + list(MideaFCDevice._speeds.values()).index(value) + ] + elif attr == DeviceAttributes.screen_display: + if value in MideaFCDevice._screen_displays.values(): + message.screen_display = list( + MideaFCDevice._screen_displays.keys() + )[list(MideaFCDevice._screen_displays.values()).index(value)] + elif not value: + message.screen_display = 7 + elif attr == DeviceAttributes.detect_mode: + if value in MideaFCDevice._detect_modes: + message.detect_mode = MideaFCDevice._detect_modes.index(value) + elif not value: + message.detect_mode = 0 + else: + setattr(message, str(attr), value) + self.build_send(message) + + def set_customize(self, customize): + self._standby_detect = self._standby_detect_default + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "standby_detect" in params: + settings = params.get("standby_detect") + if len(settings) == 2 and settings[0] > settings[1]: + self._standby_detect = settings + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"standby_detect": self._standby_detect}) + + +class MideaAppliance(MideaFCDevice): + pass diff --git a/src/devices/fc/message.py b/src/devices/fc/message.py new file mode 100644 index 00000000..61023413 --- /dev/null +++ b/src/devices/fc/message.py @@ -0,0 +1,214 @@ +from ...crc8 import calculate +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageFCBase(MessageRequest): + _message_serial = 0 + + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xFC, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + MessageFCBase._message_serial += 1 + if MessageFCBase._message_serial >= 254: + MessageFCBase._message_serial = 1 + self._message_id = MessageFCBase._message_serial + + @property + def _body(self): + raise NotImplementedError + + @property + def body(self): + body = bytearray([self.body_type]) + self._body + bytearray([self._message_id]) + body.append(calculate(body)) + return body + + +class MessageQuery(MessageFCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x41, + ) + + @property + def _body(self): + return bytearray( + [ + 0x00, + 0x00, + 0xFF, + 0x03, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessageSet(MessageFCBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x48, + ) + self.power = False + self.mode = 0 + self.fan_speed = 0 + self.child_lock = False + self.prompt_tone = False + self.anion = False + self.standby = False + self.screen_display = 0 + self.detect_mode = 0 + self.standby_detect = [40, 20] + + @property + def _body(self): + # byte1 power + power = 0x01 if self.power else 0x00 + detect = 0x08 if self.detect_mode > 0 else 0x00 + detect_mode = (self.detect_mode - 1) if self.detect_mode > 0 else 0 + # byte2 mode + # byte3 fan_speed + # byte 8 child_lock + child_lock = 0x80 if self.child_lock else 0x00 + # byte 9 anion + anion = 0x20 if self.anion else 0x00 + # byte 10 prompt_tone + prompt_tone = 0x40 if self.prompt_tone else 0x00 + # byte 15/16/17 standby + if self.standby: + standby = 0x04 + standby_detect_high = self.standby_detect[0] + standby_detect_low = self.standby_detect[1] + else: + standby = 0x08 + standby_detect_high = 0 + standby_detect_low = 0 + return bytearray( + [ + power | prompt_tone | detect | 0x02, + self.mode, + self.fan_speed, + 0x00, + 0x00, + 0x00, + 0x00, + child_lock, + self.screen_display, + anion, + 0x00, + 0x00, + 0x00, + detect_mode, + standby, + standby_detect_high, + standby_detect_low, + 0x00, + 0x00, + 0x00, + ] + ) + + +class FCGeneralMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x01) > 0 + self.mode = body[2] & 0xF0 + self.fan_speed = body[3] & 0x7F + self.screen_display = body[9] & 0x07 + if len(body) > 14 and body[14] != 0xFF: + self.pm25 = body[13] + (body[14] << 8) + else: + self.pm25 = None + if len(body) > 15 and body[15] != 0xFF: + self.tvoc = body[15] + else: + self.tvoc = None + self.anion = (body[19] & 0x40 > 0) if len(body) > 19 else False + self.standby = ((body[34] & 0xFF) == 0x14) if len(body) > 34 else False + self.child_lock = (body[8] & 0x80 > 0) if len(body) > 8 else False + if len(body) > 23: + self.filter1_life = body[23] + if len(body) > 24: + self.filter2_life = body[24] + if len(body) > 29: + if (body[1] & 0x08) > 0: + self.detect_mode = body[29] + 1 + else: + self.detect_mode = 0 + if len(body) > 38 and body[38] != 0xFF: + self.hcho = body[37] + (body[38] << 8) + else: + self.hcho = None + + +class FCNotifyMessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x01) > 0 + self.mode = body[2] & 0xF0 + self.fan_speed = body[3] & 0x7F + self.screen_display = body[9] & 0x07 + if len(body) > 14 and body[14] != 0xFF: + self.pm25 = body[13] + (body[14] << 8) + else: + self.pm25 = None + if len(body) > 15 and body[15] != 0xFF: + self.tvoc = body[15] + else: + self.tvoc = None + self.anion = (body[10] & 0x20 > 0) if len(body) > 10 else False + self.standby = (body[27] & 0x14 == 0xFF) if len(body) > 27 else False + self.child_lock = (body[10] & 0x10 > 0) if len(body) > 10 else False + if len(body) > 22: + if (body[1] & 0x08) > 0: + self.detect_mode = body[22] + 1 + else: + self.detect_mode = 0 + if len(body) > 31 and body[31] != 0xFF: + self.hcho = body[30] + (body[31] << 8) + else: + self.hcho = None + + +class MessageFCResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.body_type in [0xB0, 0xB1]: + pass + else: + if ( + self.message_type + in [MessageType.query, MessageType.set, MessageType.notify1] + and self.body_type == 0xC8 + ): + self.set_body(FCGeneralMessageBody(super().body)) + elif self.message_type == MessageType.notify1 and self.body_type == 0xA0: + self.set_body(FCNotifyMessageBody(super().body)) + self.set_attr() diff --git a/src/devices/fd/device.py b/src/devices/fd/device.py new file mode 100644 index 00000000..58c5c628 --- /dev/null +++ b/src/devices/fd/device.py @@ -0,0 +1,204 @@ +import logging +from .message import MessageQuery, MessageFDResponse, MessageSet + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + fan_speed = "fan_speed" + prompt_tone = "prompt_tone" + target_humidity = "target_humidity" + current_humidity = "current_humidity" + current_temperature = "current_temperature" + tank = "tank" + mode = "mode" + screen_display = "screen_display" + disinfect = "disinfect" + + +class MideaFDDevice(MideaDevice): + _modes = [ + "Manual", + "Auto", + "Continuous", + "Living-Room", + "Bed-Room", + "Kitchen", + "Sleep", + ] + _speeds_old = { + 1: "Lowest", + 40: "Low", + 60: "Medium", + 80: "High", + 102: "Auto", + 127: "Off", + } + _speeds_new = { + 1: "Lowest", + 39: "Low", + 59: "Medium", + 80: "High", + 101: "Auto", + 127: "Off", + } + _screen_displays = {0: "Bright", 6: "Dim", 7: "Off"} + _detect_modes = ["Off", "PM 2.5", "Methanal"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0xFD, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.fan_speed: None, + DeviceAttributes.prompt_tone: True, + DeviceAttributes.target_humidity: 60, + DeviceAttributes.current_humidity: None, + DeviceAttributes.current_temperature: None, + DeviceAttributes.tank: 0, + DeviceAttributes.mode: None, + DeviceAttributes.screen_display: None, + DeviceAttributes.disinfect: None, + }, + ) + if self.subtype > 5: + self._speeds = MideaFDDevice._speeds_new + else: + self._speeds = MideaFDDevice._speeds_old + + @property + def modes(self): + return list(MideaFDDevice._modes) + + @property + def fan_speeds(self): + return list(self._speeds.values()) + + @property + def screen_displays(self): + return list(MideaFDDevice._screen_displays.values()) + + @property + def detect_modes(self): + return self._detect_modes + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = MessageFDResponse(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.mode: + if value <= len(MideaFDDevice._modes): + self._attributes[status] = MideaFDDevice._modes[value - 1] + else: + self._attributes[status] = None + elif status == DeviceAttributes.fan_speed: + if value in self._speeds.keys(): + self._attributes[status] = self._speeds.get(value) + else: + self._attributes[status] = None + elif status == DeviceAttributes.screen_display: + if value in MideaFDDevice._screen_displays.keys(): + self._attributes[status] = MideaFDDevice._screen_displays.get( + value + ) + else: + self._attributes[status] = None + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def make_message_set(self): + message = MessageSet(self._protocol_version) + message.power = self._attributes[DeviceAttributes.power] + message.prompt_tone = self._attributes[DeviceAttributes.prompt_tone] + message.screen_display = self._attributes[DeviceAttributes.screen_display] + message.disinfect = self._attributes[DeviceAttributes.disinfect] + if self._attributes[DeviceAttributes.mode] in MideaFDDevice._modes: + message.mode = ( + MideaFDDevice._modes.index(self._attributes[DeviceAttributes.mode]) + 1 + ) + else: + message.mode = 1 + message.fan_speed = ( + 40 + if self._attributes[DeviceAttributes.fan_speed] is None + else list(self._speeds.keys())[ + list(self._speeds.values()).index( + self._attributes[DeviceAttributes.fan_speed] + ) + ] + ) + message.screen_display = ( + 0 + if self._attributes[DeviceAttributes.screen_display] is None + else list(MideaFDDevice._screen_displays.keys())[ + list(MideaFDDevice._screen_displays.values()).index( + self._attributes[DeviceAttributes.screen_display] + ) + ] + ) + return message + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.prompt_tone: + self._attributes[DeviceAttributes.prompt_tone] = value + self.update_all({DeviceAttributes.prompt_tone.value: value}) + else: + message = self.make_message_set() + if attr == DeviceAttributes.mode: + if value in MideaFDDevice._modes: + message.mode = MideaFDDevice._modes.index(value) + 1 + elif attr == DeviceAttributes.fan_speed: + if value in self._speeds.values(): + message.fan_speed = list(self._speeds.keys())[ + list(self._speeds.values()).index(value) + ] + elif attr == DeviceAttributes.screen_display: + if value in MideaFDDevice._screen_displays.values(): + message.screen_display = list( + MideaFDDevice._screen_displays.keys() + )[list(MideaFDDevice._screen_displays.values()).index(value)] + elif not value: + message.screen_display = 7 + else: + setattr(message, str(attr), value) + self.build_send(message) + + +class MideaAppliance(MideaFDDevice): + pass diff --git a/src/devices/fd/message.py b/src/devices/fd/message.py new file mode 100644 index 00000000..7755db74 --- /dev/null +++ b/src/devices/fd/message.py @@ -0,0 +1,176 @@ +from ...crc8 import calculate +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class MessageFDBase(MessageRequest): + _message_serial = 0 + + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0xFD, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + MessageFDBase._message_serial += 1 + if MessageFDBase._message_serial >= 254: + MessageFDBase._message_serial = 1 + self._message_id = MessageFDBase._message_serial + + @property + def _body(self): + raise NotImplementedError + + @property + def body(self): + body = bytearray([self.body_type]) + self._body + bytearray([self._message_id]) + body.append(calculate(body)) + return body + + +class MessageQuery(MessageFDBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x41, + ) + + @property + def _body(self): + return bytearray( + [ + 0x81, + 0x00, + 0xFF, + 0x03, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class MessageSet(MessageFDBase): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x48, + ) + self.power = False + self.fan_speed = 0 + self.target_humidity = 50 + self.prompt_tone = False + self.screen_display = 0x07 + self.mode = 0x01 + self.disinfect = None + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + prompt_tone = 0x40 if self.prompt_tone else 0x00 + disinfect = 0 if self.disinfect is None else (1 if self.disinfect else 2) + return bytearray( + [ + power | prompt_tone | 0x02, + 0x00, + self.fan_speed, + 0x00, + 0x00, + 0x00, + self.target_humidity, + 0x00, + self.screen_display, + self.mode, + 0x00, + 0x00, + 0x00, + 0x00, + disinfect, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + + +class FDC8MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x01) > 0 + self.fan_speed = body[3] & 0x7F + self.target_humidity = body[7] + self.current_humidity = body[16] + self.current_temperature = (body[17] - 50) / 2 + self.tank = body[10] + self.mode = (body[8] & 0x70) >> 4 + self.screen_display = body[9] & 0x07 + if len(body) > 36: + disinfect = body[34] & 0x03 + if disinfect == 1: + self.disinfect = True + elif disinfect == 2: + self.disinfect = False + + +class FDA0MessageBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = (body[1] & 0x01) > 0 + self.fan_speed = body[3] & 0x7F + self.target_humidity = body[7] + self.current_humidity = body[16] + self.current_temperature = (body[17] - 50) / 2 + self.tank = body[10] + self.mode = body[10] & 0x07 + self.screen_display = body[9] & 0x07 + if len(body) > 29: + disinfect = body[27] & 0x03 + if disinfect == 1: + self.disinfect = True + elif disinfect == 2: + self.disinfect = False + + +class MessageFDResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.message_type in [ + MessageType.query, + MessageType.set, + MessageType.notify1, + ]: + if self.body_type in [0xB0, 0xB1]: + pass + elif self.body_type == 0xA0: + self.set_body(FDA0MessageBody(super().body)) + elif self.body_type == 0xC8: + self.set_body(FDC8MessageBody(super().body)) + self.set_attr() + if ( + hasattr(self, "fan_speed") + and self.fan_speed is not None + and self.fan_speed < 5 + ): + self.fan_speed = 1 diff --git a/src/devices/x13/device.py b/src/devices/x13/device.py new file mode 100644 index 00000000..84f17ff8 --- /dev/null +++ b/src/devices/x13/device.py @@ -0,0 +1,135 @@ +import logging +import json +from .message import MessageQuery, MessageSet, Message13Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + brightness = "brightness" + color_temperature = "color_temperature" + rgb_color = "rgb_color" + effect = "effect" + power = "power" + + +class Midea13Device(MideaDevice): + _effects = ["Manual", "Living", "Reading", "Mildly", "Cinema", "Night"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0x13, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.brightness: None, + DeviceAttributes.color_temperature: None, + DeviceAttributes.rgb_color: None, + DeviceAttributes.effect: None, + DeviceAttributes.power: False, + }, + ) + self._color_temp_range = None + self._default_color_temp_range = [2700, 6500] + self.set_customize(customize) + + @property + def effects(self): + return Midea13Device._effects + + @property + def color_temp_range(self): + return self._color_temp_range + + def kelvin_to_midea(self, kelvin): + return round( + (kelvin - self._color_temp_range[0]) + / (self._color_temp_range[1] - self._color_temp_range[0]) + * 255 + ) + + def midea_to_kelvin(self, midea): + return ( + round((self._color_temp_range[1] - self._color_temp_range[0]) / 255 * midea) + + self._color_temp_range[0] + ) + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = Message13Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + if hasattr(message, "control_success"): + new_status = {"control_success", message.control_success} + if message.control_success: + self.refresh_status() + else: + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.effect: + self._attributes[status] = Midea13Device._effects[value] + elif status == DeviceAttributes.color_temperature: + self._attributes[status] = self.midea_to_kelvin(value) + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr in [ + DeviceAttributes.brightness, + DeviceAttributes.color_temperature, + DeviceAttributes.effect, + DeviceAttributes.power, + ]: + message = MessageSet(self._protocol_version) + if attr == DeviceAttributes.effect and value in self._effects: + setattr(message, str(attr), Midea13Device._effects.index(value)) + elif attr == DeviceAttributes.color_temperature: + setattr(message, str(attr), self.kelvin_to_midea(value)) + else: + setattr(message, str(attr), value) + self.build_send(message) + + def set_customize(self, customize): + self._color_temp_range = self._default_color_temp_range + if customize and len(customize) > 0: + try: + params = json.loads(customize) + if params and "color_temp_range_kelvin" in params: + self._color_temp_range = params.get("color_temp_range_kelvin") + except Exception as e: + _LOGGER.error(f"[{self.device_id}] Set customize error: {repr(e)}") + self.update_all({"color_temp_range": self._color_temp_range}) + + +class MideaAppliance(Midea13Device): + pass diff --git a/src/devices/x13/message.py b/src/devices/x13/message.py new file mode 100644 index 00000000..c790ee2c --- /dev/null +++ b/src/devices/x13/message.py @@ -0,0 +1,90 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class Message13Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0x13, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(Message13Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x24, + ) + + @property + def _body(self): + return bytearray([0x00, 0x00, 0x00, 0x00]) + + +class MessageSet(Message13Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x00, + ) + self.brightness = None + self.color_temperature = None + self.effect = None + self.power = None + + @property + def _body(self): + body_byte = 0x00 + if self.power is not None: + self.body_type = 0x01 + body_byte = 0x01 if self.power else 0x00 + elif self.effect is not None and self.effect in range(1, 6): + self.body_type = 0x02 + body_byte = self.effect + 1 + elif self.color_temperature is not None: + self.body_type = 0x03 + body_byte = self.color_temperature + elif self.brightness is not None: + self.body_type = 0x04 + body_byte = self.brightness + return bytearray([body_byte, 0x00, 0x00, 0x00]) + + +class MessageMainLightBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.brightness = self.read_byte(body, 1) + self.color_temperature = self.read_byte(body, 2) + self.effect = self.read_byte(body, 3) - 1 + if self.effect > 5: + self.effect = 1 + """ + self.rgb_color = [self.read_byte(body, 5), + self.read_byte(body, 6), + self.read_byte(body, 7)] + """ + self.power = self.read_byte(body, 8) > 0 + + +class MessageMainLightResponseBody(MessageBody): + def __init__(self, body): + super().__init__(body) + self.control_success = body[1] > 0 + + +class Message13Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if self.body_type == 0xA4: + self.set_body(MessageMainLightBody(super().body)) + elif self.message_type == MessageType.set and self.body_type > 0x80: + self.set_body(MessageMainLightResponseBody(super().body)) + self.set_attr() diff --git a/src/devices/x26/device.py b/src/devices/x26/device.py new file mode 100644 index 00000000..b277d9d7 --- /dev/null +++ b/src/devices/x26/device.py @@ -0,0 +1,143 @@ +import logging +import math +from .message import MessageQuery, MessageSet, Message26Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + main_light = "main_light" + night_light = "night_light" + mode = "mode" + direction = "direction" + current_humidity = "current_humidity" + current_radar = "current_radar" + current_temperature = "current_temperature" + + +class Midea26Device(MideaDevice): + _modes = ["Off", "Heat(high)", "Heat(low)", "Bath", "Blow", "Ventilation", "Dry"] + _directions = ["60", "70", "80", "90", "100", "110", "120", "Oscillate"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0x26, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.main_light: False, + DeviceAttributes.night_light: False, + DeviceAttributes.mode: None, + DeviceAttributes.direction: None, + DeviceAttributes.current_humidity: None, + DeviceAttributes.current_radar: None, + DeviceAttributes.current_temperature: None, + }, + ) + self._fields = {} + + @staticmethod + def _convert_to_midea_direction(direction): + if direction == "Oscillate": + result = 0xFD + else: + result = ( + Midea26Device._directions.index(direction) * 10 + 60 + if direction in Midea26Device._directions + else 0xFD + ) + return result + + @staticmethod + def _convert_from_midea_direction(direction): + if direction > 120 or direction < 60: + result = 7 + else: + result = math.floor((direction - 60 + 5) / 10) + return result + + @property + def preset_modes(self): + return Midea26Device._modes + + @property + def directions(self): + return Midea26Device._directions + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = Message26Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + self._fields = getattr(message, "fields") + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.mode: + self._attributes[status] = Midea26Device._modes[value] + elif status == DeviceAttributes.direction: + self._attributes[status] = Midea26Device._directions[ + self._convert_from_midea_direction(value) + ] + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr in [ + DeviceAttributes.main_light, + DeviceAttributes.night_light, + DeviceAttributes.mode, + DeviceAttributes.direction, + ]: + message = MessageSet(self._protocol_version) + message.fields = self._fields + message.main_light = self._attributes[DeviceAttributes.main_light] + message.night_light = self._attributes[DeviceAttributes.night_light] + message.mode = Midea26Device._modes.index( + self._attributes[DeviceAttributes.mode] + ) + message.direction = self._convert_to_midea_direction( + self._attributes[DeviceAttributes.direction] + ) + if attr in [DeviceAttributes.main_light, DeviceAttributes.night_light]: + message.main_light = False + message.night_light = False + setattr(message, str(attr), value) + elif attr == DeviceAttributes.mode: + message.mode = Midea26Device._modes.index(value) + elif attr == DeviceAttributes.direction: + message.direction = self._convert_to_midea_direction(value) + self.build_send(message) + + +class MideaAppliance(Midea26Device): + pass diff --git a/src/devices/x26/message.py b/src/devices/x26/message.py new file mode 100644 index 00000000..f8bf0f5c --- /dev/null +++ b/src/devices/x26/message.py @@ -0,0 +1,181 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class Message26Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0x26, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(Message26Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(Message26Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x01, + ) + self.fields = {} + self.main_light = False + self.night_light = False + self.mode = 0 + self.direction = 0xFD + + def read_field(self, field): + value = self.fields.get(field, 0) + return value if value else 0 + + @property + def _body(self): + return bytearray( + [ + 1 if self.main_light else 0, + self.read_field("MAIN_LIGHT_BRIGHTNESS"), + 1 if self.night_light else 0, + self.read_field("NIGHT_LIGHT_BRIGHTNESS"), + self.read_field("RADAR_INDUCTION_ENABLE"), + self.read_field("RADAR_INDUCTION_CLOSING_TIME"), + self.read_field("LIGHT_INTENSITY_THRESHOLD"), + self.read_field("RADAR_SENSITIVITY"), + 1 if self.mode == 1 or self.mode == 2 else 0, + ( + 0 + if not (self.mode == 1 or self.mode == 2) + else 55 + if self.mode == 1 + else 30 + ), + self.read_field("HEATING_SPEED"), + self.direction, + 1 if self.mode == 3 else 0, + self.read_field("BATH_HEATING_TIME"), + self.read_field("BATH_TEMPERATURE"), + self.read_field("BATH_SPEED"), + self.direction, + 1 if self.mode == 5 else 0, + self.read_field("VENTILATION_SPEED"), + self.direction, + 1 if self.mode == 6 else 0, + self.read_field("DRYING_TIME"), + self.read_field("DRYING_TEMPERATURE"), + self.read_field("DRYING_SPEED"), + self.direction, + 1 if self.mode == 4 else 0, + self.read_field("BLOWING_SPEED"), + self.direction, + self.read_field("DELAY_ENABLE"), + self.read_field("DELAY_TIME"), + self.read_field("SOFT_WIND_ENABLE"), + self.read_field("SOFT_WIND_TIME"), + self.read_field("SOFT_WIND_TEMPERATURE"), + self.read_field("SOFT_WIND_SPEED"), + self.read_field("SOFT_WIND_DIRECTION"), + self.read_field("WINDLESS_ENABLE"), + self.read_field("ANION_ENABLE"), + self.read_field("SMELLY_ENABLE"), + self.read_field("SMELLY_THRESHOLD"), + ] + ) + + +class Message26Body(MessageBody): + def __init__(self, body): + super().__init__(body) + self.fields = {} + self.main_light = self.read_byte(body, 1) > 0 + self.fields["MAIN_LIGHT_BRIGHTNESS"] = self.read_byte(body, 2) + self.night_light = self.read_byte(body, 3) > 0 + self.fields["NIGHT_LIGHT_BRIGHTNESS"] = self.read_byte(body, 4) + self.fields["RADAR_INDUCTION_ENABLE"] = self.read_byte(body, 5) + self.fields["RADAR_INDUCTION_CLOSING_TIME"] = self.read_byte(body, 6) + self.fields["LIGHT_INTENSITY_THRESHOLD"] = self.read_byte(body, 7) + self.fields["RADAR_SENSITIVITY"] = self.read_byte(body, 8) + heat_mode = self.read_byte(body, 9) > 0 + heat_temperature = self.read_byte(body, 10) + self.fields["HEATING_SPEED"] = self.read_byte(body, 11) + heat_direction = self.read_byte(body, 12) + bath_mode = self.read_byte(body, 13) > 0 + self.fields["BATH_HEATING_TIME"] = self.read_byte(body, 14) + self.fields["BATH_TEMPERATURE"] = self.read_byte(body, 15) + self.fields["BATH_SPEED"] = self.read_byte(body, 16) + bath_direction = self.read_byte(body, 17) + ventilation_mode = self.read_byte(body, 18) > 0 + self.fields["VENTILATION_SPEED"] = self.read_byte(body, 19) + ventilation_direction = self.read_byte(body, 20) + dry_mode = self.read_byte(body, 21) > 0 + self.fields["DRYING_TIME"] = self.read_byte(body, 22) + self.fields["DRYING_TEMPERATURE"] = self.read_byte(body, 23) + self.fields["DRYING_SPEED"] = self.read_byte(body, 24) + dry_direction = self.read_byte(body, 25) + blow_mode = self.read_byte(body, 26) > 0 + self.fields["BLOWING_SPEED"] = self.read_byte(body, 27) + blow_direction = self.read_byte(body, 28) + self.fields["DELAY_ENABLE"] = self.read_byte(body, 29) + self.fields["DELAY_TIME"] = self.read_byte(body, 30) + if self.read_byte(body, 31) != 0xFF: + self.current_humidity = self.read_byte(body, 31) + if self.read_byte(body, 32) != 0xFF: + self.current_radar = self.read_byte(body, 32) + if self.read_byte(body, 33) != 0xFF: + self.current_temperature = self.read_byte(body, 33) + self.fields["SOFT_WIND_ENABLE"] = self.read_byte(body, 38) + self.fields["SOFT_WIND_TIME"] = self.read_byte(body, 39) + self.fields["SOFT_WIND_TEMPERATURE"] = self.read_byte(body, 40) + self.fields["SOFT_WIND_SPEED"] = self.read_byte(body, 41) + self.fields["SOFT_WIND_DIRECTION"] = self.read_byte(body, 42) + self.fields["WINDLESS_ENABLE"] = self.read_byte(body, 43) + self.fields["ANION_ENABLE"] = self.read_byte(body, 44) + self.fields["SMELLY_ENABLE"] = self.read_byte(body, 45) + self.fields["SMELLY_THRESHOLD"] = self.read_byte(body, 46) + self.mode = 0 + self.direction = 0xFD + if heat_mode: + if heat_temperature > 50: + self.mode = 1 + else: + self.mode = 2 + self.direction = heat_direction + elif bath_mode: + self.mode = 3 + self.direction = bath_direction + elif blow_mode: + self.mode = 4 + self.direction = blow_direction + elif ventilation_mode: + self.mode = 5 + self.direction = ventilation_direction + elif dry_mode: + self.mode = 6 + self.direction = dry_direction + + +class Message26Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type + in [MessageType.set, MessageType.notify1, MessageType.query] + and self.body_type == 0x01 + ): + self.set_body(Message26Body(super().body)) + self.set_attr() diff --git a/src/devices/x34/device.py b/src/devices/x34/device.py new file mode 100644 index 00000000..9708c4ab --- /dev/null +++ b/src/devices/x34/device.py @@ -0,0 +1,170 @@ +import logging +from .message import ( + MessageQuery, + MessagePower, + MessageStorage, + MessageLock, + Message34Response, +) + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + power = "power" + status = "status" + mode = "mode" + additional = "additional" + door = "door" + rinse_aid = "rinse_aid" + salt = "salt" + child_lock = "child_lock" + uv = "uv" + dry = "dry" + dry_status = "dry_status" + storage = "storage" + storage_status = "storage_status" + time_remaining = "time_remaining" + progress = "progress" + storage_remaining = "storage_remaining" + temperature = "temperature" + humidity = "humidity" + waterswitch = "waterswitch" + water_lack = "water_lack" + error_code = "error_code" + softwater = "softwater" + wrong_operation = "wrong_operation" + bright = "bright" + + +class Midea34Device(MideaDevice): + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0x34, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.power: False, + DeviceAttributes.status: None, + DeviceAttributes.mode: 0, + DeviceAttributes.additional: 0, + DeviceAttributes.uv: False, + DeviceAttributes.dry: False, + DeviceAttributes.dry_status: False, + DeviceAttributes.door: False, + DeviceAttributes.rinse_aid: False, + DeviceAttributes.salt: False, + DeviceAttributes.child_lock: False, + DeviceAttributes.storage: False, + DeviceAttributes.storage_status: False, + DeviceAttributes.time_remaining: None, + DeviceAttributes.progress: None, + DeviceAttributes.storage_remaining: None, + DeviceAttributes.temperature: None, + DeviceAttributes.humidity: None, + DeviceAttributes.waterswitch: False, + DeviceAttributes.water_lack: False, + DeviceAttributes.error_code: None, + DeviceAttributes.softwater: 0, + DeviceAttributes.wrong_operation: None, + DeviceAttributes.bright: 0, + }, + ) + self._modes = { + 0x0: "Neutral Gear", # BYTE_MODE_NEUTRAL_GEAR + 0x1: "Auto", # BYTE_MODE_AUTO_WASH + 0x2: "Heavy", # BYTE_MODE_STRONG_WASH + 0x3: "Normal", # BYTE_MODE_STANDARD_WASH + 0x4: "Energy Saving", # BYTE_MODE_ECO_WASH + 0x5: "Delicate", # BYTE_MODE_GLASS_WASH + 0x6: "Hour", # BYTE_MODE_HOUR_WASH + 0x7: "Quick", # BYTE_MODE_FAST_WASH + 0x8: "Rinse", # BYTE_MODE_SOAK_WASH + 0x9: "90min", # BYTE_MODE_90MIN_WASH + 0xA: "Self Clean", # BYTE_MODE_SELF_CLEAN + 0xB: "Fruit Wash", # BYTE_MODE_FRUIT_WASH + 0xC: "Self Define", # BYTE_MODE_SELF_DEFINE + 0xD: "Germ", # BYTE_MODE_GERM ??? + 0xE: "Bowl Wash", # BYTE_MODE_BOWL_WASH + 0xF: "Kill Germ", # BYTE_MODE_KILL_GERM + 0x10: "Sea Food Wash", # BYTE_MODE_SEA_FOOD_WASH + 0x12: "Hot Pot Wash", # BYTE_MODE_HOT_POT_WASH + 0x13: "Quiet", # BYTE_MODE_QUIET_NIGHT_WASH + 0x14: "Less Wash", # BYTE_MODE_LESS_WASH + 0x16: "Oil Net Wash", # BYTE_MODE_OIL_NET_WASH + 0x19: "Cloud Wash", # BYTE_MODE_CLOUD_WASH + } + self._status = ["Off", "Idle", "Delay", "Running", "Error"] + self._progress = ["Idle", "Pre-wash", "Wash", "Rinse", "Dry", "Complete"] + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = Message34Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + for status in self._attributes.keys(): + if hasattr(message, str(status)): + if status == DeviceAttributes.status: + v = getattr(message, str(status)) + if v < len(self._status): + self._attributes[status] = self._status[v] + else: + self._attributes[status] = None + elif status == DeviceAttributes.progress: + v = getattr(message, str(status)) + if v < len(self._progress): + self._attributes[status] = self._progress[v] + else: + self._attributes[status] = None + elif status == DeviceAttributes.mode: + v = getattr(message, str(status)) + self._attributes[status] = self._modes[v] + else: + self._attributes[status] = getattr(message, str(status)) + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr == DeviceAttributes.power: + message = MessagePower(self._protocol_version) + message.power = value + self.build_send(message) + elif attr == DeviceAttributes.child_lock: + message = MessageLock(self._protocol_version) + message.lock = value + self.build_send(message) + elif attr == DeviceAttributes.storage: + message = MessageStorage(self._protocol_version) + message.storage = value + self.build_send(message) + + +class MideaAppliance(Midea34Device): + pass diff --git a/src/devices/x34/message.py b/src/devices/x34/message.py new file mode 100644 index 00000000..07f56634 --- /dev/null +++ b/src/devices/x34/message.py @@ -0,0 +1,127 @@ +from ...message import ( + MessageType, + MessageRequest, + MessageResponse, + MessageBody, +) + + +class Message34Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0x34, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(Message34Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x00, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessagePower(Message34Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x08, + ) + self.power = False + + @property + def _body(self): + power = 0x01 if self.power else 0x00 + return bytearray([power, 0x00, 0x00, 0x00]) + + +class MessageLock(Message34Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x83, + ) + self.lock = False + + @property + def _body(self): + lock = 0x03 if self.lock else 0x04 + return bytearray([lock]) + bytearray([0x00] * 36) + + +class MessageStorage(Message34Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x81, + ) + self.storage = False + + @property + def _body(self): + storage = 0x01 if self.storage else 0x00 + return ( + bytearray([0x00, 0x00, 0x00, storage]) + + bytearray([0xFF] * 6) + + bytearray([0x00] * 27) + ) + + +class Message34Body(MessageBody): + def __init__(self, body): + super().__init__(body) + self.power = body[1] > 0 + self.status = body[1] + self.mode = body[2] + self.additional = body[3] + self.door = (body[5] & 0x01) == 0 # 0 - open, 1 - close + self.rinse_aid = (body[5] & 0x02) > 0 # 0 - enough, 1 - shortage + self.salt = (body[5] & 0x04) > 0 # 0 - enough, 1 - shortage + start_pause = (body[5] & 0x08) > 0 + if start_pause: + self.start = True + elif self.status in [2, 3]: + self.start = False + self.child_lock = (body[5] & 0x10) > 0 + self.uv = (body[4] & 0x2) > 0 + self.dry = (body[4] & 0x10) > 0 + self.dry_status = (body[4] & 0x20) > 0 + self.storage = (body[5] & 0x20) > 0 + self.storage_status = (body[5] & 0x40) > 0 + self.time_remaining = body[6] + self.progress = body[9] + self.storage_remaining = body[18] if len(body) > 18 else False + self.temperature = body[11] + self.humidity = body[33] if len(body) > 33 else None + self.waterswitch = (body[4] & 0x4) > 0 + self.water_lack = (body[5] & 0x80) > 0 + self.error_code = body[10] + self.softwater = body[13] + self.wrong_operation = body[16] + self.bright = body[24] + + +class Message34Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if (self.message_type == MessageType.set and 0 <= self.body_type <= 7) or ( + self.message_type in [MessageType.query, MessageType.notify1] + and self.body_type == 0 + ): + self.set_body(Message34Body(super().body)) + self.set_attr() diff --git a/src/devices/x40/device.py b/src/devices/x40/device.py new file mode 100644 index 00000000..d3845ba4 --- /dev/null +++ b/src/devices/x40/device.py @@ -0,0 +1,133 @@ +import logging +import math +from .message import MessageQuery, MessageSet, Message40Response + +try: + from enum import StrEnum +except ImportError: + from ...backports.enum import StrEnum +from ...device import MideaDevice + +_LOGGER = logging.getLogger(__name__) + + +class DeviceAttributes(StrEnum): + light = "light" + fan_speed = "fan_speed" + direction = "direction" + ventilation = "ventilation" + smelly_sensor = "smelly_sensor" + current_temperature = "current_temperature" + + +class Midea40Device(MideaDevice): + _directions = ["60", "70", "80", "90", "100", "Oscillate"] + + def __init__( + self, + name: str, + device_id: int, + ip_address: str, + port: int, + token: str, + key: str, + protocol: int, + model: str, + subtype: int, + customize: str, + ): + super().__init__( + name=name, + device_id=device_id, + device_type=0x40, + ip_address=ip_address, + port=port, + token=token, + key=key, + protocol=protocol, + model=model, + subtype=subtype, + attributes={ + DeviceAttributes.light: False, + DeviceAttributes.fan_speed: 0, + DeviceAttributes.direction: False, + DeviceAttributes.ventilation: False, + DeviceAttributes.smelly_sensor: False, + DeviceAttributes.current_temperature: None, + }, + ) + self._fields = {} + + @property + def directions(self): + return Midea40Device._directions + + @staticmethod + def _convert_to_midea_direction(direction): + if direction == "Oscillate": + result = 0xFD + else: + result = ( + Midea40Device._directions.index(direction) * 10 + 60 + if direction in Midea40Device._directions + else 0xFD + ) + return result + + @staticmethod + def _convert_from_midea_direction(direction): + if direction > 100 or direction < 60: + result = 5 + else: + result = math.floor((direction - 60 + 5) / 10) + return result + + def build_query(self): + return [MessageQuery(self._protocol_version)] + + def process_message(self, msg): + message = Message40Response(msg) + _LOGGER.debug(f"[{self.device_id}] Received: {message}") + new_status = {} + self._fields = getattr(message, "fields") + for status in self._attributes.keys(): + if hasattr(message, str(status)): + value = getattr(message, str(status)) + if status == DeviceAttributes.direction: + self._attributes[status] = Midea40Device._directions[ + self._convert_from_midea_direction(value) + ] + else: + self._attributes[status] = value + new_status[str(status)] = self._attributes[status] + return new_status + + def set_attribute(self, attr, value): + if attr in [ + DeviceAttributes.light, + DeviceAttributes.fan_speed, + DeviceAttributes.direction, + DeviceAttributes.ventilation, + DeviceAttributes.smelly_sensor, + ]: + message = MessageSet(self._protocol_version) + message.fields = self._fields + message.light = self._attributes[DeviceAttributes.light] + message.ventilation = self._attributes[DeviceAttributes.ventilation] + message.smelly_sensor = self._attributes[DeviceAttributes.smelly_sensor] + message.fan_speed = self._attributes[DeviceAttributes.fan_speed] + message.direction = self._convert_to_midea_direction( + self._attributes[DeviceAttributes.direction] + ) + if attr == DeviceAttributes.direction: + message.direction = self._convert_to_midea_direction(value) + elif attr == DeviceAttributes.ventilation and message.fan_speed == 2: + message.fan_speed = 1 + message.ventilation = value + else: + setattr(message, str(attr), value) + self.build_send(message) + + +class MideaAppliance(Midea40Device): + pass diff --git a/src/devices/x40/message.py b/src/devices/x40/message.py new file mode 100644 index 00000000..f88309d3 --- /dev/null +++ b/src/devices/x40/message.py @@ -0,0 +1,164 @@ +from ...message import MessageType, MessageRequest, MessageResponse, MessageBody + + +class Message40Base(MessageRequest): + def __init__(self, protocol_version, message_type, body_type): + super().__init__( + device_type=0x40, + protocol_version=protocol_version, + message_type=message_type, + body_type=body_type, + ) + + @property + def _body(self): + raise NotImplementedError + + +class MessageQuery(Message40Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.query, + body_type=0x01, + ) + + @property + def _body(self): + return bytearray([]) + + +class MessageSet(Message40Base): + def __init__(self, protocol_version): + super().__init__( + protocol_version=protocol_version, + message_type=MessageType.set, + body_type=0x01, + ) + self.fields = {} + self.light = False + self.fan_speed = 0 + self.direction = False + self.ventilation = False + self.smelly_sensor = False + + def read_field(self, field): + value = self.fields.get(field, 0) + return value if value else 0 + + @property + def _body(self): + light = 1 if self.light else 0 + blow = 1 if self.fan_speed > 0 else 0 + fan_speed = 0xFF if self.fan_speed == 0 else 30 if self.fan_speed == 1 else 100 + ventilation = 1 if self.ventilation else 0 + direction = self.direction + smelly_sensor = 1 if self.smelly_sensor else 0 + return bytearray( + [ + light, + self.read_field("MAIN_LIGHT_BRIGHTNESS"), + self.read_field("NIGHT_LIGHT_ENABLE"), + self.read_field("NIGHT_LIGHT_BRIGHTNESS"), + self.read_field("RADAR_INDUCTION_ENABLE"), + self.read_field("RADAR_INDUCTION_CLOSING_TIME"), + self.read_field("LIGHT_INTENSITY_THRESHOLD"), + self.read_field("RADAR_SENSITIVITY"), + self.read_field("HEATING_ENABLE"), + self.read_field("HEATING_TEMPERATURE"), + self.read_field("HEATING_SPEED"), + self.read_field("HEATING_DIRECTION"), + self.read_field("BATH_ENABLE"), + self.read_field("BATH_HEATING_TIME"), + self.read_field("BATH_TEMPERATURE"), + self.read_field("BATH_SPEED"), + self.read_field("BATH_DIRECTION"), + ventilation, + self.read_field("VENTILATION_SPEED"), + self.read_field("VENTILATION_DIRECTION"), + self.read_field("DRYING_ENABLE"), + self.read_field("DRYING_TIME"), + self.read_field("DRYING_TEMPERATURE"), + self.read_field("DRYING_SPEED"), + self.read_field("DRYING_DIRECTION"), + blow, + fan_speed, + direction, + self.read_field("DELAY_ENABLE"), + self.read_field("DELAY_TIME"), + self.read_field("SOFT_WIND_ENABLE"), + self.read_field("SOFT_WIND_TIME"), + self.read_field("SOFT_WIND_TEMPERATURE"), + self.read_field("SOFT_WIND_SPEED"), + self.read_field("SOFT_WIND_DIRECTION"), + self.read_field("WINDLESS_ENABLE"), + self.read_field("ANION_ENABLE"), + smelly_sensor, + self.read_field("SMELLY_THRESHOLD"), + ] + ) + + +class Message40Body(MessageBody): + def __init__(self, body): + super().__init__(body) + self.fields = {} + self.light = body[1] > 0 + self.fields["MAIN_LIGHT_BRIGHTNESS"] = body[2] + self.fields["NIGHT_LIGHT_ENABLE"] = body[3] + self.fields["NIGHT_LIGHT_BRIGHTNESS"] = body[4] + self.fields["RADAR_INDUCTION_ENABLE"] = body[5] + self.fields["RADAR_INDUCTION_CLOSING_TIME"] = body[6] + self.fields["LIGHT_INTENSITY_THRESHOLD"] = body[7] + self.fields["RADAR_SENSITIVITY"] = body[8] + self.fields["HEATING_ENABLE"] = body[9] + self.fields["HEATING_TEMPERATURE"] = body[10] + self.fields["HEATING_SPEED"] = body[11] + self.fields["HEATING_DIRECTION"] = body[12] + self.fields["BATH_ENABLE"] = body[13] > 0 + self.fields["BATH_HEATING_TIME"] = body[14] + self.fields["BATH_TEMPERATURE"] = body[15] + self.fields["BATH_SPEED"] = body[16] + self.fields["BATH_DIRECTION"] = body[17] + self.ventilation = body[18] > 0 + self.fields["VENTILATION_SPEED"] = body[19] + self.fields["VENTILATION_DIRECTION"] = body[20] + self.fields["DRYING_ENABLE"] = body[21] > 0 + self.fields["DRYING_TIME"] = body[22] + self.fields["DRYING_TEMPERATURE"] = body[23] + self.fields["DRYING_SPEED"] = body[24] + self.fields["DRYING_DIRECTION"] = body[25] + blow = body[26] > 0 + blow_speed = body[27] + self.direction = body[28] + self.fields["DELAY_ENABLE"] = body[29] + self.fields["DELAY_TIME"] = body[30] + self.current_temperature = body[33] + self.fields["SOFT_WIND_ENABLE"] = body[38] + self.fields["SOFT_WIND_TIME"] = body[39] + self.fields["SOFT_WIND_TEMPERATURE"] = body[40] + self.fields["SOFT_WIND_SPEED"] = body[41] + self.fields["SOFT_WIND_DIRECTION"] = body[42] + self.fields["WINDLESS_ENABLE"] = body[43] + self.fields["ANION_ENABLE"] = body[44] + self.smelly_sensor = body[45] + self.fields["SMELLY_THRESHOLD"] = body[46] + if blow: + if blow_speed <= 30: + self.fan_speed = 1 + else: + self.fan_speed = 2 + else: + self.fan_speed = 0 + + +class Message40Response(MessageResponse): + def __init__(self, message): + super().__init__(message) + if ( + self.message_type + in [MessageType.set, MessageType.notify1, MessageType.query] + and self.body_type == 0x01 + ): + self.set_body(Message40Body(super().body)) + self.set_attr() diff --git a/src/discover.py b/src/discover.py new file mode 100644 index 00000000..73cd7332 --- /dev/null +++ b/src/discover.py @@ -0,0 +1,304 @@ +import logging +import socket +import xml.etree.ElementTree as ET +from ipaddress import IPv4Network + +import ifaddr + +from .security import LocalSecurity + +_LOGGER = logging.getLogger(__name__) + +BROADCAST_MSG = bytearray( + [ + 0x5A, + 0x5A, + 0x01, + 0x11, + 0x48, + 0x00, + 0x92, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x7F, + 0x75, + 0xBD, + 0x6B, + 0x3E, + 0x4F, + 0x8B, + 0x76, + 0x2E, + 0x84, + 0x9C, + 0x6E, + 0x57, + 0x8D, + 0x65, + 0x90, + 0x03, + 0x6E, + 0x9D, + 0x43, + 0x42, + 0xA5, + 0x0F, + 0x1F, + 0x56, + 0x9E, + 0xB8, + 0xEC, + 0x91, + 0x8E, + 0x92, + 0xE5, + ] +) + +DEVICE_INFO_MSG = bytearray( + [ + 0x5A, + 0x5A, + 0x15, + 0x00, + 0x00, + 0x38, + 0x00, + 0x04, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x27, + 0x33, + 0x05, + 0x13, + 0x06, + 0x14, + 0x14, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, + 0xE8, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xCA, + 0x8D, + 0x9B, + 0xF9, + 0xA0, + 0x30, + 0x1A, + 0xE3, + 0xB7, + 0xE4, + 0x2D, + 0x53, + 0x49, + 0x47, + 0x62, + 0xBE, + ] +) + + +def discover(discover_type=None, ip_address=None): + if discover_type is None: + discover_type = [] + security = LocalSecurity() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(5) + found_devices = {} + if ip_address is None: + addrs = enum_all_broadcast() + else: + addrs = [ip_address] + _LOGGER.debug(f"All addresses for broadcast: {addrs}") + for addr in addrs: + try: + sock.sendto(BROADCAST_MSG, (addr, 6445)) + sock.sendto(BROADCAST_MSG, (addr, 20086)) + except Exception: + _LOGGER.warning(f"Can't access network {addr}") + while True: + try: + data, addr = sock.recvfrom(512) + ip = addr[0] + _LOGGER.debug(f"Received response from {addr}: {data.hex()}") + if len(data) >= 104 and ( + data[:2].hex() == "5a5a" or data[8:10].hex() == "5a5a" + ): + if data[:2].hex() == "5a5a": + protocol = 2 + elif data[:2].hex() == "8370": + protocol = 3 + if data[8:10].hex() == "5a5a": + data = data[8:-16] + else: + continue + device_id = int.from_bytes( + bytearray.fromhex(data[20:26].hex()), "little" + ) + if device_id in found_devices: + continue + encrypt_data = data[40:-16] + reply = security.aes_decrypt(encrypt_data) + _LOGGER.debug(f"Declassified reply: {reply.hex()}") + ssid = reply[41 : 41 + reply[40]].decode("utf-8") + device_type = ssid.split("_")[1] + port = bytes2port(reply[4:8]) + model = reply[17:25].decode("utf-8") + sn = reply[8:40].decode("utf-8") + elif data[:6].hex() == "3c3f786d6c20": + protocol = 1 + root = ET.fromstring(data.decode(encoding="utf-8", errors="replace")) + child = root.find("body/device") + m = child.attrib + port, sn, device_type = ( + int(m["port"]), + m["apc_sn"], + str(hex(int(m["apc_type"])))[2:], + ) + response = get_device_info(ip, int(port)) + device_id = get_id_from_response(response) + if len(sn) == 32: + model = sn[9:17] + elif len(sn) == 22: + model = sn[3:11] + else: + model = "" + else: + continue + device = { + "device_id": device_id, + "type": int(device_type, 16), + "ip_address": ip, + "port": port, + "model": model, + "sn": sn, + "protocol": protocol, + } + if len(discover_type) == 0 or device.get("type") in discover_type: + found_devices[device_id] = device + _LOGGER.debug(f"Found a supported device: {device}") + else: + _LOGGER.debug(f"Found a unsupported device: {device}") + except socket.timeout: + break + except socket.error as e: + _LOGGER.error(f"Socket error: {repr(e)}") + return found_devices + + +def get_id_from_response(response): + if response[64:-16][:6].hex() == "3c3f786d6c20": + xml = response[64:-16] + root = ET.fromstring(xml.decode(encoding="utf-8", errors="replace")) + child = root.find("smartDevice") + m = child.attrib + return int.from_bytes(bytearray.fromhex(m["devId"]), "little") + else: + return 0 + + +def bytes2port(paramArrayOfbyte): + if paramArrayOfbyte is None: + return 0 + b, i = 0, 0 + while b < 4: + if b < len(paramArrayOfbyte): + b1 = paramArrayOfbyte[b] & 0xFF + else: + b1 = 0 + i |= b1 << b * 8 + b += 1 + return i + + +def get_device_info(device_ip, device_port: int): + response = bytearray(0) + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(8) + device_address = (device_ip, device_port) + sock.connect(device_address) + _LOGGER.debug( + f"Sending to {device_ip}:{device_port} {DEVICE_INFO_MSG.hex()}" + ) + sock.sendall(DEVICE_INFO_MSG) + response = sock.recv(512) + except socket.timeout: + _LOGGER.warning( + f"Connect the device {device_ip}:{device_port} timed out for 8s. " + f"Don't care about a small amount of this. if many maybe not support." + ) + except socket.error: + _LOGGER.warning(f"Can't connect to Device {device_ip}:{device_port}") + return response + + +def enum_all_broadcast(): + nets = [] + adapters = ifaddr.get_adapters() + for adapter in adapters: + for ip in adapter.ips: + if ip.is_IPv4 and ip.network_prefix < 32: + local_network = IPv4Network( + f"{ip.ip}/{ip.network_prefix}", strict=False + ) + if ( + local_network.is_private + and not local_network.is_loopback + and not local_network.is_link_local + ): + addr = str(local_network.broadcast_address) + if addr not in nets: + nets.append(addr) + return nets diff --git a/src/message.py b/src/message.py new file mode 100644 index 00000000..8a6222f9 --- /dev/null +++ b/src/message.py @@ -0,0 +1,272 @@ +import logging +from abc import ABC +from enum import IntEnum + +_LOGGER = logging.getLogger(__name__) + + +class MessageLenError(Exception): + pass + + +class MessageBodyError(Exception): + pass + + +class MessageCheckSumError(Exception): + pass + + +class MessageType(IntEnum): + set = (0x02,) + query = (0x03,) + notify1 = (0x04,) + notify2 = (0x05,) + exception = (0x06,) + exception2 = (0x0A,) + query_appliance = 0xA0 + + +class MessageBase(ABC): + HEADER_LENGTH = 10 + + def __init__(self): + self._device_type = 0x00 + self._message_type = 0x00 + self._body_type = 0x00 + self._protocol_version = 0x00 + + @staticmethod + def checksum(data): + return (~sum(data) + 1) & 0xFF + + @property + def header(self): + raise NotImplementedError + + @property + def body(self): + raise NotImplementedError + + @property + def message_type(self): + return self._message_type + + @message_type.setter + def message_type(self, value): + self._message_type = value + + @property + def device_type(self): + return self._device_type + + @device_type.setter + def device_type(self, value): + self._device_type = value + + @property + def body_type(self): + return self._body_type + + @body_type.setter + def body_type(self, value): + self._body_type = value + + @property + def protocol_version(self): + return self._protocol_version + + @protocol_version.setter + def protocol_version(self, protocol_version): + self._protocol_version = protocol_version + + def __str__(self) -> str: + output = { + "header": self.header.hex(), + "body": self.body.hex(), + "message type": "%02x" % self._message_type, + "body type": ( + ("%02x" % self._body_type) if self._body_type is not None else "None" + ), + } + return str(output) + + +class MessageRequest(MessageBase): + def __init__(self, device_type, protocol_version, message_type, body_type): + super().__init__() + self.device_type = device_type + self.protocol_version = protocol_version + self.message_type = message_type + self.body_type = body_type + + @property + def header(self): + length = self.HEADER_LENGTH + len(self.body) + return bytearray( + [ + # flag + 0xAA, + # length + length, + # device type + self.device_type, + # frame checksum + 0x00, # self._device_type ^ length, + # unused + 0x00, + 0x00, + # frame ID + 0x00, + # frame protocol version + 0x00, + # device protocol version + self.protocol_version, + # frame type + self.message_type, + ] + ) + + @property + def _body(self): + raise NotImplementedError + + @property + def body(self): + body = bytearray([]) + if self.body_type is not None: + body.append(self.body_type) + if self._body is not None: + body.extend(self._body) + return body + + def serialize(self): + stream = self.header + self.body + stream.append(MessageBase.checksum(stream[1:])) + return stream + + +class MessageQuestCustom(MessageRequest): + def __init__(self, device_type, protocol_version, cmd_type, cmd_body): + super().__init__( + device_type=device_type, + protocol_version=protocol_version, + message_type=cmd_type, + body_type=None, + ) + self._cmd_body = cmd_body + + @property + def _body(self): + return bytearray([]) + + @property + def body(self): + return self._cmd_body + + +class MessageQueryAppliance(MessageRequest): + def __init__(self, device_type): + super().__init__( + device_type=device_type, + protocol_version=0, + message_type=MessageType.query_appliance, + body_type=None, + ) + + @property + def _body(self): + return bytearray([]) + + @property + def body(self): + return bytearray([0x00] * 19) + + +class MessageBody: + def __init__(self, body): + self._data = body + + @property + def data(self): + return self._data + + @property + def body_type(self): + return self._data[0] + + @staticmethod + def read_byte(body, byte, default_value=0): + return body[byte] if len(body) > byte else default_value + + +class NewProtocolMessageBody(MessageBody): + def __init__(self, body, bt): + super().__init__(body) + if bt == 0xB5: + self._pack_len = 4 + else: + self._pack_len = 5 + + @staticmethod + def pack(param, value: bytearray, pack_len=4): + length = len(value) + if pack_len == 4: + stream = bytearray([param & 0xFF, param >> 8, length]) + value + else: + stream = bytearray([param & 0xFF, param >> 8, 0x00, length]) + value + return stream + + def parse(self): + result = {} + try: + pos = 2 + for pack in range(0, self.data[1]): + param = self.data[pos] + (self.data[pos + 1] << 8) + if self._pack_len == 5: + pos += 1 + length = self.data[pos + 2] + if length > 0: + value = self.data[pos + 3 : pos + 3 + length] + result[param] = value + pos += 3 + length + except IndexError: + # Some device used non-standard new-protocol(美的乐享三代中央空调?) + _LOGGER.debug(f"Non-standard new-protocol {self.data.hex()}") + return result + + +class MessageResponse(MessageBase): + def __init__(self, message): + super().__init__() + if message is None or len(message) < self.HEADER_LENGTH + 1: + raise MessageLenError + self._header = message[: self.HEADER_LENGTH] + self.protocol_version = self._header[-2] + self.message_type = self._header[-1] + self.device_type = self._header[2] + body = message[self.HEADER_LENGTH : -1] + self._body = MessageBody(body) + self.body_type = self._body.body_type + + @property + def header(self): + return self._header + + @property + def body(self): + return self._body.data + + def set_body(self, body: MessageBody): + self._body = body + + def set_attr(self): + for key in vars(self._body).keys(): + if key != "data": + value = getattr(self._body, key, None) + setattr(self, key, value) + + +class MessageApplianceResponse(MessageResponse): + def __init__(self, message): + super().__init__(message) diff --git a/src/packet_builder.py b/src/packet_builder.py new file mode 100644 index 00000000..49b9770c --- /dev/null +++ b/src/packet_builder.py @@ -0,0 +1,94 @@ +import datetime + +from .security import LocalSecurity + + +class PacketBuilder: + def __init__(self, device_id: int, command): + self.command = None + self.security = LocalSecurity() + # aa20ac00000000000003418100ff03ff000200000000000000000000000006f274 + # Init the packet with the header data. + self.packet = bytearray( + [ + # 2 bytes - StaicHeader + 0x5A, + 0x5A, + # 2 bytes - mMessageType + 0x01, + 0x11, + # 2 bytes - PacketLenght + 0x00, + 0x00, + # 2 bytes + 0x20, + 0x00, + # 4 bytes - MessageId + 0x00, + 0x00, + 0x00, + 0x00, + # 8 bytes - Date&Time + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # 6 bytes - mDeviceID + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + # 12 bytes + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + self.packet[12:20] = self.packet_time() + self.packet[20:28] = device_id.to_bytes(8, "little") + self.command = command + + def finalize(self, msg_type=1): + if msg_type != 1: + self.packet[3] = 0x10 + self.packet[6] = 0x7B + else: + self.packet.extend(self.security.aes_encrypt(self.command)) + # PacketLenght + self.packet[4:6] = (len(self.packet) + 16).to_bytes(2, "little") + # Append a basic checksum data(16 bytes) to the packet + self.packet.extend(self.encode32(self.packet)) + return self.packet + + def encode32(self, data: bytearray): + return self.security.encode32_data(data) + + @staticmethod + def checksum(data): + return (~sum(data) + 1) & 0xFF + + @staticmethod + def packet_time(): + t = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")[:16] + b = bytearray() + for i in range(0, len(t), 2): + d = int(t[i : i + 2]) + b.insert(0, d) + return b diff --git a/src/security.py b/src/security.py new file mode 100644 index 00000000..6b055a11 --- /dev/null +++ b/src/security.py @@ -0,0 +1,264 @@ +import hmac +from hashlib import md5, sha256 +from typing import Any +from urllib.parse import unquote_plus, urlencode, urlparse + +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad, unpad +from Crypto.Util.strxor import strxor + +MSGTYPE_HANDSHAKE_REQUEST = 0x0 +MSGTYPE_HANDSHAKE_RESPONSE = 0x1 +MSGTYPE_ENCRYPTED_RESPONSE = 0x3 +MSGTYPE_ENCRYPTED_REQUEST = 0x6 + + +class CloudSecurity: + def __init__(self, login_key, iot_key, hmac_key, fixed_key=None, fixed_iv=None): + self._login_key = login_key + self._iot_key = iot_key + self._hmac_key = hmac_key + self._aes_key = None + self._aes_iv = None + self._fixed_key = format(fixed_key, "x").encode("ascii") if fixed_key else None + self._fixed_iv = format(fixed_iv, "x").encode("ascii") if fixed_iv else None + + def sign(self, url: str, data: Any, random: str) -> str: + msg = self._iot_key + msg += str(data) + msg += random + sign = hmac.new(self._hmac_key.encode("ascii"), msg.encode("ascii"), sha256) + return sign.hexdigest() + + def encrypt_password(self, login_id, data): + m = sha256() + m.update(data.encode("ascii")) + login_hash = login_id + m.hexdigest() + self._login_key + m = sha256() + m.update(login_hash.encode("ascii")) + return m.hexdigest() + + def encrypt_iam_password(self, login_id, data) -> str: + raise NotImplementedError + + @staticmethod + def get_deviceid(username): + return sha256(f"Hello, {username}!".encode("ascii")).digest().hex()[:16] + + @staticmethod + def get_udp_id(appliance_id, method=0): + if method == 0: + bytes_id = bytes(reversed(appliance_id.to_bytes(8, "big"))) + elif method == 1: + bytes_id = appliance_id.to_bytes(6, "big") + elif method == 2: + bytes_id = appliance_id.to_bytes(6, "little") + else: + return None + data = bytearray(sha256(bytes_id).digest()) + for i in range(0, 16): + data[i] ^= data[i + 16] + return data[0:16].hex() + + def set_aes_keys(self, key, iv): + if isinstance(key, str): + key = key.encode("ascii") + if isinstance(iv, str): + iv = iv.encode("ascii") + self._aes_key = key + self._aes_iv = iv + + def aes_encrypt_with_fixed_key(self, data): + return self.aes_encrypt(data, self._fixed_key, self._fixed_iv) + + def aes_decrypt_with_fixed_key(self, data): + return self.aes_decrypt(data, self._fixed_key, self._fixed_iv) + + def aes_encrypt(self, data, key=None, iv=None): + if key is not None: + aes_key = key + aes_iv = iv + else: + aes_key = self._aes_key + aes_iv = self._aes_iv + if aes_key is None: + raise ValueError("Encrypt need a key") + if isinstance(data, str): + data = bytes.fromhex(data) + if aes_iv is None: # ECB + return AES.new(aes_key, AES.MODE_ECB).encrypt(pad(data, 16)) + else: # CBC + return AES.new(aes_key, AES.MODE_CBC, iv=aes_iv).encrypt(pad(data, 16)) + + def aes_decrypt(self, data, key=None, iv=None): + if key is not None: + aes_key = key + aes_iv = iv + else: + aes_key = self._aes_key + aes_iv = self._aes_iv + if aes_key is None: + raise ValueError("Encrypt need a key") + if isinstance(data, str): + data = bytes.fromhex(data) + if aes_iv is None: # ECB + return unpad( + AES.new(aes_key, AES.MODE_ECB).decrypt(data), len(aes_key) + ).decode() + else: # CBC + return unpad( + AES.new(aes_key, AES.MODE_CBC, iv=aes_iv).decrypt(data), len(aes_key) + ).decode() + + +class MeijuCloudSecurity(CloudSecurity): + def __init__(self, login_key, iot_key, hmac_key): + super().__init__(login_key, iot_key, hmac_key, 10864842703515613082) + + def encrypt_iam_password(self, login_id, data) -> str: + md = md5() + md.update(data.encode("ascii")) + md_second = md5() + md_second.update(md.hexdigest().encode("ascii")) + return md_second.hexdigest() + + +class MSmartCloudSecurity(CloudSecurity): + def __init__(self, login_key, iot_key, hmac_key): + super().__init__( + login_key, iot_key, hmac_key, 13101328926877700970, 16429062708050928556 + ) + + def encrypt_iam_password(self, login_id, data) -> str: + md = md5() + md.update(data.encode("ascii")) + md_second = md5() + md_second.update(md.hexdigest().encode("ascii")) + login_hash = login_id + md_second.hexdigest() + self._login_key + sha = sha256() + sha.update(login_hash.encode("ascii")) + return sha.hexdigest() + + def set_aes_keys(self, encrypted_key, encrypted_iv): + key_digest = sha256(self._login_key.encode("ascii")).hexdigest() + tmp_key = key_digest[:16].encode("ascii") + tmp_iv = key_digest[16:32].encode("ascii") + self._aes_key = self.aes_decrypt(encrypted_key, tmp_key, tmp_iv).encode("ascii") + self._aes_iv = self.aes_decrypt(encrypted_iv, tmp_key, tmp_iv).encode("ascii") + + +class MideaAirSecurity(CloudSecurity): + def __init__(self, login_key): + super().__init__(login_key, None, None) + + def sign(self, url: str, data: Any, random: str) -> str: + payload = unquote_plus(urlencode(sorted(data.items(), key=lambda x: x[0]))) + sha = sha256() + sha.update((urlparse(url).path + payload + self._login_key).encode("ascii")) + return sha.hexdigest() + + +class LocalSecurity: + def __init__(self): + self.blockSize = 16 + self.iv = b"\0" * 16 + self.aes_key = bytes.fromhex( + format(141661095494369103254425781617665632877, "x") + ) + self.salt = bytes.fromhex( + format( + 233912452794221312800602098970898185176935770387238278451789080441632479840061417076563, + "x", + ) + ) + self._tcp_key = None + self._request_count = 0 + self._response_count = 0 + + def aes_decrypt(self, raw): + try: + return unpad( + AES.new(self.aes_key, AES.MODE_ECB).decrypt(bytearray(raw)), 16 + ) + except ValueError: + return bytearray(0) + + def aes_encrypt(self, raw): + return AES.new(self.aes_key, AES.MODE_ECB).encrypt(bytearray(pad(raw, 16))) + + def aes_cbc_decrypt(self, raw, key): + return AES.new(key=key, mode=AES.MODE_CBC, iv=self.iv).decrypt(raw) + + def aes_cbc_encrypt(self, raw, key): + return AES.new(key=key, mode=AES.MODE_CBC, iv=self.iv).encrypt(raw) + + def encode32_data(self, raw): + return md5(raw + self.salt).digest() + + def tcp_key(self, response, key): + if response == b"ERROR": + raise Exception("authentication failed") + if len(response) != 64: + raise Exception("unexpected data length") + payload = response[:32] + sign = response[32:] + plain = self.aes_cbc_decrypt(payload, key) + if sha256(plain).digest() != sign: + raise Exception("sign does not match") + self._tcp_key = strxor(plain, key) + self._request_count = 0 + self._response_count = 0 + return self._tcp_key + + def encode_8370(self, data, msgtype): + header = bytearray([0x83, 0x70]) + size, padding = len(data), 0 + if msgtype in (MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST): + if (size + 2) % 16 != 0: + padding = 16 - (size + 2 & 0xF) + size += padding + 32 + data += get_random_bytes(padding) + header += size.to_bytes(2, "big") + header += bytearray([0x20, padding << 4 | msgtype]) + data = self._request_count.to_bytes(2, "big") + data + self._request_count += 1 + if self._request_count >= 0xFFFF: + self._request_count = 0 + if msgtype in (MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST): + sign = sha256(header + data).digest() + data = self.aes_cbc_encrypt(raw=data, key=self._tcp_key) + sign + return header + data + + def decode_8370(self, data): + if len(data) < 6: + return [], data + header = data[:6] + if header[0] != 0x83 or header[1] != 0x70: + raise Exception("not an 8370 message") + size = int.from_bytes(header[2:4], "big") + 8 + leftover = None + if len(data) < size: + return [], data + elif len(data) > size: + leftover = data[size:] + data = data[:size] + if header[4] != 0x20: + raise Exception("missing byte 4") + padding = header[5] >> 4 + msgtype = header[5] & 0xF + data = data[6:] + if msgtype in (MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST): + sign = data[-32:] + data = data[:-32] + data = self.aes_cbc_decrypt(raw=data, key=self._tcp_key) + if sha256(header + data).digest() != sign: + raise Exception("sign does not match") + if padding: + data = data[:-padding] + self._response_count = int.from_bytes(data[:2], "big") + data = data[2:] + if leftover: + packets, incomplete = self.decode_8370(leftover) + return [data] + packets, incomplete + return [data], b""