From 87d8f88504025468b1d8589652c0db4f4bf2f884 Mon Sep 17 00:00:00 2001 From: Nikita Petrov Date: Mon, 11 May 2026 16:26:13 +0100 Subject: [PATCH 1/3] Fix provisioning for new Dataplicity device-gateway API Dataplicity migrated to a new provisioning stack: - Endpoint moved from https://www.dataplicity.com/install/ to https://app-api.dataplicity.com/device-gateway/provision/ - Install URL extension changed from .py to .sh (the .sh wraps the legacy .py with a new required `device_class_hash` query parameter that is per-organization) - POST field renames: token -> provisioning_key, with new required device_class_hash field - Response renames: serial -> hash_id, auth -> device_secret (64-hex, previously UUID and ~20-char alnum) Without this, v1.2.2 fails to register new tokens ("Wrong URL or Token") and devices provisioned via the official installer cannot be authorized by `device.check_auth` (m2m returns -32602 "unknown device"), spamming the HA log ~9 errors/second. Changes: - config_flow.py: regex accepts .sh and .py, www-optional; the isalnum() check is dropped (new tokens contain "-" and uppercase); added a recovery-input mode that accepts `serial:auth` of an already-provisioned device, to skip the HTTP round trip when the install token has already been consumed by the official installer - utils.py: register_device POSTs the new endpoint with the new field names; new fetch_device_class_hash GETs the .sh wrapper and extracts the 64-hex hash; response parsed with hash_id->serial and device_secret->auth fallbacks for back-compat - translations/{en,ru}.json: update the hint to show the .sh URL shape and document the optional `serial:auth` recovery format The pinned dataplicity==0.4.40 agent is unchanged - its Client(serial, auth_token) signature is API-compatible with the new 64-hex credentials. Closes #49. --- custom_components/dataplicity/config_flow.py | 34 ++++++++++++--- .../dataplicity/translations/en.json | 4 +- .../dataplicity/translations/ru.json | 4 +- custom_components/dataplicity/utils.py | 43 ++++++++++++++++--- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/custom_components/dataplicity/config_flow.py b/custom_components/dataplicity/config_flow.py index 99310f7..72f5223 100644 --- a/custom_components/dataplicity/config_flow.py +++ b/custom_components/dataplicity/config_flow.py @@ -10,7 +10,11 @@ _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 +36,37 @@ 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` + input_str = data["token"].strip() + + 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") 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/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..dbcaaf4 100644 --- a/custom_components/dataplicity/translations/ru.json +++ b/custom_components/dataplicity/translations/ru.json @@ -14,9 +14,9 @@ "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" } } } diff --git a/custom_components/dataplicity/utils.py b/custom_components/dataplicity/utils.py index 862c71e..097b307 100644 --- a/custom_components/dataplicity/utils.py +++ b/custom_components/dataplicity/utils.py @@ -1,5 +1,6 @@ import logging import os +import re import sys from ipaddress import IPv4Network from subprocess import Popen, PIPE @@ -9,18 +10,50 @@ _LOGGER = logging.getLogger(__name__) +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 register_device(session: ClientSession, token: str): + +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( - "https://www.dataplicity.com/install/", - data={"name": "Home Assistant", "serial": "None", "token": token}, + 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 - return await r.json() - except: + 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 From 8a22ffd266e848b370460196b149d4b9c032e9f6 Mon Sep 17 00:00:00 2001 From: Nikita Petrov Date: Mon, 11 May 2026 16:26:37 +0100 Subject: [PATCH 2/3] Update version to 1.3.0 --- custom_components/dataplicity/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/dataplicity/manifest.json b/custom_components/dataplicity/manifest.json index a56f2ee..c50db41 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" + "version": "1.3.0" } \ No newline at end of file From cb58ed668b9a0fa692976d9098c8c22a8eafed26 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 20 May 2026 17:08:50 +0300 Subject: [PATCH 3/3] Code refactoring for #50 --- custom_components/dataplicity/config_flow.py | 33 ++++++------- custom_components/dataplicity/manifest.json | 2 +- .../dataplicity/translations/en.json | 2 +- .../dataplicity/translations/ru.json | 2 +- custom_components/dataplicity/utils.py | 46 ++++++++++--------- 5 files changed, 42 insertions(+), 43 deletions(-) diff --git a/custom_components/dataplicity/config_flow.py b/custom_components/dataplicity/config_flow.py index 72f5223..72c5c10 100644 --- a/custom_components/dataplicity/config_flow.py +++ b/custom_components/dataplicity/config_flow.py @@ -10,9 +10,7 @@ _LOGGER = logging.getLogger(__name__) -RE_INSTALL_URL = re.compile( - r"https?://(?:www\.)?dataplicity\.com/([A-Za-z0-9_-]+)\.(?:py|sh)" -) +RE_INSTALL_URL = re.compile(r"dataplicity\.com/([A-Za-z0-9_-]+)") RE_TOKEN_CHARS = re.compile(r"^[A-Za-z0-9_-]+$") RE_RECOVERY = re.compile(r"^([A-Za-z0-9_-]{8,}):(.+)$") @@ -36,20 +34,20 @@ async def async_step_user(self, data=None, error=None): errors={"base": error} if error else None, ) - input_str = data["token"].strip() + token = data["token"].strip() - 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": ""}, - ) + if m := RE_RECOVERY.match(token): + serial, auth = m.group(1), m.group(2).strip() + return self.async_create_entry( + title="Dataplicity", + data={"auth": auth, "serial": serial}, + description_placeholders={"device_url": ""}, + ) + + # 2026.05 link format https://app-api.dataplicity.com/3-XXXXXXXX.py + if m := RE_INSTALL_URL.search(token): + token = m.group(1) - 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): @@ -61,12 +59,11 @@ async def async_step_user(self, data=None, error=None): 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: + if resp := await utils.register_device(session, token, device_class_hash): return self.async_create_entry( title="Dataplicity", data={"auth": resp["auth"], "serial": resp["serial"]}, - description_placeholders={"device_url": resp.get("device_url", "")}, + description_placeholders={"device_url": resp["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 c50db41..a56f2ee 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.3.0" + "version": "1.2.2" } \ No newline at end of file diff --git a/custom_components/dataplicity/translations/en.json b/custom_components/dataplicity/translations/en.json index 9e31162..059a3ca 100644 --- a/custom_components/dataplicity/translations/en.json +++ b/custom_components/dataplicity/translations/en.json @@ -14,7 +14,7 @@ "step": { "user": { "title": "Register Dataplicity Device", - "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`.", + "description": "Sign up to [Dataplicity](https://www.dataplicity.com/) and paste the install URL from Add device:\n`dataplicity.com/XXXXXXXX...`\n\nOr, to reuse already-provisioned credentials, paste them as `serial:auth`.", "data": { "token": "Install URL or serial:auth" } diff --git a/custom_components/dataplicity/translations/ru.json b/custom_components/dataplicity/translations/ru.json index dbcaaf4..b405bef 100644 --- a/custom_components/dataplicity/translations/ru.json +++ b/custom_components/dataplicity/translations/ru.json @@ -14,7 +14,7 @@ "step": { "user": { "title": "Регистрация устройства Dataplicity", - "description": "Зарегистрируйтесь в сервисе [Dataplicity](https://www.dataplicity.com/) и вставьте ссылку установки из кнопки Add device:\n`https://dataplicity.com/XXXXXXXX.sh`\n\nИли, чтобы использовать уже полученные учётные данные, вставьте их в формате `serial:auth`.", + "description": "Зарегистрируйтесь в сервисе [Dataplicity](https://www.dataplicity.com/) и вставьте ссылку установки из кнопки Add device:\n`dataplicity.com/XXXXXXXX...`\n\nИли используйте ранее полученные учётные данные, в формате `serial:auth`.", "data": { "token": "Ссылка установки или serial:auth" } diff --git a/custom_components/dataplicity/utils.py b/custom_components/dataplicity/utils.py index 097b307..0ed019d 100644 --- a/custom_components/dataplicity/utils.py +++ b/custom_components/dataplicity/utils.py @@ -10,32 +10,31 @@ _LOGGER = logging.getLogger(__name__) -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)) + r = await session.get(f"https://dataplicity.com/{token}.sh") 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 + if m := RE_DEVICE_CLASS_HASH.search(text): + return m.group(1) + + _LOGGER.error("device_class_hash not found in install wrapper") + except Exception as e: + _LOGGER.error("Can't fetch device_class_hash", exc_info=e) + + return None async def register_device(session: ClientSession, token: str, device_class_hash: str): try: r = await session.post( - PROVISION_URL, + "https://app-api.dataplicity.com/device-gateway/provision/", data={ "provisioning_key": token, "name": "Home Assistant", @@ -46,16 +45,19 @@ async def register_device(session: ClientSession, token: str, device_class_hash: 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 + + data = await r.json() + serial = data.get("hash_id") or data.get("serial") + auth = data.get("device_secret") or data.get("auth") + if serial and auth: + device_url = data.get("device_url") or "https://www.dataplicity.com/" + return {"serial": serial, "auth": auth, "device_url": device_url} + + _LOGGER.error(f"Provisioning response missing creds: keys={list(data)}") + except Exception as e: + _LOGGER.error("Can't register dataplicity device", exc_info=e) + + return None async def fix_middleware(hass: HomeAssistant):