diff --git a/custom_components/dataplicity/__init__.py b/custom_components/dataplicity/__init__.py index 5055061..c04c0a2 100644 --- a/custom_components/dataplicity/__init__.py +++ b/custom_components/dataplicity/__init__.py @@ -1,4 +1,5 @@ import inspect +import logging from threading import Thread from homeassistant.config_entries import ConfigEntry @@ -10,60 +11,59 @@ from . import utils DOMAIN = "dataplicity" - +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, hass_config: dict): real_install = package.install_package def fake_install(pkg: str, *args, **kwargs): - if pkg == "dataplicity==0.4.40": + if pkg == "dataplicity==0.5.13": return utils.install_package(pkg, *args, **kwargs) return real_install(pkg, *args, **kwargs) try: package.install_package = fake_install - # latest dataplicity has bug with redirect_port - await async_process_requirements(hass, DOMAIN, ["dataplicity==0.4.40"]) + # 0.5.13 verified working; older versions had a redirect_port bug + await async_process_requirements(hass, DOMAIN, ["dataplicity==0.5.13"]) - # fix Python 3.11 support + # fix Python 3.11+ support (getargspec removed in 3.11) if not hasattr(inspect, "getargspec"): - def getargspec(*args): spec = inspect.getfullargspec(*args) return spec.args, spec.varargs, spec.varkw, spec.defaults - inspect.getargspec = getargspec return True - except: + except Exception: + import traceback + _LOGGER.error("Dataplicity setup failed:\n%s", traceback.format_exc()) return False finally: package.install_package = real_install - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # fix https://github.com/AlexxIT/Dataplicity/issues/29 - Client = await hass.async_add_executor_job(utils.import_client) - - hass.data[DOMAIN] = client = Client( - serial=entry.data["serial"], auth_token=entry.data["auth"] - ) - # replace default 80 port to Hass port (usual 8123) - client.port_forward.add_service("web", hass.config.api.port) - Thread(name=DOMAIN, target=client.run_forever).start() - - async def hass_stop(event): - client.exit() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_stop) - - await utils.fix_middleware(hass) - - return True - + try: + Client = await hass.async_add_executor_job(utils.import_client) + hass.data[DOMAIN] = client = Client( + serial=entry.data["serial"], auth_token=entry.data["auth"] + ) + # replace default 80 port to Hass port (usual 8123) + client.port_forward.add_service("web", hass.config.api.port) + Thread(name=DOMAIN, target=client.run_forever).start() + async def hass_stop(event): + client.exit() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_stop) + await utils.fix_middleware(hass) + return True + except Exception: + import traceback + _LOGGER.error("async_setup_entry failed:\n%s", traceback.format_exc()) + return False async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - client = hass.data[DOMAIN] - client.exit() + client = hass.data.pop(DOMAIN, None) + if client is not None: + client.exit() return True diff --git a/custom_components/dataplicity/config_flow.py b/custom_components/dataplicity/config_flow.py index 99310f7..d30c3e5 100644 --- a/custom_components/dataplicity/config_flow.py +++ b/custom_components/dataplicity/config_flow.py @@ -10,7 +10,13 @@ _LOGGER = logging.getLogger(__name__) -RE_TOKEN = re.compile(r"https://www\.dataplicity\.com/([a-z0-9-]+)\.py") +RE_INSTALL_URL = re.compile( + r"https?://(?:www\.)?dataplicity\.com/([A-Za-z0-9_\-]+)\.(?:py|sh)" +) + +RE_TOKEN_CHARS = re.compile(r"^[A-Za-z0-9_\-]+$") + +RE_RECOVERY = re.compile(r"^([A-Za-z0-9_\-]{8,}):(.+)$") class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -32,21 +38,45 @@ async def async_step_user(self, data=None, error=None): errors={"base": error} if error else None, ) - m = RE_TOKEN.search(data["token"]) - token = m[1] if m else data["token"] - # fix new format `https://www.dataplicity.com/3-********.py` - token = re.sub(r"^\d-", "", token) + input_str = data["token"].strip() - if not token.isalnum(): - return await self.async_step_user(error="token") + if not input_str.lower().startswith("http"): + m_rec = RE_RECOVERY.match(input_str) + if m_rec: + serial, auth = m_rec.group(1), m_rec.group(2).strip() + return self.async_create_entry( + title="Dataplicity", + data={"auth": auth, "serial": serial}, + description_placeholders={"device_url": ""}, + ) + + m = RE_INSTALL_URL.search(input_str) + token = m.group(1) if m else input_str + token = re.sub(r"^\d-", "", token) + + if not token.isalnum(): + if not RE_TOKEN_CHARS.match(token): + return await self.async_step_user(error="token") + else: + m = RE_INSTALL_URL.search(input_str) + token = m.group(1) if m else input_str + token = re.sub(r"^\d-", "", token) + + if not RE_TOKEN_CHARS.match(token): + return await self.async_step_user(error="token") session = async_get_clientsession(self.hass) - resp = await utils.register_device(session, token) + + device_class_hash = await utils.fetch_device_class_hash(session, token) + if device_class_hash is None: + return await self.async_step_user(error="token") + + resp = await utils.register_device(session, token, device_class_hash) if resp: return self.async_create_entry( title="Dataplicity", data={"auth": resp["auth"], "serial": resp["serial"]}, - description_placeholders={"device_url": resp["device_url"]}, + description_placeholders={"device_url": resp.get("device_url", "")}, ) return await self.async_step_user(error="auth") diff --git a/custom_components/dataplicity/manifest.json b/custom_components/dataplicity/manifest.json index a56f2ee..2e28f72 100644 --- a/custom_components/dataplicity/manifest.json +++ b/custom_components/dataplicity/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/AlexxIT/Dataplicity/issues", "requirements": [], - "version": "1.2.2" -} \ No newline at end of file + "version": "1.3.0" +} diff --git a/custom_components/dataplicity/translations/en.json b/custom_components/dataplicity/translations/en.json index 163d9cd..9e31162 100644 --- a/custom_components/dataplicity/translations/en.json +++ b/custom_components/dataplicity/translations/en.json @@ -14,9 +14,9 @@ "step": { "user": { "title": "Register Dataplicity Device", - "description": "Sign up to [Dataplicity](https://www.dataplicity.com/) and paste full install line or URL or Token:\n`https://www.dataplicity.com/XXXXXXXX.py`", + "description": "Sign up to [Dataplicity](https://www.dataplicity.com/) and paste the install URL from Add device:\n`https://dataplicity.com/XXXXXXXX.sh`\n\nOr, to reuse already-provisioned credentials, paste them as `serial:auth`.", "data": { - "token": "URL or Token" + "token": "Install URL or serial:auth" } } } diff --git a/custom_components/dataplicity/translations/ru.json b/custom_components/dataplicity/translations/ru.json index d01ddfa..00cd24b 100644 --- a/custom_components/dataplicity/translations/ru.json +++ b/custom_components/dataplicity/translations/ru.json @@ -1,24 +1,24 @@ { "config": { "create_entry": { - "default": "Home Assistant добавлен в сервис Dataplicity. Включите **Wormhole** в [настройках устройства]({device_url}) для публичного HTTPS доступа." + "default": "Home Assistant добавлен в Dataplicity. Включите **Wormhole** в [настройках устройства]({device_url}) для публичного HTTPS доступа." }, "abort": { "win32": "Windows не поддерживается", - "ssl": "[SSL конфигурация](https://www.home-assistant.io/integrations/http/) не поддерживается" + "ssl": "[SSL конфиг](https://www.home-assistant.io/integrations/http/) не поддерживается" }, "error": { - "auth": "Ошибка в процессе регистрации устройства", - "token": "Неправильная ссылка или токен" + "auth": "Ошибка при регистрации устройства", + "token": "Неверный URL или токен" }, "step": { "user": { "title": "Регистрация устройства Dataplicity", - "description": "Зарегистрируйтесь в сервисе [Dataplicity](https://www.dataplicity.com/) и вставьте полную строку установки или ссылку или токен:\n`https://www.dataplicity.com/XXXXXXXX.py`", + "description": "Зарегистрируйтесь в сервисе [Dataplicity](https://www.dataplicity.com/) и вставьте ссылку установки из кнопки Add device:\n`https://dataplicity.com/XXXXXXXX.sh`\n\nИли, чтобы использовать уже полученные учётные данные, вставьте их в формате `serial:auth`.", "data": { - "token": "Ссылка или токен" + "token": "Ссылка установки или serial:auth" } } } } -} \ No newline at end of file +} diff --git a/custom_components/dataplicity/utils.py b/custom_components/dataplicity/utils.py index 862c71e..895ded9 100644 --- a/custom_components/dataplicity/utils.py +++ b/custom_components/dataplicity/utils.py @@ -1,56 +1,16 @@ import logging import os +import re import sys -from ipaddress import IPv4Network from subprocess import Popen, PIPE from aiohttp import ClientSession from homeassistant.core import HomeAssistant +from ipaddress import IPv4Network _LOGGER = logging.getLogger(__name__) -async def register_device(session: ClientSession, token: str): - try: - r = await session.post( - "https://www.dataplicity.com/install/", - data={"name": "Home Assistant", "serial": "None", "token": token}, - ) - if r.status != 200: - _LOGGER.error(f"Can't register dataplicity device: {r.status}") - return None - return await r.json() - except: - _LOGGER.exception("Can't register dataplicity device") - return None - - -async def fix_middleware(hass: HomeAssistant): - """Dirty hack for HTTP integration. Plug and play for usual users... - - [v2021.7] Home Assistant will now block HTTP requests when a misconfigured - reverse proxy, or misconfigured Home Assistant instance when using a - reverse proxy, has been detected. - - http: - use_x_forwarded_for: true - trusted_proxies: - - 127.0.0.1 - """ - for f in hass.http.app.middlewares: - if f.__name__ != "forwarded_middleware": - continue - # https://til.hashrocket.com/posts/ykhyhplxjh-examining-the-closure - for i, var in enumerate(f.__code__.co_freevars): - cell = f.__closure__[i] - if var == "use_x_forwarded_for": - if not cell.cell_contents: - cell.cell_contents = True - elif var == "trusted_proxies": - if not cell.cell_contents: - cell.cell_contents = [IPv4Network("127.0.0.1/32")] - - def install_package( package: str, upgrade: bool = True, @@ -58,9 +18,9 @@ def install_package( constraints: str | None = None, timeout: int | None = None, ) -> bool: - # important to use no-deps, because: - # - enum34 has problems with Hass constraints - # - six has problmes with Python 3.12 + """Install dataplicity package via pip subprocess (avoids recursion with fake_install).""" + # important to use --no-deps, because some transitive packages + # (e.g. lomond) have versions that conflict with Home Assistant constraints args = [ sys.executable, "-m", @@ -69,12 +29,9 @@ def install_package( "--quiet", package, "--no-deps", - # "enum34==1.1.6", - # "six==1.10.0", "lomond==0.3.3", ] env = os.environ.copy() - if timeout: args += ["--timeout", str(timeout)] if upgrade: @@ -103,20 +60,99 @@ def install_package( stderr.decode("utf-8").lstrip().strip(), ) return False - return True +PROVISION_URL = "https://app-api.dataplicity.com/device-gateway/provision/" +SH_URL_TEMPLATE = "https://dataplicity.com/{token}.sh" +RE_DEVICE_CLASS_HASH = re.compile(r"device_class_hash=([a-f0-9]{64})") + + +async def fetch_device_class_hash(session: ClientSession, token: str): + try: + r = await session.get(SH_URL_TEMPLATE.format(token=token)) + if r.status != 200: + _LOGGER.error(f"Can't fetch install wrapper for token: {r.status}") + return None + text = await r.text() + m = RE_DEVICE_CLASS_HASH.search(text) + if not m: + _LOGGER.error("device_class_hash not found in install wrapper") + return None + return m.group(1) + except Exception: + _LOGGER.exception("Can't fetch device_class_hash") + return None + + +async def register_device(session: ClientSession, token: str, device_class_hash: str): + try: + r = await session.post( + PROVISION_URL, + data={ + "provisioning_key": token, + "name": "Home Assistant", + "device_class_hash": device_class_hash, + }, + headers={"User-Agent": "Python-urllib/3.11"}, + ) + if r.status != 200: + _LOGGER.error(f"Can't register dataplicity device: {r.status}") + return None + body = await r.json() + serial = body.get("hash_id") or body.get("serial") + auth = body.get("device_secret") or body.get("auth") + if not serial or not auth: + _LOGGER.error(f"Provisioning response missing creds: keys={list(body)}") + return None + return {"serial": serial, "auth": auth, "device_url": body.get("device_url", "")} + except Exception: + _LOGGER.exception("Can't register dataplicity device") + return None + + +async def fix_middleware(hass: HomeAssistant): + """Dirty hack for HTTP integration. Plug and play for usual users... + + [v2021.7] Home Assistant will now block HTTP requests when a misconfigured + reverse proxy, or misconfigured Home Assistant instance when using a + reverse proxy, has been detected. + + http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + """ + for f in hass.http.app.middlewares: + if getattr(f, "__name__", None) != "forwarded_middleware": + continue + # https://til.hashrocket.com/posts/ykhyhplxjh-examining-the-closure + for i, var in enumerate(f.__code__.co_freevars): + cell = f.__closure__[i] + if var == "use_x_forwarded_for": + if not cell.cell_contents: + cell.cell_contents = True + elif var == "trusted_proxies": + if not cell.cell_contents: + cell.cell_contents = [IPv4Network("127.0.0.1/32")] + break + def import_client(): # fix: type object 'array.array' has no attribute 'tostring' - from dataplicity import iptool - - iptool.get_all_interfaces = lambda: [("lo", "127.0.0.1")] + try: + from dataplicity import iptool + except ImportError: + pass + else: + iptool.get_all_interfaces = lambda: [("lo", "127.0.0.1")] # fix: module 'platform' has no attribute 'linux_distribution' - from dataplicity import device_meta - - device_meta.get_os_version = lambda: "Linux" + try: + from dataplicity import device_meta + except ImportError: + pass + else: + device_meta.get_os_version = lambda: "Linux" from dataplicity.client import Client