From 30806560a65d04e0d5df23ca6116d05b32556af1 Mon Sep 17 00:00:00 2001 From: ske087 Date: Thu, 26 Feb 2026 20:13:07 +0200 Subject: [PATCH] Fix SonoffLAN signing key: hardcode pre-computed HMAC key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/__init__.py | 18 ++ app/drivers/sonoff_ewelink/__init__.py | 0 app/drivers/sonoff_ewelink/driver.py | 306 +++++++++++++++++++ app/drivers/sonoff_ewelink/ewelink_api.py | 353 ++++++++++++++++++++++ app/drivers/sonoff_ewelink/manifest.json | 5 + app/models/__init__.py | 1 + app/models/board.py | 22 ++ app/models/sonoff_device.py | 210 +++++++++++++ app/routes/boards.py | 12 +- app/routes/sonoff.py | 242 +++++++++++++++ app/templates/base.html | 2 +- app/templates/boards/add.html | 54 +++- app/templates/boards/list.html | 12 +- app/templates/sonoff/auth_settings.html | 104 +++++++ app/templates/sonoff/device.html | 281 +++++++++++++++++ app/templates/sonoff/gateway.html | 260 ++++++++++++++++ requirements.txt | 1 + 17 files changed, 1864 insertions(+), 19 deletions(-) create mode 100644 app/drivers/sonoff_ewelink/__init__.py create mode 100644 app/drivers/sonoff_ewelink/driver.py create mode 100644 app/drivers/sonoff_ewelink/ewelink_api.py create mode 100644 app/drivers/sonoff_ewelink/manifest.json create mode 100644 app/models/sonoff_device.py create mode 100644 app/routes/sonoff.py create mode 100644 app/templates/sonoff/auth_settings.html create mode 100644 app/templates/sonoff/device.html create mode 100644 app/templates/sonoff/gateway.html diff --git a/app/__init__.py b/app/__init__.py index d501875..81aad63 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -35,6 +35,7 @@ def create_app(config_name: str = "default") -> Flask: from app.routes.workflows import workflows_bp from app.routes.api import api_bp from app.routes.admin import admin_bp + from app.routes.sonoff import sonoff_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -42,6 +43,7 @@ def create_app(config_name: str = "default") -> Flask: app.register_blueprint(workflows_bp, url_prefix="/workflows") app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(admin_bp, url_prefix="/admin") + app.register_blueprint(sonoff_bp) # ── user loader ─────────────────────────────────────────────────────────── from app.models.user import User @@ -57,9 +59,13 @@ def create_app(config_name: str = "default") -> Flask: # ── create tables & seed admin on first run ─────────────────────────────── with app.app_context(): + # Import all models so their tables are registered before create_all + from app.models import board, user, workflow # noqa: F401 + from app.models import sonoff_device # noqa: F401 db.create_all() _seed_admin(app) _add_entities_column(app) + _add_config_json_column(app) _migrate_board_types(app) return app @@ -77,6 +83,18 @@ def _add_entities_column(app: Flask) -> None: db.session.rollback() # Column already exists — safe to ignore +def _add_config_json_column(app: Flask) -> None: + """Add config_json column to boards table if it doesn't exist yet.""" + from sqlalchemy import text + with app.app_context(): + try: + db.session.execute(text("ALTER TABLE boards ADD COLUMN config_json TEXT DEFAULT '{}'")) + db.session.commit() + app.logger.info("Added config_json column to boards table") + except Exception: + db.session.rollback() # Column already exists — safe to ignore + + def _migrate_board_types(app: Flask) -> None: """Rename legacy board_type values to current driver IDs.""" from app.models.board import Board diff --git a/app/drivers/sonoff_ewelink/__init__.py b/app/drivers/sonoff_ewelink/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/drivers/sonoff_ewelink/driver.py b/app/drivers/sonoff_ewelink/driver.py new file mode 100644 index 0000000..10f0575 --- /dev/null +++ b/app/drivers/sonoff_ewelink/driver.py @@ -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 diff --git a/app/drivers/sonoff_ewelink/ewelink_api.py b/app/drivers/sonoff_ewelink/ewelink_api.py new file mode 100644 index 0000000..a962403 --- /dev/null +++ b/app/drivers/sonoff_ewelink/ewelink_api.py @@ -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": , "region": , "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), + } diff --git a/app/drivers/sonoff_ewelink/manifest.json b/app/drivers/sonoff_ewelink/manifest.json new file mode 100644 index 0000000..dde394b --- /dev/null +++ b/app/drivers/sonoff_ewelink/manifest.json @@ -0,0 +1,5 @@ +{ + "driver_id": "sonoff_ewelink", + "name": "Sonoff eWeLink Gateway", + "description": "Control all Sonoff devices via eWeLink cloud account (LAN + Cloud)" +} diff --git a/app/models/__init__.py b/app/models/__init__.py index 503a24c..f63747b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,5 +2,6 @@ from .user import User from .board import Board from .workflow import Workflow +from .sonoff_device import SonoffDevice __all__ = ["User", "Board", "Workflow"] diff --git a/app/models/board.py b/app/models/board.py index 7893b67..8004aa6 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -40,6 +40,9 @@ class Board(db.Model): firmware_version = db.Column(db.String(64), nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) + # Extra driver-specific config (JSON) – e.g. eWeLink credentials/token + config_json = db.Column(db.Text, default="{}") + # ── relationships ──────────────────────────────────────────────── workflows_trigger = db.relationship( "Workflow", foreign_keys="Workflow.trigger_board_id", @@ -49,6 +52,10 @@ class Board(db.Model): "Workflow", foreign_keys="Workflow.action_board_id", back_populates="action_board" ) + sonoff_devices = db.relationship( + "SonoffDevice", back_populates="board", + cascade="all, delete-orphan", lazy="dynamic" + ) # ── helpers ────────────────────────────────────────────────────── @property @@ -130,6 +137,21 @@ class Board(db.Model): "idle_label": tdef["idle"][1], } + @property + def config(self) -> dict: + try: + return json.loads(self.config_json or "{}") + except (ValueError, TypeError): + return {} + + @config.setter + def config(self, value: dict): + self.config_json = json.dumps(value) + + @property + def is_sonoff_gateway(self) -> bool: + return self.board_type == "sonoff_ewelink" + def get_relay_label(self, relay_num: int) -> str: return self.get_relay_entity(relay_num)["name"] diff --git a/app/models/sonoff_device.py b/app/models/sonoff_device.py new file mode 100644 index 0000000..c4519bf --- /dev/null +++ b/app/models/sonoff_device.py @@ -0,0 +1,210 @@ +"""SonoffDevice model – represents an individual Sonoff sub-device +belonging to a Sonoff eWeLink Gateway board.""" +import json +from datetime import datetime +from app import db + + +# ----------------------------------------------------------------- +# Device-type → human label + channel count heuristics +# Based on UIIDs from SonoffLAN devices.py +# ----------------------------------------------------------------- +UIID_INFO: dict[int, dict] = { + 1: {"label": "Basic / BASICR2", "channels": 1, "kind": "switch"}, + 2: {"label": "Dual Switch", "channels": 2, "kind": "switch"}, + 3: {"label": "Triple Switch", "channels": 3, "kind": "switch"}, + 4: {"label": "Quad Switch (4CH)", "channels": 4, "kind": "switch"}, + 5: {"label": "POW (Power Meter)", "channels": 1, "kind": "switch", "has_power": True}, + 6: {"label": "Basic (3rd-party)", "channels": 1, "kind": "switch"}, + 7: {"label": "T1 2C", "channels": 2, "kind": "switch"}, + 8: {"label": "T1 3C", "channels": 3, "kind": "switch"}, + 9: {"label": "T1 4C", "channels": 4, "kind": "switch"}, + 14: {"label": "Basic (3rd-party)", "channels": 1, "kind": "switch"}, + 15: {"label": "Sonoff TH (Thermostat)", "channels": 1, "kind": "sensor", + "has_temp": True, "has_humidity": True}, + 22: {"label": "Slampher B1", "channels": 1, "kind": "light"}, + 24: {"label": "GSM Socket", "channels": 1, "kind": "switch"}, + 28: {"label": "RF Bridge 433", "channels": 0, "kind": "remote"}, + 29: {"label": "Dual Switch (2CH)", "channels": 2, "kind": "switch"}, + 30: {"label": "Triple Switch (3CH)", "channels": 3, "kind": "switch"}, + 31: {"label": "Quad Switch (4CH)", "channels": 4, "kind": "switch"}, + 32: {"label": "POWR2 (Power Meter)", "channels": 1, "kind": "switch", "has_power": True}, + 34: {"label": "iFan02/iFan03", "channels": 1, "kind": "fan"}, + 44: {"label": "D1 Dimmer", "channels": 1, "kind": "light"}, + 57: {"label": "Micro USB", "channels": 1, "kind": "switch"}, + 59: {"label": "LED Light Strip L1", "channels": 1, "kind": "light"}, + 77: {"label": "Micro Switch", "channels": 1, "kind": "switch"}, + 78: {"label": "Micro Switch 2CH", "channels": 2, "kind": "switch"}, + 81: {"label": "Switch (compact)", "channels": 1, "kind": "switch"}, + 82: {"label": "Switch 2CH (compact)", "channels": 2, "kind": "switch"}, + 83: {"label": "Switch 3CH (compact)", "channels": 3, "kind": "switch"}, + 84: {"label": "Switch 4CH (compact)", "channels": 4, "kind": "switch"}, + 102: {"label": "DW2 Door/Window Sensor", "channels": 0, "kind": "sensor", + "has_door": True}, + 107: {"label": "MINIR3", "channels": 1, "kind": "switch"}, + 109: {"label": "DUAL R3", "channels": 2, "kind": "switch"}, + 126: {"label": "DUAL R3 Lite", "channels": 2, "kind": "switch"}, + 130: {"label": "POWR3 (Power Meter)", "channels": 1, "kind": "switch", "has_power": True}, + 133: {"label": "ZBMINI (ZigBee)", "channels": 1, "kind": "switch"}, + 136: {"label": "B05-BL Light", "channels": 1, "kind": "light"}, + 138: {"label": "MINIR4", "channels": 1, "kind": "switch"}, + 160: {"label": "SwitchMan M5-1C", "channels": 1, "kind": "switch"}, + 161: {"label": "SwitchMan M5-2C", "channels": 2, "kind": "switch"}, + 162: {"label": "SwitchMan M5-3C", "channels": 3, "kind": "switch"}, + 190: {"label": "NSPanel", "channels": 2, "kind": "switch"}, + 195: {"label": "NSPanel Pro", "channels": 1, "kind": "switch"}, +} + + +def _uiid_info(uiid: int) -> dict: + return UIID_INFO.get(uiid, {"label": f"Sonoff UIID-{uiid}", "channels": 1, "kind": "switch"}) + + +class SonoffDevice(db.Model): + __tablename__ = "sonoff_devices" + + id = db.Column(db.Integer, primary_key=True) + board_id = db.Column(db.Integer, db.ForeignKey("boards.id"), nullable=False) + + # eWeLink device ID (10-char string, e.g. "1000abcdef") + device_id = db.Column(db.String(20), nullable=False) + # User-visible name + name = db.Column(db.String(128), default="") + # LAN address (host:port) — populated when device reachable on LAN + ip_address = db.Column(db.String(64), default="") + port = db.Column(db.Integer, default=8081) + # eWeLink UIID (determines device capabilities/type) + uiid = db.Column(db.Integer, default=1) + # Number of controllable channels + num_channels = db.Column(db.Integer, default=1) + # Firmware/model string + model = db.Column(db.String(64), default="") + firmware = db.Column(db.String(32), default="") + # AES encryption key (needed for non-DIY LAN control) + device_key = db.Column(db.String(128), default="") + # eWeLink device apikey (owner's apikey) + api_key = db.Column(db.String(128), default="") + # Current device state as JSON {"switch":"on"} or {"switches":[...]} + params_json = db.Column(db.Text, default="{}") + # Whether device is currently online + is_online = db.Column(db.Boolean, default=False) + last_seen = db.Column(db.DateTime, nullable=True) + + board = db.relationship("Board", back_populates="sonoff_devices") + + # ── param helpers ───────────────────────────────────────────────────────── + + @property + def params(self) -> dict: + try: + return json.loads(self.params_json or "{}") + except (ValueError, TypeError): + return {} + + @params.setter + def params(self, value: dict): + self.params_json = json.dumps(value) + + # ── channel state helpers ───────────────────────────────────────────────── + + def get_channel_state(self, channel: int = 0) -> bool: + """Return True if channel is ON. + For single-channel devices uses 'switch' param. + For multi-channel uses 'switches' array with outlet index. + """ + p = self.params + if self.num_channels == 1: + return p.get("switch") == "on" + switches = p.get("switches", []) + for s in switches: + if s.get("outlet") == channel: + return s.get("switch") == "on" + return False + + def all_channel_states(self) -> list[dict]: + """Return [{channel, label, state}, ...] for every channel.""" + result = [] + for ch in range(max(self.num_channels, 1)): + result.append({ + "channel": ch, + "label": f"Channel {ch + 1}", + "state": self.get_channel_state(ch), + }) + return result + + # ── sensor helpers ──────────────────────────────────────────────────────── + + @property + def temperature(self) -> float | None: + p = self.params + for key in ("temperature", "currentTemperature"): + if key in p: + try: + return float(p[key]) + except (TypeError, ValueError): + pass + return None + + @property + def humidity(self) -> float | None: + p = self.params + for key in ("humidity", "currentHumidity"): + if key in p: + try: + return float(p[key]) + except (TypeError, ValueError): + pass + return None + + @property + def power(self) -> float | None: + v = self.params.get("power") + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + @property + def voltage(self) -> float | None: + v = self.params.get("voltage") + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + @property + def current(self) -> float | None: + v = self.params.get("current") + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + # ── display helpers ─────────────────────────────────────────────────────── + + @property + def device_info(self) -> dict: + return _uiid_info(self.uiid) + + @property + def device_label(self) -> str: + return self.device_info.get("label", f"UIID-{self.uiid}") + + @property + def kind(self) -> str: + return self.device_info.get("kind", "switch") + + @property + def has_power_meter(self) -> bool: + return self.device_info.get("has_power", False) + + @property + def has_temperature(self) -> bool: + return self.device_info.get("has_temp", False) + + @property + def has_door_sensor(self) -> bool: + return self.device_info.get("has_door", False) + + def __repr__(self): + return f"" diff --git a/app/routes/boards.py b/app/routes/boards.py index 288a78b..bbc7e6b 100644 --- a/app/routes/boards.py +++ b/app/routes/boards.py @@ -32,6 +32,9 @@ def list_boards(): @login_required def board_detail(board_id: int): board = db.get_or_404(Board, board_id) + # Sonoff eWeLink gateway boards have their own page + if board.board_type == "sonoff_ewelink": + return redirect(url_for("sonoff.gateway", board_id=board_id)) # Refresh states from device poll_board(current_app._get_current_object(), board_id) board = db.session.get(Board, board_id) @@ -54,10 +57,17 @@ def add_board(): num_relays = int(request.form.get("num_relays", 4)) num_inputs = int(request.form.get("num_inputs", 4)) - if not name or not host: + # Sonoff gateway doesn't need a real host address + is_gateway = board_type == "sonoff_ewelink" + if not name or (not host and not is_gateway): flash("Name and host are required.", "danger") return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all()) + if is_gateway: + host = host or "ewelink.cloud" + num_relays = 0 + num_inputs = 0 + board = Board( name=name, board_type=board_type, diff --git a/app/routes/sonoff.py b/app/routes/sonoff.py new file mode 100644 index 0000000..8ba1a87 --- /dev/null +++ b/app/routes/sonoff.py @@ -0,0 +1,242 @@ +"""Sonoff eWeLink Gateway routes. + +URL structure: + GET /sonoff/ – gateway overview + GET /sonoff//auth – auth settings form + POST /sonoff//auth – save credentials + login + POST /sonoff//sync – sync devices from cloud + GET /sonoff//device/ – device detail + POST /sonoff//device//ch//on|off|toggle – relay control + POST /sonoff//device//rename – rename a device +""" +import json +from flask import ( + Blueprint, + abort, + flash, + jsonify, + redirect, + render_template, + request, + url_for, +) +from flask_login import current_user, login_required + +from app import db, socketio +from app.models.board import Board +from app.models.sonoff_device import SonoffDevice +from app.drivers.registry import registry + +sonoff_bp = Blueprint("sonoff", __name__, url_prefix="/sonoff") + + +def _get_gateway(board_id: int) -> Board: + board = db.get_or_404(Board, board_id) + if board.board_type != "sonoff_ewelink": + abort(404) + return board + + +def _get_driver(board: Board): + drv = registry.get("sonoff_ewelink") + if drv is None: + abort(500) + return drv + + +# ── Gateway overview ────────────────────────────────────────────────────────── + +@sonoff_bp.route("/") +@login_required +def gateway(board_id: int): + board = _get_gateway(board_id) + devices = SonoffDevice.query.filter_by(board_id=board_id).order_by( + SonoffDevice.name + ).all() + has_credentials = bool(board.config.get("ewelink_username") and + board.config.get("ewelink_password")) + return render_template( + "sonoff/gateway.html", + board=board, + devices=devices, + has_credentials=has_credentials, + ) + + +# ── Auth settings ───────────────────────────────────────────────────────────── + +@sonoff_bp.route("//auth", methods=["GET", "POST"]) +@login_required +def auth_settings(board_id: int): + if not current_user.is_admin(): + abort(403) + board = _get_gateway(board_id) + + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + country = request.form.get("country_code", "+1").strip() + + if not username or not password: + flash("Username and password are required.", "danger") + return redirect(url_for("sonoff.auth_settings", board_id=board_id)) + + cfg = board.config + cfg["ewelink_username"] = username + cfg["ewelink_password"] = password + cfg["ewelink_country_code"] = country + cfg.pop("ewelink_at", None) # force re-login + board.config = cfg + db.session.commit() + + # Try to login immediately + drv = _get_driver(board) + if drv.ensure_token(board): + db.session.commit() + flash("✓ Connected to eWeLink successfully.", "success") + return redirect(url_for("sonoff.gateway", board_id=board_id)) + else: + flash("Login failed — check credentials.", "danger") + return redirect(url_for("sonoff.auth_settings", board_id=board_id)) + + # Country codes for dropdown + from app.drivers.sonoff_ewelink.ewelink_api import REGIONS + countries = sorted( + [(code, f"{info[0]} ({code})") for code, info in REGIONS.items()], + key=lambda x: x[1] + ) + return render_template( + "sonoff/auth_settings.html", + board=board, + countries=countries, + current_country=board.config.get("ewelink_country_code", "+1"), + ) + + +# ── Sync devices ────────────────────────────────────────────────────────────── + +@sonoff_bp.route("//sync", methods=["POST"]) +@login_required +def sync_devices(board_id: int): + if not current_user.is_admin(): + abort(403) + board = _get_gateway(board_id) + drv = _get_driver(board) + + try: + count = drv.sync_devices(board) + db.session.commit() + flash(f"✓ Synced {count} devices from eWeLink.", "success") + except Exception as exc: + flash(f"Sync failed: {exc}", "danger") + + return redirect(url_for("sonoff.gateway", board_id=board_id)) + + +# ── Device detail ───────────────────────────────────────────────────────────── + +@sonoff_bp.route("//device/") +@login_required +def device_detail(board_id: int, device_id: str): + board = _get_gateway(board_id) + dev = SonoffDevice.query.filter_by( + board_id=board_id, device_id=device_id + ).first_or_404() + return render_template("sonoff/device.html", board=board, device=dev) + + +# ── Relay / channel control ─────────────────────────────────────────────────── + +@sonoff_bp.route( + "//device//ch//", + methods=["POST"], +) +@login_required +def control_channel(board_id: int, device_id: str, channel: int, action: str): + board = _get_gateway(board_id) + dev = SonoffDevice.query.filter_by( + board_id=board_id, device_id=device_id + ).first_or_404() + drv = _get_driver(board) + + if action == "on": + new_state = True + elif action == "off": + new_state = False + elif action == "toggle": + new_state = not dev.get_channel_state(channel) + else: + abort(400) + + ok = drv.set_device_channel(board, device_id, channel, new_state) + + # Emit SocketIO update + socketio.emit("sonoff_update", { + "board_id": board_id, + "device_id": device_id, + "channel": channel, + "state": new_state, + "ok": ok, + }) + + if request.accept_mimetypes.best == "application/json" or \ + request.headers.get("Accept") == "application/json": + return jsonify({ + "ok": ok, + "state": new_state, + "device_id": device_id, + "channel": channel, + }) + + if not ok: + flash(f"⚠ Command sent but device may not have responded.", "warning") + return redirect(url_for("sonoff.gateway", board_id=board_id)) + + +# ── Rename device ───────────────────────────────────────────────────────────── + +@sonoff_bp.route( + "//device//rename", methods=["POST"] +) +@login_required +def rename_device(board_id: int, device_id: str): + if not current_user.is_admin(): + abort(403) + board = _get_gateway(board_id) + dev = SonoffDevice.query.filter_by( + board_id=board_id, device_id=device_id + ).first_or_404() + + new_name = request.form.get("name", "").strip()[:64] + if new_name: + dev.name = new_name + db.session.commit() + + if request.headers.get("Accept") == "application/json": + return jsonify({"ok": True, "name": dev.name}) + return redirect(url_for("sonoff.device_detail", + board_id=board_id, device_id=device_id)) + + +# ── Update LAN IP for a device ──────────────────────────────────────────────── + +@sonoff_bp.route( + "//device//set_ip", methods=["POST"] +) +@login_required +def set_device_ip(board_id: int, device_id: str): + if not current_user.is_admin(): + abort(403) + board = _get_gateway(board_id) + dev = SonoffDevice.query.filter_by( + board_id=board_id, device_id=device_id + ).first_or_404() + + ip = request.form.get("ip", "").strip() + port = int(request.form.get("port", 8081)) + dev.ip_address = ip + dev.port = port + db.session.commit() + flash(f"✓ LAN address updated for {dev.name}.", "success") + return redirect(url_for("sonoff.device_detail", + board_id=board_id, device_id=device_id)) diff --git a/app/templates/base.html b/app/templates/base.html index f0e0208..06f6575 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -27,7 +27,7 @@
  • + class="nav-link text-white {% if 'boards.' in request.endpoint or 'sonoff.' in request.endpoint %}active{% endif %}"> Boards
  • diff --git a/app/templates/boards/add.html b/app/templates/boards/add.html index b1c1e42..40de3b8 100644 --- a/app/templates/boards/add.html +++ b/app/templates/boards/add.html @@ -28,24 +28,42 @@
    -
    -
    - - + {# ── Hardware board fields (hidden for Sonoff gateway) ── #} +
    +
    +
    + + +
    +
    + + +
    -
    - - +
    +
    + + +
    +
    + + +
    -
    -
    - - -
    -
    - - + + {# ── Sonoff gateway info ── #} +
    +
    + +
    + Sonoff eWeLink Gateway
    + + After adding, you'll be redirected to set up your eWeLink account credentials. + All your Sonoff devices will be discovered automatically from the cloud. + LAN control is used when devices are reachable on the local network. + +
    @@ -69,6 +87,12 @@ function updateDefaults(type) { document.getElementById("num_relays").value = d.relays; document.getElementById("num_inputs").value = d.inputs; document.getElementById("driver_desc").textContent = d.desc; + + const isGateway = type === "sonoff_ewelink"; + document.getElementById("hw_fields").classList.toggle("d-none", isGateway); + document.getElementById("sonoff_info").classList.toggle("d-none", !isGateway); + // host is not required for gateway + document.getElementById("host_input").required = !isGateway; } // init on page load updateDefaults(document.getElementById("board_type_select").value); diff --git a/app/templates/boards/list.html b/app/templates/boards/list.html index 81b3e40..90b4bd9 100644 --- a/app/templates/boards/list.html +++ b/app/templates/boards/list.html @@ -22,7 +22,15 @@ {% for b in boards %} - {{ b.name }} + + {% if b.board_type == 'sonoff_ewelink' %} + + {{ b.name }} + + {% else %} + {{ b.name }} + {% endif %} + {{ b.board_type }} {{ b.host }}:{{ b.port }} {{ b.num_relays }} @@ -36,7 +44,7 @@ {% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %} - + {% if current_user.is_admin() %}
    + + Back to Gateway + + +
    +
    +
    eWeLink Account Settings
    +
    +
    + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +

    + Enter your eWeLink account credentials. These are stored on the server and used + to authenticate with the eWeLink cloud API. + Tip: create a secondary eWeLink account and share your devices + with it to avoid being logged out of the mobile app. +

    + + +
    + + +
    Use email or full phone number (e.g. +1234567890)
    +
    + +
    + +
    + + +
    +
    + +
    + + +
    + Determines which eWeLink server region to use (EU, US, AS, CN) +
    +
    + +
    + + + Cancel + +
    + + + {% if board.config.get('ewelink_at') %} +
    +

    + + Currently connected to eWeLink + (region: {{ board.config.get('ewelink_region', '?') }}) +

    + {% endif %} +
    +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/sonoff/device.html b/app/templates/sonoff/device.html new file mode 100644 index 0000000..5245a57 --- /dev/null +++ b/app/templates/sonoff/device.html @@ -0,0 +1,281 @@ +{% extends "base.html" %} +{% block title %}{{ device.name or device.device_id }} – Sonoff Device{% endblock %} + +{% block content %} +
    + + {# breadcrumb #} + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {# ── Device header card ───────────────────────────────────────────── #} +
    +
    +
    +
    +

    + {% set kind_icon = { + 'switch': 'bi-toggles', 'light': 'bi-lightbulb-fill', + 'fan': 'bi-fan', 'sensor': 'bi-thermometer-half', + 'remote': 'bi-broadcast', 'cover': 'bi-door-open' + }.get(device.kind, 'bi-plug') %} + + {{ device.name or device.device_id }} +

    +
    + {{ device.device_label }} + {% if device.firmware %}  ·  v{{ device.firmware }}{% endif %} + {% if device.model %}  ·  {{ device.model }}{% endif %} +
    +
    + {% if device.is_online %} + Online + {% else %} + Offline + {% endif %} +
    +
    + {# Rename form #} + {% if current_user.is_admin() %} +
    + + +
    + {% endif %} +
    +
    +
    + + {# ── Channel controls ─────────────────────────────────────────────── #} + {% if device.kind in ('switch', 'light', 'fan') and device.num_channels > 0 %} +
    +
    + Channels +
    +
    +
    + {% for ch in device.all_channel_states() %} + {% set ch_on = ch.state %} +
    +
    +
    {{ ch.label }}
    +
    + + +
    +
    +
    + {% endfor %} +
    +
    +
    + {% endif %} + + {# ── Sensor readings ──────────────────────────────────────────────── #} + {% if device.temperature is not none or device.humidity is not none or device.power is not none %} +
    +
    + Sensor Readings +
    +
    +
    + {% if device.temperature is not none %} +
    +
    {{ device.temperature }}°C
    +
    Temperature
    +
    + {% endif %} + {% if device.humidity is not none %} +
    +
    {{ device.humidity }}%
    +
    Humidity
    +
    + {% endif %} + {% if device.power is not none %} +
    +
    {{ device.power }}W
    +
    Power
    +
    + {% endif %} + {% if device.voltage is not none %} +
    +
    {{ device.voltage }}V
    +
    Voltage
    +
    + {% endif %} + {% if device.current is not none %} +
    +
    {{ device.current }}A
    +
    Current
    +
    + {% endif %} +
    +
    +
    + {% endif %} + + {# ── Device info ──────────────────────────────────────────────────── #} +
    +
    + Device Info +
    +
    + + + + + + + {% if device.firmware %} + + {% endif %} + + + + + + + + + {% if device.last_seen %} + + + + + {% endif %} + +
    Device ID{{ device.device_id }}
    UIID{{ device.uiid }} ({{ device.device_label }})
    Type{{ device.kind }}
    Channels{{ device.num_channels }}
    Firmware{{ device.firmware }}
    LAN IP{{ device.ip_address or 'Not detected'|safe }}
    Connection + {% if device.ip_address %} + LAN available + {% else %} + Cloud only + {% endif %} +
    Last Seen{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
    +
    +
    + + {# ── Set LAN IP manually ──────────────────────────────────────────── #} + {% if current_user.is_admin() %} +
    +
    + Manual LAN Override +
    +
    +

    + By default the LAN IP is auto-detected during cloud sync. + You can override it here if the device is reachable on a static IP. +

    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    + {% endif %} + + {# ── Raw params ───────────────────────────────────────────────────── #} +
    + + Raw Params + + +
    +
    +
    {{ device.params_json }}
    +
    +
    +
    + +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/sonoff/gateway.html b/app/templates/sonoff/gateway.html new file mode 100644 index 0000000..91bea56 --- /dev/null +++ b/app/templates/sonoff/gateway.html @@ -0,0 +1,260 @@ +{% extends "base.html" %} +{% block title %}{{ board.name }} – Sonoff Gateway{% endblock %} + +{% block content %} +
    + + {# ── Page header ──────────────────────────────────────────────────── #} +
    +
    + + + +
    +

    + {{ board.name }} +

    + Sonoff eWeLink Gateway + {% if board.is_online %} + Online + {% else %} + Offline + {% endif %} + +
    +
    +
    + {% if current_user.is_admin() %} +
    + +
    + + eWeLink Settings + + {% endif %} +
    +
    + + {# ── Flash messages ───────────────────────────────────────────────── #} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {# ── No credentials warning ───────────────────────────────────────── #} + {% if not has_credentials %} + + {% endif %} + + {# ── Device grid ──────────────────────────────────────────────────── #} + {% if devices %} +
    + {% for dev in devices %} +
    +
    +
    +
    + {# Kind icon #} + {% set kind_icon = { + 'switch': 'bi-toggles', 'light': 'bi-lightbulb-fill', + 'fan': 'bi-fan', 'sensor': 'bi-thermometer-half', + 'remote': 'bi-broadcast', 'cover': 'bi-door-open' + }.get(dev.kind, 'bi-plug') %} + + {{ dev.name or dev.device_id }} +
    +
    + {% if dev.is_online %} + Online + {% else %} + Offline + {% endif %} + + + +
    +
    +
    +
    + {{ dev.device_label }} + {% if dev.firmware %}v{{ dev.firmware }}{% endif %} +
    + + {# ── Switch/relay device ────────────────────────────────────── #} + {% if dev.kind in ('switch', 'light', 'fan') and dev.num_channels > 0 %} +
    + {% for ch in dev.all_channel_states() %} + {% set ch_on = ch.state %} +
    + {{ ch.label }} +
    + + +
    +
    + {% endfor %} +
    + + {# ── Sensor device ──────────────────────────────────────────── #} + {% elif dev.kind == 'sensor' %} +
    + {% if dev.temperature is not none %} +
    +
    {{ dev.temperature }}°C
    +
    Temperature
    +
    + {% endif %} + {% if dev.humidity is not none %} +
    +
    {{ dev.humidity }}%
    +
    Humidity
    +
    + {% endif %} +
    + + {# ── Power meter ────────────────────────────────────────────── #} + {% if dev.has_power_meter %} +
    + {% if dev.power is not none %} +
    +
    {{ dev.power }}W
    +
    Power
    +
    + {% endif %} + {% if dev.voltage is not none %} +
    +
    {{ dev.voltage }}V
    +
    Voltage
    +
    + {% endif %} + {% if dev.current is not none %} +
    +
    {{ dev.current }}A
    +
    Current
    +
    + {% endif %} +
    + {% endif %} + + {# ── RF Bridge / Remote ─────────────────────────────────────── #} + {% elif dev.kind == 'remote' %} +
    RF Bridge – cloud only
    + {% endif %} + +
    + ID: {{ dev.device_id }} + {% if dev.ip_address %} +  ·  {{ dev.ip_address }} + {% endif %} +
    +
    +
    +
    + {% endfor %} +
    + + {% else %} +
    + +

    No devices found.

    + {% if has_credentials %} +
    + +
    + {% else %} + + Configure eWeLink Account + + {% endif %} +
    + {% endif %} + +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/requirements.txt b/requirements.txt index 46f8049..f17f2e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ python-dotenv==1.0.1 WTForms==3.1.2 SQLAlchemy==2.0.31 greenlet>=3.1.1 +pycryptodome==3.23.0