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:
0
app/drivers/sonoff_ewelink/__init__.py
Normal file
0
app/drivers/sonoff_ewelink/__init__.py
Normal file
306
app/drivers/sonoff_ewelink/driver.py
Normal file
306
app/drivers/sonoff_ewelink/driver.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Sonoff eWeLink Gateway Driver.
|
||||
|
||||
This driver represents an entire eWeLink account as a single "board".
|
||||
Each discovered Sonoff device is stored as a SonoffDevice record linked
|
||||
to the board. Device control goes through the LAN (if device IP is known)
|
||||
with automatic cloud fallback.
|
||||
|
||||
Key differences from hardware boards:
|
||||
- relay_num / input_num are NOT used (Sonoff devices are managed separately)
|
||||
- poll() syncs the SonoffDevice table from cloud
|
||||
- set_relay() / toggle_relay() are stubs (use set_device_channel() instead)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.drivers.base import BoardDriver
|
||||
from .ewelink_api import (
|
||||
EWeLinkAuthError,
|
||||
get_devices,
|
||||
login,
|
||||
parse_device,
|
||||
send_lan_command,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.board import Board
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SonoffEweLinkDriver(BoardDriver):
|
||||
DRIVER_ID = "sonoff_ewelink"
|
||||
DISPLAY_NAME = "Sonoff eWeLink Gateway"
|
||||
DESCRIPTION = (
|
||||
"Control all Sonoff devices in an eWeLink account over LAN and/or Cloud"
|
||||
)
|
||||
DEFAULT_NUM_RELAYS = 0
|
||||
DEFAULT_NUM_INPUTS = 0
|
||||
|
||||
# ── Abstract method stubs (not used for gateway boards) ───────────────────
|
||||
|
||||
def get_relay_status(self, board: "Board", relay_num: int):
|
||||
return None
|
||||
|
||||
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
|
||||
return False
|
||||
|
||||
def toggle_relay(self, board: "Board", relay_num: int):
|
||||
return None
|
||||
|
||||
def register_webhook(self, board: "Board", callback_url: str) -> bool:
|
||||
return True # No webhook concept for this driver
|
||||
|
||||
def poll(self, board: "Board") -> dict:
|
||||
"""Sync device list + states from eWeLink cloud.
|
||||
|
||||
Returns a minimal dict so board_service doesn't break.
|
||||
Actual device states are stored in SonoffDevice records.
|
||||
"""
|
||||
try:
|
||||
self.sync_devices(board)
|
||||
board.is_online = True
|
||||
except Exception as exc:
|
||||
logger.warning("Sonoff poll failed for board %d: %s", board.id, exc)
|
||||
board.is_online = False
|
||||
return {
|
||||
"relay_states": {},
|
||||
"input_states": {},
|
||||
"is_online": board.is_online,
|
||||
}
|
||||
|
||||
# ── Gateway-specific public API ───────────────────────────────────────────
|
||||
|
||||
def ensure_token(self, board: "Board") -> bool:
|
||||
"""Authenticate with eWeLink if no valid token is stored.
|
||||
Updates board.config in-place (caller must commit).
|
||||
Returns True if token is available.
|
||||
"""
|
||||
cfg = board.config
|
||||
at = cfg.get("ewelink_at")
|
||||
if at:
|
||||
return True
|
||||
|
||||
username = cfg.get("ewelink_username", "")
|
||||
password = cfg.get("ewelink_password", "")
|
||||
country = cfg.get("ewelink_country_code", "+1")
|
||||
if not username or not password:
|
||||
logger.error("Board %d has no eWeLink credentials", board.id)
|
||||
return False
|
||||
|
||||
try:
|
||||
auth = login(username, password, country)
|
||||
cfg["ewelink_at"] = auth["at"]
|
||||
cfg["ewelink_region"] = auth["region"]
|
||||
cfg["ewelink_apikey"] = auth["apikey"]
|
||||
board.config = cfg
|
||||
logger.info("eWeLink login OK for board %d (region=%s)", board.id, auth["region"])
|
||||
return True
|
||||
except EWeLinkAuthError as exc:
|
||||
logger.error("eWeLink auth error for board %d: %s", board.id, exc)
|
||||
cfg.pop("ewelink_at", None)
|
||||
board.config = cfg
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("eWeLink login failed for board %d: %s", board.id, exc)
|
||||
return False
|
||||
|
||||
def sync_devices(self, board: "Board") -> int:
|
||||
"""Fetch device list from cloud, upsert SonoffDevice records.
|
||||
Returns number of devices synced.
|
||||
"""
|
||||
from app import db
|
||||
from app.models.sonoff_device import SonoffDevice
|
||||
from datetime import datetime
|
||||
|
||||
if not self.ensure_token(board):
|
||||
return 0
|
||||
|
||||
cfg = board.config
|
||||
at = cfg["ewelink_at"]
|
||||
region = cfg.get("ewelink_region", "eu")
|
||||
|
||||
try:
|
||||
raw_devices = get_devices(at, region)
|
||||
except Exception as exc:
|
||||
# Token may have expired — clear it and retry once
|
||||
if "401" in str(exc) or "token" in str(exc).lower():
|
||||
cfg.pop("ewelink_at", None)
|
||||
board.config = cfg
|
||||
if not self.ensure_token(board):
|
||||
return 0
|
||||
at = board.config["ewelink_at"]
|
||||
region = board.config.get("ewelink_region", "eu")
|
||||
raw_devices = get_devices(at, region)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Build lookup of existing records
|
||||
existing: dict[str, SonoffDevice] = {
|
||||
d.device_id: d
|
||||
for d in SonoffDevice.query.filter_by(board_id=board.id).all()
|
||||
}
|
||||
|
||||
synced = 0
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
for raw in raw_devices:
|
||||
info = parse_device(raw)
|
||||
did = info["device_id"]
|
||||
if not did:
|
||||
continue
|
||||
seen_ids.add(did)
|
||||
|
||||
dev = existing.get(did)
|
||||
if dev is None:
|
||||
from app.models.sonoff_device import _uiid_info
|
||||
dev = SonoffDevice(board_id=board.id, device_id=did)
|
||||
db.session.add(dev)
|
||||
|
||||
# Always update these from cloud
|
||||
if not dev.name:
|
||||
dev.name = info["name"]
|
||||
dev.uiid = info["uiid"]
|
||||
dev.model = info["model"]
|
||||
dev.firmware = info["firmware"]
|
||||
dev.device_key = info["device_key"]
|
||||
dev.api_key = info["api_key"]
|
||||
dev.is_online = info["online"]
|
||||
|
||||
from app.models.sonoff_device import _uiid_info
|
||||
dev.num_channels = _uiid_info(info["uiid"]).get("channels", 1)
|
||||
|
||||
if info["params"]:
|
||||
dev.params = info["params"]
|
||||
|
||||
if info["ip_address"]:
|
||||
dev.ip_address = info["ip_address"]
|
||||
dev.port = info["port"] or 8081
|
||||
|
||||
if info["online"]:
|
||||
dev.last_seen = datetime.utcnow()
|
||||
|
||||
synced += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info("Board %d: synced %d Sonoff devices", board.id, synced)
|
||||
return synced
|
||||
|
||||
def set_device_channel(
|
||||
self,
|
||||
board: "Board",
|
||||
device_id: str,
|
||||
channel: int,
|
||||
state: bool,
|
||||
) -> bool:
|
||||
"""Turn a Sonoff device channel ON or OFF.
|
||||
Tries LAN first, falls back to cloud.
|
||||
"""
|
||||
from app.models.sonoff_device import SonoffDevice
|
||||
dev = SonoffDevice.query.filter_by(
|
||||
board_id=board.id, device_id=device_id
|
||||
).first()
|
||||
if not dev:
|
||||
logger.error("Device %s not found on board %d", device_id, board.id)
|
||||
return False
|
||||
|
||||
switch_val = "on" if state else "off"
|
||||
|
||||
# Build params dict
|
||||
if dev.num_channels == 1:
|
||||
params = {"switch": switch_val}
|
||||
command = "switch"
|
||||
else:
|
||||
params = {"switches": [{"outlet": channel, "switch": switch_val}]}
|
||||
command = "switches"
|
||||
|
||||
# ── Try LAN first ───────────────────────────────────────────────────
|
||||
if dev.ip_address:
|
||||
ok = send_lan_command(
|
||||
ip=dev.ip_address,
|
||||
port=dev.port or 8081,
|
||||
device_id=device_id,
|
||||
device_key=dev.device_key or "",
|
||||
params=params,
|
||||
command=command,
|
||||
)
|
||||
if ok:
|
||||
self._apply_state(dev, channel, state)
|
||||
from app import db
|
||||
db.session.commit()
|
||||
return True
|
||||
logger.debug("LAN failed for %s, trying cloud", device_id)
|
||||
|
||||
# ── Cloud fallback ───────────────────────────────────────────────────
|
||||
if not self.ensure_token(board):
|
||||
return False
|
||||
|
||||
cfg = board.config
|
||||
ok = self._cloud_set(
|
||||
at=cfg["ewelink_at"],
|
||||
region=cfg.get("ewelink_region", "eu"),
|
||||
device_id=device_id,
|
||||
api_key=dev.api_key or cfg.get("ewelink_apikey", ""),
|
||||
params=params,
|
||||
)
|
||||
if ok:
|
||||
self._apply_state(dev, channel, state)
|
||||
from app import db
|
||||
db.session.commit()
|
||||
return ok
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _apply_state(dev, channel: int, state: bool) -> None:
|
||||
"""Update the SonoffDevice params in-memory after a successful command."""
|
||||
p = dev.params
|
||||
switch_val = "on" if state else "off"
|
||||
if dev.num_channels == 1:
|
||||
p["switch"] = switch_val
|
||||
else:
|
||||
switches = p.get("switches", [])
|
||||
updated = False
|
||||
for s in switches:
|
||||
if s.get("outlet") == channel:
|
||||
s["switch"] = switch_val
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
switches.append({"outlet": channel, "switch": switch_val})
|
||||
p["switches"] = switches
|
||||
dev.params = p
|
||||
|
||||
@staticmethod
|
||||
def _cloud_set(at: str, region: str, device_id: str,
|
||||
api_key: str, params: dict) -> bool:
|
||||
"""Send a device update via the eWeLink cloud REST API (simple HTTP POST).
|
||||
|
||||
This uses the v2 update-device endpoint rather than the WebSocket,
|
||||
which is simpler for synchronous Flask code.
|
||||
"""
|
||||
import requests as req
|
||||
from .ewelink_api import API, APPID
|
||||
import time
|
||||
|
||||
headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID}
|
||||
payload = {
|
||||
"deviceid": device_id,
|
||||
"params": params,
|
||||
}
|
||||
url = f"{API[region]}/v2/device/thing/status"
|
||||
try:
|
||||
r = req.post(url, json=payload, headers=headers, timeout=8)
|
||||
resp = r.json()
|
||||
ok = resp.get("error") == 0
|
||||
if ok:
|
||||
logger.debug("Cloud %s → %s OK", device_id, params)
|
||||
else:
|
||||
logger.warning("Cloud %s → %s ERR %s", device_id, params, resp)
|
||||
return ok
|
||||
except Exception as exc:
|
||||
logger.error("Cloud send failed for %s: %s", device_id, exc)
|
||||
return False
|
||||
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),
|
||||
}
|
||||
5
app/drivers/sonoff_ewelink/manifest.json
Normal file
5
app/drivers/sonoff_ewelink/manifest.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"driver_id": "sonoff_ewelink",
|
||||
"name": "Sonoff eWeLink Gateway",
|
||||
"description": "Control all Sonoff devices via eWeLink cloud account (LAN + Cloud)"
|
||||
}
|
||||
Reference in New Issue
Block a user