Sfoglia il codice sorgente

WIP: Dev container and configuration.yml -> entity support

Also fixed an issue from the very beginning which was spamming devices
unnecessarily. This should help reliability especially for Fan devices.
Nik Rolls 5 anni fa
parent
commit
02a9cb4834

+ 31 - 0
.devcontainer/devcontainer.json

@@ -0,0 +1,31 @@
+{
+  "name": "Goldair Climate dev",
+  "image": "ludeeus/container:integration",
+  "context": "..",
+  "appPort": ["8123:8123"],
+  "postCreateCommand": "dc install",
+  "runArgs": [
+    "-e",
+    "GIT_EDITOR=code --wait",
+    "-v",
+    "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh"
+  ],
+  "extensions": [
+    "ms-python.python",
+    "visualstudioexptteam.vscodeintellicode",
+    "redhat.vscode-yaml",
+    "esbenp.prettier-vscode"
+  ],
+  "settings": {
+    "files.eol": "\n",
+    "python.pythonPath": "/usr/local/bin/python",
+    "python.linting.pylintEnabled": true,
+    "python.linting.enabled": true,
+    "python.formatting.provider": "black",
+    "editor.formatOnPaste": false,
+    "editor.formatOnSave": true,
+    "editor.formatOnType": true,
+    "files.trimTrailingWhitespace": true,
+    "terminal.integrated.shell.linux": "/bin/bash"
+  }
+}

+ 1 - 0
.gitignore

@@ -1,2 +1,3 @@
 /.idea/
+/.vscode/
 __pycache__/

+ 35 - 0
.vscode/tasks.json

@@ -0,0 +1,35 @@
+{
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "label": "Install requirements",
+      "type": "shell",
+      "command": "pip3 install pycrypto==2.6.1;pip3 install pytuya==7.0.5",
+      "problemMatcher": []
+    },
+    {
+      "label": "Run Home Assistant on port 8123",
+      "type": "shell",
+      "command": "container start",
+      "problemMatcher": []
+    },
+    {
+      "label": "Run Home Assistant configuration against /config",
+      "type": "shell",
+      "command": "container check",
+      "problemMatcher": []
+    },
+    {
+      "label": "Upgrade Home Assistant to latest dev",
+      "type": "shell",
+      "command": "container install",
+      "problemMatcher": []
+    },
+    {
+      "label": "Install a specific version of Home Assistant",
+      "type": "shell",
+      "command": "container set-version",
+      "problemMatcher": []
+    }
+  ]
+}

+ 0 - 6
README.md

@@ -44,12 +44,6 @@ Installation
 ------------
 The preferred installation method is via [HACS](https://hacs.xyz/). Once you have HACS set up, simply follow the [instructions for adding a custom repository](https://hacs.xyz/docs/navigation/settings#custom-repositories) and then the integration will be available to install like any other.
 
-You can also use [Custom Updater](https://github.com/custom-components/custom_updater). Once Custom Updater is  set up, go to the Developer Tools > Service page and call the `custom_updater.install` service with this service data:
-
-```json
-{ "element": "goldair_climate" }
-```
-
 Alternatively you can copy the contents of this repository's `custom_components` directory to your `<config>/custom_components` directory, however you will not get automatic updates this way.
 
 Configuration

+ 36 - 30
custom_components/goldair_climate/__init__.py

@@ -1,5 +1,5 @@
 """
-Platform for Goldair WiFi-connected heaters and panels.
+Platform for Goldair WiFi-connected heaters, dehumidifiers and fans.
 
 Based on sean6541/tuya-homeassistant for service call logic, and TarxBoy's
 investigation into Goldair's tuyapi statuses
@@ -9,16 +9,28 @@ import logging
 
 import homeassistant.helpers.config_validation as cv
 import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT
 from homeassistant.const import CONF_HOST, CONF_NAME
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.discovery import async_load_platform
 
 from .configuration import individual_config_schema
-from .const import (DOMAIN, CONF_CHILD_LOCK, CONF_CLIMATE, CONF_DEVICE_ID,
-                    CONF_DISPLAY_LIGHT, CONF_LOCAL_KEY, CONF_TYPE,
-                    CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_HEATER, SCAN_INTERVAL, CONF_TYPE_AUTO)
+from .const import (
+    DOMAIN,
+    CONF_CHILD_LOCK,
+    CONF_CLIMATE,
+    CONF_DEVICE_ID,
+    CONF_DISPLAY_LIGHT,
+    CONF_LOCAL_KEY,
+    CONF_TYPE,
+    CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_FAN,
+    CONF_TYPE_HEATER,
+    SCAN_INTERVAL,
+    CONF_TYPE_AUTO,
+)
 from .device import GoldairTuyaDevice
+from .config_flow import ConfigFlowHandler
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -31,34 +43,19 @@ CONFIG_SCHEMA = vol.Schema(
 
 
 async def async_setup(hass: HomeAssistant, config: dict):
-    hass.data[DOMAIN] = {}
-
     for device_config in config.get(DOMAIN, []):
-        setup_device(hass, device_config)
-
-        discovery_info = {
-            CONF_DEVICE_ID: device_config[CONF_DEVICE_ID],
-            CONF_TYPE: device_config[CONF_TYPE],
-        }
-
-        if device_config[CONF_CLIMATE] == True:
-            hass.async_create_task(
-                async_load_platform(hass, "climate", DOMAIN, discovery_info, config)
-            )
-        if device_config[CONF_DISPLAY_LIGHT] == True:
-            hass.async_create_task(
-                async_load_platform(hass, "light", DOMAIN, discovery_info, config)
-            )
-        if device_config[CONF_CHILD_LOCK] == True:
-            hass.async_create_task(
-                async_load_platform(hass, "lock", DOMAIN, discovery_info, config)
+        hass.async_create_task(
+            hass.config_entries.flow.async_init(
+                DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config
             )
+        )
 
     return True
 
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
-    config = {**entry.data, **entry.options, 'name': entry.title}
+    _LOGGER.debug(f"Setting up entry for device: {entry.data[CONF_DEVICE_ID]}")
+    config = {**entry.data, **entry.options, "name": entry.title}
     setup_device(hass, config)
 
     if config[CONF_CLIMATE] == True:
@@ -80,6 +77,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+    if entry.data.get(SOURCE_IMPORT):
+        raise ValueError("Devices configured via yaml cannot be deleted from the UI.")
+
+    _LOGGER.debug(f"Unloading entry for device: {entry.data[CONF_DEVICE_ID]}")
     config = entry.data
     data = hass.data[DOMAIN][config[CONF_DEVICE_ID]]
 
@@ -97,11 +98,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
 
 
 async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry):
+    if entry.data.get(SOURCE_IMPORT):
+        raise ValueError("Devices configured via yaml cannot be updated from the UI.")
+
+    _LOGGER.debug(f"Updating entry for device: {entry.data[CONF_DEVICE_ID]}")
     await async_unload_entry(hass, entry)
     await async_setup_entry(hass, entry)
 
 
 def setup_device(hass: HomeAssistant, config: dict):
+    _LOGGER.debug(f"Creating device: {config[CONF_DEVICE_ID]}")
+    hass.data[DOMAIN] = hass.data.get(DOMAIN, {})
     device = GoldairTuyaDevice(
         config[CONF_NAME],
         config[CONF_DEVICE_ID],
@@ -109,12 +116,11 @@ def setup_device(hass: HomeAssistant, config: dict):
         config[CONF_LOCAL_KEY],
         hass,
     )
-    hass.data[DOMAIN][config[CONF_DEVICE_ID]] = {
-        'device': device
-    }
+    hass.data[DOMAIN][config[CONF_DEVICE_ID]] = {"device": device}
 
     return device
 
 
 def delete_device(hass: HomeAssistant, config: dict):
-    del hass.data[DOMAIN][config[CONF_DEVICE_ID]]['device']
+    _LOGGER.debug(f"Deleting device: {config[CONF_DEVICE_ID]}")
+    del hass.data[DOMAIN][config[CONF_DEVICE_ID]]["device"]

+ 42 - 5
custom_components/goldair_climate/config_flow.py

@@ -1,10 +1,13 @@
 import voluptuous as vol
-from homeassistant import config_entries
+from homeassistant import config_entries, data_entry_flow
 from homeassistant.const import CONF_NAME, CONF_HOST
 from homeassistant.core import callback, HomeAssistant
 
 from . import DOMAIN, individual_config_schema, GoldairTuyaDevice
 from .const import CONF_DEVICE_ID, CONF_LOCAL_KEY
+import logging
+
+_LOGGER = logging.getLogger(__name__)
 
 
 class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@@ -27,9 +30,34 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
                 errors["base"] = "connection"
 
         return self.async_show_form(
-            step_id="user", data_schema=vol.Schema(individual_config_schema(user_input or {})), errors=errors
+            step_id="user",
+            data_schema=vol.Schema(individual_config_schema(user_input or {})),
+            errors=errors,
         )
 
+    async def async_step_import(self, user_input):
+        title = user_input[CONF_NAME]
+        del user_input[CONF_NAME]
+        user_input[config_entries.SOURCE_IMPORT] = True
+
+        current_entries = self.hass.config_entries.async_entries(DOMAIN)
+        existing_entry = next(
+            (
+                entry
+                for entry in current_entries
+                if entry.data[CONF_DEVICE_ID] == user_input[CONF_DEVICE_ID]
+            ),
+            None,
+        )
+        if existing_entry is not None:
+            self.hass.config_entries.async_update_entry(
+                existing_entry, title=title, options=user_input
+            )
+            return self.async_abort(reason="imported")
+        else:
+            await self.async_set_unique_id(user_input[CONF_DEVICE_ID])
+            return self.async_create_entry(title=title, data=user_input)
+
     @staticmethod
     @callback
     def async_get_options_flow(config_entry):
@@ -42,7 +70,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
         self.config_entry = config_entry
 
     async def async_step_init(self, user_input=None):
-        return await self.async_step_user(user_input)
+        if self.config_entry.data.get(config_entries.SOURCE_IMPORT, False):
+            return await self.async_step_imported(user_input)
+        else:
+            return await self.async_step_user(user_input)
 
     async def async_step_user(self, user_input=None):
         """Manage the options."""
@@ -62,10 +93,16 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
             data_schema=vol.Schema(
                 individual_config_schema(defaults=config, options_only=True)
             ),
-            errors=errors
+            errors=errors,
         )
 
+    async def async_step_imported(self, user_input=None):
+        return {**self.async_abort(reason="imported"), "data": {}}
+
+
 async def async_test_connection(config: dict, hass: HomeAssistant):
-    device = GoldairTuyaDevice("Test", config[CONF_DEVICE_ID], config[CONF_HOST], config[CONF_LOCAL_KEY], hass)
+    device = GoldairTuyaDevice(
+        "Test", config[CONF_DEVICE_ID], config[CONF_HOST], config[CONF_LOCAL_KEY], hass
+    )
     await device.async_refresh()
     return device.get_property("1") is not None

+ 30 - 20
custom_components/goldair_climate/device.py

@@ -5,17 +5,24 @@ API for Goldair Tuya devices.
 import json
 import logging
 from threading import Lock, Timer
-from time import time
+from time import time, sleep
 
 from homeassistant.const import TEMP_CELSIUS
+from homeassistant.core import HomeAssistant
 
-from .const import DOMAIN, API_PROTOCOL_VERSIONS, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_HEATER
+from .const import (
+    DOMAIN,
+    API_PROTOCOL_VERSIONS,
+    CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_FAN,
+    CONF_TYPE_HEATER,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
 
 class GoldairTuyaDevice(object):
-    def __init__(self, name, dev_id, address, local_key, hass):
+    def __init__(self, name, dev_id, address, local_key, hass: HomeAssistant):
         """
         Represents a Goldair Tuya-based device.
 
@@ -29,6 +36,7 @@ class GoldairTuyaDevice(object):
         self._name = name
         self._api_protocol_version_index = None
         self._api = pytuya.Device(dev_id, address, local_key, "device")
+        self._refresh_task = None
         self._rotate_api_protocol_version()
 
         self._fixed_properties = {}
@@ -62,7 +70,7 @@ class GoldairTuyaDevice(object):
         return {
             "identifiers": {(DOMAIN, self.unique_id)},
             "name": self.name,
-            "manufacturer": "Goldair"
+            "manufacturer": "Goldair",
         }
 
     @property
@@ -70,11 +78,10 @@ class GoldairTuyaDevice(object):
         return self._TEMPERATURE_UNIT
 
     async def async_inferred_type(self):
-        cached_state = self._get_cached_state()
-
-        if not "1" in cached_state:
+        if "1" not in self._get_cached_state():
             await self.async_refresh()
-            return await self.async_inferred_type()
+
+        cached_state = self._get_cached_state()
 
         _LOGGER.debug(f"Inferring device type from cached state: {cached_state}")
         if "5" in cached_state:
@@ -93,18 +100,20 @@ class GoldairTuyaDevice(object):
         )
         set_fixed_properties.start()
 
-    def refresh(self):
-        now = time()
-        cached_state = self._get_cached_state()
-        if now - cached_state["updated_at"] >= self._CACHE_TIMEOUT:
+    async def async_refresh(self):
+        last_updated = self._get_cached_state()["updated_at"]
+        if self._refresh_task is None or time() - last_updated >= self._CACHE_TIMEOUT:
             self._cached_state["updated_at"] = time()
-            self._retry_on_failed_connection(
-                lambda: self._refresh_cached_state(),
-                f"Failed to refresh device state for {self.name}.",
-            )
+            self._refresh_task = self._hass.async_add_executor_job(self.refresh)
 
-    async def async_refresh(self):
-        await self._hass.async_add_executor_job(self.refresh)
+        await self._refresh_task
+
+    def refresh(self):
+        _LOGGER.debug(f"Refreshing device state for {self.name}.")
+        self._retry_on_failed_connection(
+            lambda: self._refresh_cached_state(),
+            f"Failed to refresh device state for {self.name}.",
+        )
 
     def get_property(self, dps_id):
         cached_state = self._get_cached_state()
@@ -192,7 +201,9 @@ class GoldairTuyaDevice(object):
         for i in range(self._CONNECTION_ATTEMPTS):
             try:
                 func()
-            except:
+                break
+            except Exception as e:
+                _LOGGER.debug(f"Retrying after exception {e}")
                 if i + 1 == self._CONNECTION_ATTEMPTS:
                     self._reset_cached_state()
                     _LOGGER.error(error_message)
@@ -201,7 +212,6 @@ class GoldairTuyaDevice(object):
 
     def _get_cached_state(self):
         cached_state = self._cached_state.copy()
-        _LOGGER.debug(f"pending updates: {json.dumps(self._get_pending_updates())}")
         return {**cached_state, **self._get_pending_properties()}
 
     def _get_pending_properties(self):

+ 14 - 3
custom_components/goldair_climate/lock.py

@@ -2,16 +2,27 @@
 Setup for different kinds of Goldair climate devices
 """
 from . import DOMAIN
-from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_DEHUMIDIFIER,
-                    CONF_TYPE_FAN, CONF_TYPE_HEATER, CONF_CHILD_LOCK, CONF_TYPE_AUTO)
+from .const import (
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+    CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_FAN,
+    CONF_TYPE_HEATER,
+    CONF_CHILD_LOCK,
+    CONF_TYPE_AUTO,
+)
 from .dehumidifier.lock import GoldairDehumidifierChildLock
 from .heater.lock import GoldairHeaterChildLock
+import logging
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
     """Set up the Goldair climate device according to its type."""
+    _LOGGER.debug(f"Domain data: {hass.data[DOMAIN]}")
     data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
-    device = data['device']
+    device = data["device"]
 
     if discovery_info[CONF_TYPE] == CONF_TYPE_AUTO:
         discovery_info[CONF_TYPE] = await device.async_inferred_type()

+ 8 - 1
custom_components/goldair_climate/strings.json

@@ -18,7 +18,8 @@
       }
     },
     "abort": {
-      "already_configured": "A device with that ID has already been added."
+      "already_configured": "A device with that ID has already been added.",
+      "imported_connection": "Unable to connect to your device with the configured details."
     },
     "error": {
       "connection": "Unable to connect to your device with those details. It could be an intermittent issue, or they may be incorrect."
@@ -37,10 +38,16 @@
           "display_light": "Include LED display as light entity",
           "child_lock": "Include child lock as lock entity (unsupported on fans)"
         }
+      },
+      "imported": {
+        "title": "Configured via yaml"
       }
     },
     "error": {
       "connection": "Unable to connect to your device with those details. It could be an intermittent issue, or they may be incorrect."
+    },
+    "abort": {
+      "imported": "This device is configured via your `configuration.yaml` file. To configure it via the UI, remove it from `configuration.yaml` first then add it here."
     }
   }
 }

+ 0 - 27
custom_updater.json

@@ -1,27 +0,0 @@
-{
-    "goldair_climate": {
-        "version": "0.0.8",
-        "local_location": "/custom_components/goldair_climate/__init__.py",
-        "remote_location": "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/__init__.py",
-        "visit_repo": "https://github.com/nikrolls/homeassistant-goldair-climate",
-        "changelog": "https://github.com/nikrolls/homeassistant-goldair-climate/releases/latest",
-        "resources": [
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/manifest.json",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/__init__.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/climate.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/light.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/lock.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/dehumidifier/__init__.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/dehumidifier/climate.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/dehumidifier/light.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/dehumidifier/lock.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/fan/__init__.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/fan/climate.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/fan/light.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/__init__.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/climate.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/light.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/lock.py"
-        ]
-    }
-}

+ 6 - 10
hacs.json

@@ -1,14 +1,10 @@
 {
   "name": "Goldair WiFi climate devices",
   "render_readme": true,
-  "domains": [
-    "climate",
-    "light",
-    "lock"
-  ],
-  "country": [
-    "NZ",
-    "AU"
-  ],
-  "homeassistant": "0.96.0"
+  "hide_default_branch": true,
+  "domains": ["climate", "light", "lock"],
+  "country": ["NZ", "AU"],
+  "homeassistant": "0.96.0",
+  "zip_release": true,
+  "filename": "homeassistant-goldair-climate.zip"
 }