diff --git a/README.md b/README.md index 1ad4419..1687578 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ #### Серия Coral
AS20HPL1HRA - + - Поддерживается - Отображение текущей температуры - Включение/выключение diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..19278e6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import asyncio +from . import api +from .const import DOMAIN +import logging +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[str] = ["climate"] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + haier_object = api.Haier(hass, entry.data["email"], entry.data["password"]) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = haier_object + await hass.async_add_executor_job( + haier_object.pull_data + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/api.py b/api.py index ec5c865..8109a57 100644 --- a/api.py +++ b/api.py @@ -11,12 +11,14 @@ from homeassistant.core import HomeAssistant from homeassistant import config_entries, exceptions from urllib.parse import urlparse, parse_qs +from . import yaml_helper import websocket -from websocket._exceptions import WebSocketConnectionClosedException +from websocket._exceptions import WebSocketConnectionClosedException, WebSocketException from enum import Enum + SST_CLOUD_API_URL = "https://api.sst-cloud.com/" API_PATH = "https://evo.haieronline.ru" API_LOGIN = "v1/users/auth/sign-in" @@ -156,6 +158,9 @@ class SocketStatus(Enum): NOT_INITIALIZED = 3 + + + class HaierAC: def __init__(self, device_mac: str, device_serial: str, device_title: str, haier: Haier): self._haier = haier @@ -163,9 +168,10 @@ def __init__(self, device_mac: str, device_serial: str, device_title: str, haier self._hass:HomeAssistant = haier.hass self._id = device_mac - self.model_name = "AC" self._device_name = device_title + # the following values are updated below + self.model_name = "AC" self._current_temperature = 0 self._target_temperature = 0 self._status = None @@ -174,10 +180,17 @@ def __init__(self, device_mac: str, device_serial: str, device_title: str, haier self._min_temperature = 7 self._max_temperature = 35 self._sw_version = None - - self._disconnect_requested = False + # config values, updated below + self._config = None + self._config_current_temperature = None + self._config_mode = None + self._config_fan_mode = None + self._config_status = None + self._config_target_temperature = None + self._config_command_name = None + self._disconnect_requested = False status_url = API_STATUS.replace("{mac}", self._id) _LOGGER.info(f"Getting initial status of device {self._id}, url: {status_url}") @@ -191,17 +204,32 @@ def __init__(self, device_mac: str, device_serial: str, device_title: str, haier ): _LOGGER.debug(f"Update device {self._id} status code: {resp.status_code}") _LOGGER.debug(resp.text) + device_info = resp.json().get("info", {}) + device_model = device_info.get("model", "AC") + _LOGGER.debug(f"Device model {device_model}") + self.model_name = device_model + self._config = yaml_helper.DeviceConfig(device_model) + + # read config values + self._config_current_temperature = self._config.get_id_by_name('current_temperature') + self._config_mode = self._config.get_id_by_name('mode') + self._config_fan_mode = self._config.get_id_by_name('fan_mode') + self._config_status = self._config.get_id_by_name('status') + self._config_target_temperature = self._config.get_id_by_name('target_temperature') + self._config_command_name = self._config.get_command_name() + _LOGGER.debug(f"The following values are used: current temp - {self._config_current_temperature}, mode - {self._config_mode}, fan speed - {self._config_fan_mode}, status - {self._config_status}, target temp - {self._config_target_temperature}") + attributes = resp.json().get("attributes", {}) for attr in attributes: - if attr.get('name', '') == "0": # Температура в комнате + if attr.get('name', '') == self._config_current_temperature: # Температура в комнате self._current_temperature = int(attr.get('currentValue')) - if attr.get('name', '') == "5": # Режимы (0 - Авто, 1 - Охлаждение, 4 - Нагрев, 6 - Вентилятор, 2 - Осушение) + elif attr.get('name', '') == self._config_mode: # Режимы self._mode = int(attr.get('currentValue')) - if attr.get('name', '') == "6": # Скорость вентилятора (0 - Авто, 1 - Охлаждение, 4 - Нагрев, 6 - Вентилятор, 2 - Осушение) - self._mode = int(attr.get('currentValue')) - if attr.get('name', '') == "21": # Включение/выключение + elif attr.get('name', '') == self._config_fan_mode: # Скорость вентилятора + self._fan_mode = int(attr.get('currentValue')) + elif attr.get('name', '') == self._config_status: # Включение/выключение self._status = int(attr.get('currentValue')) - if attr.get('name', '') == "31": # Целевая температура + elif attr.get('name', '') == self._config_target_temperature: # Целевая температура self._target_temperature = int(attr.get('currentValue')) self._min_temperature = int(attr.get('range', {}).get('data', {}).get('minValue', 0)) self._max_temperature = int(attr.get('range', {}).get('data', {}).get('maxValue', 0)) @@ -280,19 +308,18 @@ def _handle_status_update(self, received_message: dict) -> None: _LOGGER.debug(f"Received status update, message_id {message_id}") for key, value in message_statuses[0]['properties'].items(): - if key == "0": # Температура в комнате + if key == self._config_current_temperature: # Температура в комнате self._current_temperature = int(value) - if key == "5": # Режимы (0 - Авто, 1 - Охлаждение, 4 - Нагрев, 6 - Вентилятор, 2 - Осушение) - self._mode = int(value) - if key == "6": # Скорость вентилятора (0 - Авто, 1 - Охлаждение, 4 - Нагрев, 6 - Вентилятор, 2 - Осушение) - self._fan_mode = int(value) - if key == "21": # Включение/выключение + if key == self._config_mode: # Режимы + self._mode = self._config.get_value_from_mappings(self._config_mode, int(value)) + if key == self._config_fan_mode: # Скорость вентилятора + self._fan_mode = self._config.get_value_from_mappings(self._config_fan_mode, int(value)) + if key == self._config_status: # Включение/выключение self._status = int(value) - if key == "31": # Целевая температура + if key == self._config_target_temperature: # Целевая температура self._target_temperature = int(value) - def _on_open(self, ws: websocket.WebSocket) -> None: _LOGGER.debug("Websocket opened") @@ -325,7 +352,11 @@ def connect(self) -> None: ]: self._socket_status = SocketStatus.INITIALIZING _LOGGER.info(f"Connecting to websocket ({API_WS_PATH})") - self._socket_app.run_forever() + try: + self._socket_app.run_forever() + except WebSocketException: # websocket._exceptions.WebSocketException: socket is already opened + pass + else: _LOGGER.info( f"Can not attempt socket connection because of current " @@ -391,7 +422,6 @@ def get_fan_mode(self) -> str: def get_status(self) -> str: return self._status - def setTemperature(self, temp) -> None: self._target_temperature = temp @@ -399,29 +429,31 @@ def setTemperature(self, temp) -> None: { "action": "operation", "macAddress": self._id, - "commandName": "3", + "commandName": self._config_command_name, "commands": [ { - "commandName": "31", + "commandName": self._config_target_temperature, "value": str(temp) } ] })) - def switchOn(self, hvac_mode=0) -> None: # default hvac mode is 0 - Авто + def switchOn(self, hvac_mode="auto") -> None: + hvac_mode_haier = self._config.get_haier_code_from_mappings(self._config_mode, hvac_mode) + self._send_message(json.dumps( { "action": "operation", "macAddress": self._id, - "commandName": "3", + "commandName": self._config_command_name, "commands": [ { - "commandName": "21", + "commandName": self._config_status, "value": "1" }, { - "commandName": "5", - "value": str(hvac_mode) + "commandName": self._config_mode, + "value": str(hvac_mode_haier) } ] })) @@ -433,10 +465,10 @@ def switchOff(self) -> None: { "action": "operation", "macAddress": self._id, - "commandName": "3", + "commandName": self._config_command_name, "commands": [ { - "commandName": "21", + "commandName": self._config_status, "value": "0" } ] @@ -444,17 +476,19 @@ def switchOff(self) -> None: self._status = 0 def setFanMode(self, fan_mode) -> None: + fan_mode_haier = self._config.get_haier_code_from_mappings(self._config_fan_mode, fan_mode) + self._fan_mode = fan_mode self._send_message(json.dumps( { "action": "operation", "macAddress": self._id, - "commandName": "3", + "commandName": self._config_command_name, "commands": [ { - "commandName": "6", - "value": str(fan_mode) + "commandName": self._config_fan_mode, + "value": str(fan_mode_haier) } ] })) \ No newline at end of file diff --git a/climate.py b/climate.py index bdb1344..684024c 100644 --- a/climate.py +++ b/climate.py @@ -72,52 +72,26 @@ def turn_off(self): @property def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" - if self._module.get_status == 1: - # (0 - Авто, 1 - Охлаждение, 4 - Нагрев, 6 - Вентилятор, 2 - Осушение) - if self._module.get_mode == 0: - return HVACMode.AUTO - if self._module.get_mode == 1: - return HVACMode.COOL - if self._module.get_mode == 2: - return HVACMode.DRY - if self._module.get_mode == 4: - return HVACMode.HEAT - if self._module.get_mode == 6: - return HVACMode.FAN_ONLY + return self._module.get_mode return HVACMode.OFF def set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" - _LOGGER.debug(f"set_hvac_mode {hvac_mode}") + _LOGGER.debug(f"Setting HVAC mode to {hvac_mode}") if hvac_mode == HVACMode.OFF: self._module.switchOff() else: - if hvac_mode == HVACMode.AUTO: - hvac_mode_int = 0 - elif hvac_mode == HVACMode.COOL: - hvac_mode_int = 1 - elif hvac_mode == HVACMode.DRY: - hvac_mode_int = 2 - elif hvac_mode == HVACMode.HEAT: - hvac_mode_int = 4 - elif hvac_mode == HVACMode.FAN_ONLY: - hvac_mode_int = 6 - - self._module.switchOn(hvac_mode_int) + self._module.switchOn(hvac_mode) def set_fan_mode(self, fan_mode): - # FAN_AUTO - 5, FAN_LOW - 3, FAN_MEDIUM - 2, FAN_HIGH - 1 - if fan_mode == FAN_HIGH: - fan_mode_int = 1 - if fan_mode == FAN_MEDIUM: - fan_mode_int = 2 - if fan_mode == FAN_LOW: - fan_mode_int = 3 - if fan_mode == FAN_AUTO: - fan_mode_int = 5 + """Set new target fan mode.""" + _LOGGER.debug(f"Setting fan mode to {fan_mode}") + self._module.setFanMode(fan_mode) - self._module.setFanMode(fan_mode_int) + @property + def fan_mode(self) -> str: + return self._module.get_fan_mode def update(self) -> None: @@ -144,17 +118,7 @@ def current_temperature(self) -> float: def target_temperature(self) -> float: return self._module.get_target_temperature - @property - def fan_mode(self) -> str: - # FAN_AUTO - 5, FAN_LOW - 3, FAN_MEDIUM - 2, FAN_HIGH - 1 - if self._module.get_fan_mode == 1: - return FAN_HIGH - if self._module.get_fan_mode == 2: - return FAN_MEDIUM - if self._module.get_fan_mode == 3: - return FAN_LOW - if self._module.get_fan_mode == 5: - return FAN_AUTO + @property def device_info(self): diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..f7ad055 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries, exceptions +from homeassistant.core import HomeAssistant +from .const import DOMAIN # pylint:disable=unused-import +# from .api import HaierEvoClimateApi + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({("email"): str, ("password"): str}) + + +async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + if len(data["email"]) < 3: + raise InvalidEmail + if len(data["password"]) < 3: + raise InvalidPassword + + # api = HaierEvoClimateApi(data["email"], data["password"]) + # api.login() + + return {"title": data["email"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["title"], data=user_input) + except InvalidEmail: + errors["email"] = "invalid_email" + except InvalidPassword: + errors["password"] = "invalid_password" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + # If there is no user input or there were errors, show the form again, including any errors that were found with the input. + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class InvalidEmail(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidPassword(exceptions.HomeAssistantError): + """Error to indicate there is an invalid hostname.""" diff --git a/const.py b/const.py new file mode 100644 index 0000000..a60e37a --- /dev/null +++ b/const.py @@ -0,0 +1 @@ +DOMAIN = "haier_evo" diff --git a/devices/AS20HPL1HRA.yaml b/devices/AS20HPL1HRA.yaml new file mode 100644 index 0000000..0cb28c7 --- /dev/null +++ b/devices/AS20HPL1HRA.yaml @@ -0,0 +1,32 @@ +command_name: "3" +attributes: + - name: current_temperature + id: "0" + - name: mode + id: "5" + mappings: + - haier: 0 + value: auto + - haier: 1 + value: cool + - haier: 2 + value: dry + - haier: 4 + value: heat + - haier: 6 + value: fan_only + - name: fan_mode + id: "6" + mappings: + - haier: 1 + value: high + - haier: 2 + value: medium + - haier: 3 + value: low + - haier: 5 + value: auto + - name: status + id: "21" + - name: target_temperature + id: "31" \ No newline at end of file diff --git a/devices/AS20HPL2HRA.yaml b/devices/AS20HPL2HRA.yaml new file mode 100644 index 0000000..c943c96 --- /dev/null +++ b/devices/AS20HPL2HRA.yaml @@ -0,0 +1,32 @@ +command_name: "4" +attributes: + - name: current_temperature + id: "36" + - name: mode + id: "2" + mappings: + - haier: 0 + value: auto + - haier: 1 + value: cool + - haier: 2 + value: dry + - haier: 4 + value: heat + - haier: 6 + value: fan_only + - name: fan_mode + id: "4" + mappings: + - haier: 1 + value: high + - haier: 2 + value: medium + - haier: 3 + value: low + - haier: 5 + value: auto + - name: status + id: "21" + - name: target_temperature + id: "0" \ No newline at end of file diff --git a/devices/__init__.py b/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/devices/default.yaml b/devices/default.yaml new file mode 100644 index 0000000..0cb28c7 --- /dev/null +++ b/devices/default.yaml @@ -0,0 +1,32 @@ +command_name: "3" +attributes: + - name: current_temperature + id: "0" + - name: mode + id: "5" + mappings: + - haier: 0 + value: auto + - haier: 1 + value: cool + - haier: 2 + value: dry + - haier: 4 + value: heat + - haier: 6 + value: fan_only + - name: fan_mode + id: "6" + mappings: + - haier: 1 + value: high + - haier: 2 + value: medium + - haier: 3 + value: low + - haier: 5 + value: auto + - name: status + id: "21" + - name: target_temperature + id: "31" \ No newline at end of file diff --git a/manifest.json b/manifest.json index 261585b..75ff743 100644 --- a/manifest.json +++ b/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/and7ey/haier_evo/issues", "requirements": ["requests"], - "version": "0.1.2", + "version": "0.2.0", "zeroconf": [] } \ No newline at end of file diff --git a/yaml_helper.py b/yaml_helper.py new file mode 100644 index 0000000..2d585de --- /dev/null +++ b/yaml_helper.py @@ -0,0 +1,66 @@ +""" +Config parser for Haier Evo devices. +""" + +import logging + +from homeassistant.util.yaml import load_yaml + +import custom_components.haier_evo.devices as config_dir +from os.path import dirname, exists, join, splitext + +_LOGGER = logging.getLogger(__name__) + + +class DeviceConfig: + """Representation of a device config.""" + + def __init__(self, fname): + """Initialize the device config. + Args: + fname (string): The filename of the yaml config to load.""" + _CONFIG_DIR = dirname(config_dir.__file__) + self._fname = fname + filename = join(_CONFIG_DIR, fname) + '.yaml' + if not exists(filename): + filename = join(_CONFIG_DIR, 'default') + '.yaml' + self._config = load_yaml(filename) + _LOGGER.debug("Loaded device config %s", fname) + + def get_command_name(self): + return self._config['command_name'] + + + def get_name_by_id(self, id): + attributes = self._config['attributes'] + for attr in attributes: + if attr.get('id') == id: + return attr.get('name') + return None + + def get_id_by_name(self, name): + attributes = self._config['attributes'] + for attr in attributes: + if attr.get('name') == name: + return attr.get('id') + return None + + def get_value_from_mappings(self, id, haier_value): + attributes = self._config['attributes'] + for attr in attributes: + if attr.get('id') == id: + mappings = attr.get('mappings') + for mapping in mappings: + if mapping.get('haier') == haier_value: + return mapping.get('value') + return None + + def get_haier_code_from_mappings(self, id, value): + attributes = self._config['attributes'] + for attr in attributes: + if attr.get('id') == id: + mappings = attr.get('mappings') + for mapping in mappings: + if mapping.get('value') == value: + return mapping.get('haier') + return None \ No newline at end of file