Fix SonoffLAN signing key: hardcode pre-computed HMAC key
The previous _compute_sign_key() function indexed into a base64 string derived from the full SonoffLAN REGIONS dict (~243 entries). Our partial dict only produced a 7876-char a string but needed index 7872+, so the function must use the full dict. Solution: pre-compute the key once from the full dict and hardcode the resulting 32-byte ASCII key. This is deterministic — the SonoffLAN algorithm always produces the same output regardless of when it runs. The sonoff_ewelink driver now loads cleanly alongside all other drivers.
This commit is contained in:
353
app/drivers/sonoff_ewelink/ewelink_api.py
Normal file
353
app/drivers/sonoff_ewelink/ewelink_api.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Synchronous eWeLink / Sonoff cloud API wrapper.
|
||||
|
||||
Uses the same authentication scheme as SonoffLAN (requests-based, no asyncio).
|
||||
Supports:
|
||||
- Login with email / phone number
|
||||
- Device list retrieval (cloud)
|
||||
- LAN relay control (HTTP to device, with optional AES encryption)
|
||||
- Cloud relay control (HTTP fallback, not WebSocket — state only)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── API endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
APPID = "R8Oq3y0eSZSYdKccHlrQzT1ACCOUT9Gv"
|
||||
|
||||
API = {
|
||||
"cn": "https://cn-apia.coolkit.cn",
|
||||
"as": "https://as-apia.coolkit.cc",
|
||||
"us": "https://us-apia.coolkit.cc",
|
||||
"eu": "https://eu-apia.coolkit.cc",
|
||||
}
|
||||
|
||||
# Country code → (Country, region) — subset of full list, used only for
|
||||
# auto-detecting the default region during login.
|
||||
REGIONS: dict[str, tuple[str, str]] = {
|
||||
"+1": ("United States", "us"),
|
||||
"+7": ("Russia", "eu"),
|
||||
"+20": ("Egypt", "eu"),
|
||||
"+27": ("South Africa", "eu"),
|
||||
"+30": ("Greece", "eu"),
|
||||
"+31": ("Netherlands", "eu"),
|
||||
"+32": ("Belgium", "eu"),
|
||||
"+33": ("France", "eu"),
|
||||
"+34": ("Spain", "eu"),
|
||||
"+36": ("Hungary", "eu"),
|
||||
"+39": ("Italy", "eu"),
|
||||
"+40": ("Romania", "eu"),
|
||||
"+41": ("Switzerland", "eu"),
|
||||
"+43": ("Austria", "eu"),
|
||||
"+44": ("UK", "eu"),
|
||||
"+45": ("Denmark", "eu"),
|
||||
"+46": ("Sweden", "eu"),
|
||||
"+47": ("Norway", "eu"),
|
||||
"+48": ("Poland", "eu"),
|
||||
"+49": ("Germany", "eu"),
|
||||
"+51": ("Peru", "us"),
|
||||
"+52": ("Mexico", "us"),
|
||||
"+54": ("Argentina", "us"),
|
||||
"+55": ("Brazil", "us"),
|
||||
"+56": ("Chile", "us"),
|
||||
"+57": ("Colombia", "us"),
|
||||
"+58": ("Venezuela", "us"),
|
||||
"+60": ("Malaysia", "as"),
|
||||
"+61": ("Australia", "us"),
|
||||
"+62": ("Indonesia", "as"),
|
||||
"+63": ("Philippines", "as"),
|
||||
"+64": ("New Zealand", "us"),
|
||||
"+65": ("Singapore", "as"),
|
||||
"+66": ("Thailand", "as"),
|
||||
"+81": ("Japan", "as"),
|
||||
"+82": ("South Korea", "as"),
|
||||
"+84": ("Vietnam", "as"),
|
||||
"+86": ("China", "cn"),
|
||||
"+90": ("Turkey", "as"),
|
||||
"+91": ("India", "as"),
|
||||
"+92": ("Pakistan", "as"),
|
||||
"+94": ("Sri Lanka", "as"),
|
||||
"+351": ("Portugal", "eu"),
|
||||
"+352": ("Luxembourg", "eu"),
|
||||
"+353": ("Ireland", "eu"),
|
||||
"+354": ("Iceland", "eu"),
|
||||
"+358": ("Finland", "eu"),
|
||||
"+359": ("Bulgaria", "eu"),
|
||||
"+370": ("Lithuania", "eu"),
|
||||
"+371": ("Latvia", "eu"),
|
||||
"+372": ("Estonia", "eu"),
|
||||
"+380": ("Ukraine", "eu"),
|
||||
"+381": ("Serbia", "eu"),
|
||||
"+385": ("Croatia", "eu"),
|
||||
"+386": ("Slovenia", "eu"),
|
||||
"+420": ("Czech", "eu"),
|
||||
"+421": ("Slovakia", "eu"),
|
||||
"+886": ("Taiwan, China", "as"),
|
||||
"+966": ("Saudi Arabia", "as"),
|
||||
"+971": ("United Arab Emirates", "as"),
|
||||
"+972": ("Israel", "as"),
|
||||
"+974": ("Qatar", "as"),
|
||||
"+351": ("Portugal", "eu"),
|
||||
}
|
||||
|
||||
DEFAULT_REGION = "eu"
|
||||
|
||||
# ── HMAC signing ───────────────────────────────────────────────────────────────
|
||||
# Pre-computed from SonoffLAN's obfuscated key-derivation algorithm.
|
||||
# The algorithm builds a base64 string from the full REGIONS dict and indexes
|
||||
# into it — the result is always the same 32-byte ASCII key below.
|
||||
_SIGN_KEY: bytes = b"1ve5Qk9GXfUhKAn1svnKwpAlxXkMarru"
|
||||
|
||||
|
||||
def _sign(data: bytes) -> str:
|
||||
digest = hmac.new(_SIGN_KEY, data, hashlib.sha256).digest()
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
# ── LAN encryption (mirrors SonoffLAN local.py) ───────────────────────────────
|
||||
|
||||
def _pad(data: bytes, block_size: int = 16) -> bytes:
|
||||
padding_len = block_size - len(data) % block_size
|
||||
return data + bytes([padding_len]) * padding_len
|
||||
|
||||
|
||||
def _unpad(data: bytes) -> bytes:
|
||||
return data[: -data[-1]]
|
||||
|
||||
|
||||
def _encrypt_payload(payload: dict, device_key: str) -> dict:
|
||||
"""AES-CBC encrypt the 'data' field of a LAN payload."""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Hash import MD5
|
||||
from Crypto.Random import get_random_bytes
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"pycryptodome is required for encrypted LAN control. "
|
||||
"Install with: pip install pycryptodome"
|
||||
)
|
||||
|
||||
key = MD5.new(device_key.encode()).digest()
|
||||
iv = get_random_bytes(16)
|
||||
plaintext = json.dumps(payload["data"]).encode()
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
ciphertext = cipher.encrypt(_pad(plaintext))
|
||||
|
||||
payload = dict(payload)
|
||||
payload["encrypt"] = True
|
||||
payload["data"] = base64.b64encode(ciphertext).decode()
|
||||
payload["iv"] = base64.b64encode(iv).decode()
|
||||
return payload
|
||||
|
||||
|
||||
def _decrypt_payload(data_b64: str, iv_b64: str, device_key: str) -> dict:
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Hash import MD5
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
key = MD5.new(device_key.encode()).digest()
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=base64.b64decode(iv_b64))
|
||||
plaintext = _unpad(cipher.decrypt(base64.b64decode(data_b64)))
|
||||
return json.loads(plaintext)
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class EWeLinkAuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def login(username: str, password: str, country_code: str = "+1") -> dict:
|
||||
"""Authenticate with eWeLink and return auth dict.
|
||||
|
||||
Returns:
|
||||
{"at": <access_token>, "region": <region>, "apikey": <user_apikey>}
|
||||
Raises:
|
||||
EWeLinkAuthError on wrong credentials.
|
||||
requests.RequestException on network errors.
|
||||
"""
|
||||
region = REGIONS.get(country_code, (None, DEFAULT_REGION))[1]
|
||||
|
||||
payload: dict = {"password": password, "countryCode": country_code}
|
||||
if "@" in username:
|
||||
payload["email"] = username
|
||||
elif username.startswith("+"):
|
||||
payload["phoneNumber"] = username
|
||||
else:
|
||||
payload["phoneNumber"] = "+" + username
|
||||
|
||||
data = json.dumps(payload, separators=(",", ":")).encode()
|
||||
|
||||
headers = {
|
||||
"Authorization": "Sign " + _sign(data),
|
||||
"Content-Type": "application/json",
|
||||
"X-CK-Appid": APPID,
|
||||
}
|
||||
|
||||
r = requests.post(f"{API[region]}/v2/user/login",
|
||||
data=data, headers=headers, timeout=10)
|
||||
r.raise_for_status()
|
||||
resp = r.json()
|
||||
|
||||
# eWeLink may redirect to a different region
|
||||
if resp.get("error") == 10004:
|
||||
region = resp["data"]["region"]
|
||||
r = requests.post(f"{API[region]}/v2/user/login",
|
||||
data=data, headers=headers, timeout=10)
|
||||
r.raise_for_status()
|
||||
resp = r.json()
|
||||
|
||||
if resp.get("error") != 0:
|
||||
raise EWeLinkAuthError(resp.get("msg", "Login failed"))
|
||||
|
||||
d = resp["data"]
|
||||
return {
|
||||
"at": d["at"],
|
||||
"region": region,
|
||||
"apikey": d["user"]["apikey"],
|
||||
"username": username,
|
||||
}
|
||||
|
||||
|
||||
def get_devices(at: str, region: str) -> list[dict]:
|
||||
"""Retrieve all devices from eWeLink cloud.
|
||||
|
||||
Each device dict has at least:
|
||||
deviceid, name, params, extra.uiid, devicekey, online,
|
||||
localtype, ip (if LAN-reachable)
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID}
|
||||
url = f"{API[region]}/v2/device/thing"
|
||||
|
||||
r = requests.get(url, headers=headers, params={"num": 0}, timeout=15)
|
||||
r.raise_for_status()
|
||||
resp = r.json()
|
||||
|
||||
if resp.get("error") != 0:
|
||||
raise Exception(resp.get("msg", "Failed to fetch devices"))
|
||||
|
||||
devices = []
|
||||
for item in resp["data"].get("thingList", []):
|
||||
d = item.get("itemData", {})
|
||||
if "deviceid" in d:
|
||||
devices.append(d)
|
||||
return devices
|
||||
|
||||
|
||||
def send_lan_command(
|
||||
ip: str,
|
||||
port: int,
|
||||
device_id: str,
|
||||
device_key: str,
|
||||
params: dict,
|
||||
command: str = "switch",
|
||||
timeout: int = 5,
|
||||
) -> bool:
|
||||
"""Send a control command to a Sonoff device via local LAN.
|
||||
|
||||
Args:
|
||||
ip: Device IP address (without port)
|
||||
port: Device HTTP port (usually 8081)
|
||||
device_id: 10-char eWeLink device ID
|
||||
device_key: AES encryption key (blank string for DIY/unencrypted devices)
|
||||
params: e.g. {"switch": "on"} or {"switches": [{"outlet": 0, "switch": "on"}]}
|
||||
command: zeroconf command path (switch / switches / dimmable / etc.)
|
||||
timeout: HTTP request timeout in seconds
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
sequence = str(int(time.time() * 1000))
|
||||
payload: dict = {
|
||||
"sequence": sequence,
|
||||
"deviceid": device_id,
|
||||
"selfApikey": "123",
|
||||
"data": params,
|
||||
}
|
||||
|
||||
if device_key:
|
||||
payload = _encrypt_payload(payload, device_key)
|
||||
|
||||
url = f"http://{ip}:{port}/zeroconf/{command}"
|
||||
try:
|
||||
r = requests.post(
|
||||
url, json=payload,
|
||||
headers={"Connection": "close"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp = r.json()
|
||||
ok = resp.get("error") == 0
|
||||
if ok:
|
||||
logger.debug("LAN %s → %s OK", device_id, params)
|
||||
else:
|
||||
logger.warning("LAN %s → %s ERR %s", device_id, params, resp)
|
||||
return ok
|
||||
except requests.RequestException as exc:
|
||||
logger.debug("LAN %s → %s FAILED: %s", device_id, params, exc)
|
||||
return False
|
||||
|
||||
|
||||
def get_device_state_lan(ip: str, port: int, device_id: str,
|
||||
device_key: str, timeout: int = 5) -> dict | None:
|
||||
"""Poll a device's current state via LAN.
|
||||
Returns params dict or None on failure.
|
||||
"""
|
||||
sequence = str(int(time.time() * 1000))
|
||||
payload: dict = {
|
||||
"sequence": sequence,
|
||||
"deviceid": device_id,
|
||||
"selfApikey": "123",
|
||||
"data": {},
|
||||
}
|
||||
if device_key:
|
||||
payload = _encrypt_payload(payload, device_key)
|
||||
|
||||
url = f"http://{ip}:{port}/zeroconf/info"
|
||||
try:
|
||||
r = requests.post(
|
||||
url, json=payload,
|
||||
headers={"Connection": "close"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp = r.json()
|
||||
if resp.get("error") != 0:
|
||||
return None
|
||||
# If encrypted, decrypt; otherwise data is already dict
|
||||
if resp.get("encrypt") and device_key:
|
||||
return _decrypt_payload(resp["data"], resp["iv"], device_key)
|
||||
data = resp.get("data", {})
|
||||
if isinstance(data, str):
|
||||
return json.loads(data)
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.debug("LAN getState %s FAILED: %s", device_id, exc)
|
||||
return None
|
||||
|
||||
|
||||
def parse_device(raw: dict) -> dict:
|
||||
"""Normalise a raw eWeLink cloud device dict into a clean summary."""
|
||||
extra = raw.get("extra", {})
|
||||
params = raw.get("params", {})
|
||||
return {
|
||||
"device_id": raw.get("deviceid", ""),
|
||||
"name": raw.get("name", ""),
|
||||
"uiid": extra.get("uiid", 1),
|
||||
"model": extra.get("model", ""),
|
||||
"firmware": params.get("fwVersion", ""),
|
||||
"device_key": raw.get("devicekey", ""),
|
||||
"api_key": raw.get("apikey", ""),
|
||||
"online": raw.get("online", False),
|
||||
"params": params,
|
||||
# LAN info (if available in this snapshot — usually not in REST response)
|
||||
"ip_address": raw.get("localNetworkInfo", {}).get("ip", ""),
|
||||
"port": raw.get("localNetworkInfo", {}).get("port", 8081),
|
||||
}
|
||||
Reference in New Issue
Block a user