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:
ske087
2026-02-26 20:13:07 +02:00
parent 7a22575dab
commit 30806560a6
17 changed files with 1864 additions and 19 deletions

View File

@@ -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

View File

View 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

View 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),
}

View File

@@ -0,0 +1,5 @@
{
"driver_id": "sonoff_ewelink",
"name": "Sonoff eWeLink Gateway",
"description": "Control all Sonoff devices via eWeLink cloud account (LAN + Cloud)"
}

View File

@@ -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"]

View File

@@ -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"]

210
app/models/sonoff_device.py Normal file
View File

@@ -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"<SonoffDevice {self.device_id} {self.name!r}>"

View File

@@ -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,

242
app/routes/sonoff.py Normal file
View File

@@ -0,0 +1,242 @@
"""Sonoff eWeLink Gateway routes.
URL structure:
GET /sonoff/<board_id> gateway overview
GET /sonoff/<board_id>/auth auth settings form
POST /sonoff/<board_id>/auth save credentials + login
POST /sonoff/<board_id>/sync sync devices from cloud
GET /sonoff/<board_id>/device/<device_id> device detail
POST /sonoff/<board_id>/device/<device_id>/ch/<ch>/on|off|toggle relay control
POST /sonoff/<board_id>/device/<device_id>/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("/<int:board_id>")
@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("/<int:board_id>/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("/<int:board_id>/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("/<int:board_id>/device/<device_id>")
@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(
"/<int:board_id>/device/<device_id>/ch/<int:channel>/<action>",
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(
"/<int:board_id>/device/<device_id>/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(
"/<int:board_id>/device/<device_id>/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))

View File

@@ -27,7 +27,7 @@
</li>
<li>
<a href="{{ url_for('boards.list_boards') }}"
class="nav-link text-white {% if 'boards.' in request.endpoint %}active{% endif %}">
class="nav-link text-white {% if 'boards.' in request.endpoint or 'sonoff.' in request.endpoint %}active{% endif %}">
<i class="bi bi-motherboard me-2"></i>Boards
</a>
</li>

View File

@@ -28,24 +28,42 @@
</select>
<div class="form-text text-secondary" id="driver_desc"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label">IP Address / Hostname</label>
<input type="text" name="host" class="form-control font-monospace" placeholder="192.168.1.100" required />
{# ── Hardware board fields (hidden for Sonoff gateway) ── #}
<div id="hw_fields">
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label">IP Address / Hostname</label>
<input type="text" name="host" id="host_input" class="form-control font-monospace" placeholder="192.168.1.100" />
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
</div>
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label">Number of Relays</label>
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="0" max="32" />
</div>
<div class="col-6">
<label class="form-label">Number of Inputs</label>
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label">Number of Relays</label>
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="1" max="32" />
</div>
<div class="col-6">
<label class="form-label">Number of Inputs</label>
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
{# ── Sonoff gateway info ── #}
<div id="sonoff_info" class="d-none mb-4">
<div class="alert alert-info d-flex gap-3 align-items-start py-3">
<i class="bi bi-cloud-lightning-fill fs-4 text-warning mt-1"></i>
<div>
<strong>Sonoff eWeLink Gateway</strong><br>
<span class="small">
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.
</span>
</div>
</div>
</div>
<div class="d-flex gap-2">
@@ -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);

View File

@@ -22,7 +22,15 @@
<tbody>
{% for b in boards %}
<tr>
<td><a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a></td>
<td>
{% if b.board_type == 'sonoff_ewelink' %}
<a href="{{ url_for('sonoff.gateway', board_id=b.id) }}" class="fw-semibold text-decoration-none">
<i class="bi bi-cloud-lightning-fill text-warning me-1"></i>{{ b.name }}
</a>
{% else %}
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a>
{% endif %}
</td>
<td><span class="badge text-bg-secondary">{{ b.board_type }}</span></td>
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
<td>{{ b.num_relays }}</td>
@@ -36,7 +44,7 @@
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
<a href="{% if b.board_type == 'sonoff_ewelink' %}{{ url_for('sonoff.gateway', board_id=b.id) }}{% else %}{{ url_for('boards.board_detail', board_id=b.id) }}{% endif %}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.edit_board', board_id=b.id) }}" class="btn btn-sm btn-outline-secondary me-1"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('boards.delete_board', board_id=b.id) }}" class="d-inline"

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} eWeLink Settings{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:560px">
<a href="{{ url_for('sonoff.gateway', board_id=board.id) }}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i>Back to Gateway
</a>
<div class="card bg-dark border-secondary">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-key-fill me-2 text-warning"></i>eWeLink Account Settings</h5>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} py-2 alert-dismissible fade show" role="alert">
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<p class="text-muted small mb-4">
Enter your eWeLink account credentials. These are stored on the server and used
to authenticate with the eWeLink cloud API.
<strong>Tip:</strong> create a secondary eWeLink account and share your devices
with it to avoid being logged out of the mobile app.
</p>
<form method="post">
<div class="mb-3">
<label class="form-label">Email or Phone Number</label>
<input type="text" name="username" class="form-control"
value="{{ board.config.get('ewelink_username', '') }}"
placeholder="you@example.com" required>
<div class="form-text text-muted">Use email or full phone number (e.g. +1234567890)</div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" name="password" id="pwdInput" class="form-control"
placeholder="eWeLink password" required>
<button type="button" class="btn btn-outline-secondary" id="pwdToggle">
<i class="bi bi-eye" id="pwdIcon"></i>
</button>
</div>
</div>
<div class="mb-4">
<label class="form-label">Country / Region</label>
<select name="country_code" class="form-select">
{% for code, label in countries %}
<option value="{{ code }}" {% if code == current_country %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<div class="form-text text-muted">
Determines which eWeLink server region to use (EU, US, AS, CN)
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save & Connect
</button>
<a href="{{ url_for('sonoff.gateway', board_id=board.id) }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
{% if board.config.get('ewelink_at') %}
<hr class="border-secondary mt-4">
<p class="small text-success mb-0">
<i class="bi bi-check-circle me-1"></i>
Currently connected to eWeLink
(region: <strong>{{ board.config.get('ewelink_region', '?') }}</strong>)
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('pwdToggle').addEventListener('click', function() {
const inp = document.getElementById('pwdInput');
const icon = document.getElementById('pwdIcon');
if (inp.type === 'password') {
inp.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
inp.type = 'password';
icon.className = 'bi bi-eye';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}{{ device.name or device.device_id }} Sonoff Device{% endblock %}
{% block content %}
<div class="container py-3" style="max-width:720px">
{# breadcrumb #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item">
<a href="{{ url_for('boards.list_boards') }}" class="text-decoration-none">Boards</a>
</li>
<li class="breadcrumb-item">
<a href="{{ url_for('sonoff.gateway', board_id=board.id) }}" class="text-decoration-none">
{{ board.name }}
</a>
</li>
<li class="breadcrumb-item active">{{ device.name or device.device_id }}</li>
</ol>
</nav>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show py-2" role="alert">
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{# ── Device header card ───────────────────────────────────────────── #}
<div class="card bg-dark border-secondary mb-3">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap">
<div>
<h4 class="mb-1 fw-bold">
{% 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') %}
<i class="bi {{ kind_icon }} text-warning me-2"></i>
{{ device.name or device.device_id }}
</h4>
<div class="text-muted small">
{{ device.device_label }}
{% if device.firmware %} &nbsp;·&nbsp; v{{ device.firmware }}{% endif %}
{% if device.model %} &nbsp;·&nbsp; {{ device.model }}{% endif %}
</div>
<div class="mt-1">
{% if device.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
</div>
</div>
{# Rename form #}
{% if current_user.is_admin() %}
<form method="post" action="{{ url_for('sonoff.rename_device', board_id=board.id, device_id=device.device_id) }}"
class="d-flex gap-2 align-items-center">
<input type="text" name="name" class="form-control form-control-sm"
value="{{ device.name }}" maxlength="64" placeholder="Device name" style="width:180px">
<button type="submit" class="btn btn-outline-light btn-sm">
<i class="bi bi-pencil"></i>
</button>
</form>
{% endif %}
</div>
</div>
</div>
{# ── Channel controls ─────────────────────────────────────────────── #}
{% if device.kind in ('switch', 'light', 'fan') and device.num_channels > 0 %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-toggles me-1"></i>Channels
</div>
<div class="card-body py-2">
<div class="row g-3">
{% for ch in device.all_channel_states() %}
{% set ch_on = ch.state %}
<div class="col-6 col-sm-4" id="ch-{{ device.device_id }}-{{ ch.channel }}">
<div class="border border-secondary rounded p-2 text-center">
<div class="small text-muted mb-2">{{ ch.label }}</div>
<div class="d-flex justify-content-center gap-2">
<button
class="btn btn-sm sonoff-ch-btn {{ 'btn-success' if ch_on else 'btn-outline-success' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=device.device_id, channel=ch.channel, action='on') }}"
data-device="{{ device.device_id }}" data-ch="{{ ch.channel }}" data-target="on">ON
</button>
<button
class="btn btn-sm sonoff-ch-btn {{ 'btn-danger' if not ch_on else 'btn-outline-danger' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=device.device_id, channel=ch.channel, action='off') }}"
data-device="{{ device.device_id }}" data-ch="{{ ch.channel }}" data-target="off">OFF
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# ── Sensor readings ──────────────────────────────────────────────── #}
{% if device.temperature is not none or device.humidity is not none or device.power is not none %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-bar-chart-fill me-1"></i>Sensor Readings
</div>
<div class="card-body">
<div class="row text-center g-3">
{% if device.temperature is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-info">{{ device.temperature }}°C</div>
<div class="small text-muted">Temperature</div>
</div>
{% endif %}
{% if device.humidity is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-primary">{{ device.humidity }}%</div>
<div class="small text-muted">Humidity</div>
</div>
{% endif %}
{% if device.power is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-warning">{{ device.power }}W</div>
<div class="small text-muted">Power</div>
</div>
{% endif %}
{% if device.voltage is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-success">{{ device.voltage }}V</div>
<div class="small text-muted">Voltage</div>
</div>
{% endif %}
{% if device.current is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-danger">{{ device.current }}A</div>
<div class="small text-muted">Current</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{# ── Device info ──────────────────────────────────────────────────── #}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-info-circle me-1"></i>Device Info
</div>
<div class="card-body p-0">
<table class="table table-dark table-sm mb-0">
<tbody>
<tr><th class="ps-3 text-muted" width="140">Device ID</th><td><code>{{ device.device_id }}</code></td></tr>
<tr><th class="ps-3 text-muted">UIID</th><td>{{ device.uiid }} ({{ device.device_label }})</td></tr>
<tr><th class="ps-3 text-muted">Type</th><td>{{ device.kind }}</td></tr>
<tr><th class="ps-3 text-muted">Channels</th><td>{{ device.num_channels }}</td></tr>
{% if device.firmware %}
<tr><th class="ps-3 text-muted">Firmware</th><td>{{ device.firmware }}</td></tr>
{% endif %}
<tr>
<th class="ps-3 text-muted">LAN IP</th>
<td>{{ device.ip_address or '<span class="text-muted">Not detected</span>'|safe }}</td>
</tr>
<tr>
<th class="ps-3 text-muted">Connection</th>
<td>
{% if device.ip_address %}
<span class="text-success"><i class="bi bi-wifi me-1"></i>LAN available</span>
{% else %}
<span class="text-warning"><i class="bi bi-cloud me-1"></i>Cloud only</span>
{% endif %}
</td>
</tr>
{% if device.last_seen %}
<tr>
<th class="ps-3 text-muted">Last Seen</th>
<td>{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{# ── Set LAN IP manually ──────────────────────────────────────────── #}
{% if current_user.is_admin() %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-wifi me-1"></i>Manual LAN Override
</div>
<div class="card-body">
<p class="text-muted small mb-3">
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.
</p>
<form method="post"
action="{{ url_for('sonoff.set_device_ip', board_id=board.id, device_id=device.device_id) }}"
class="row g-2 align-items-end">
<div class="col-sm-6">
<label class="form-label form-label-sm">IP Address</label>
<input type="text" name="ip" class="form-control form-control-sm"
value="{{ device.ip_address }}" placeholder="192.168.1.100">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Port</label>
<input type="number" name="port" class="form-control form-control-sm"
value="{{ device.port or 8081 }}" min="1" max="65535">
</div>
<div class="col-sm-3">
<button type="submit" class="btn btn-sm btn-outline-light w-100">
<i class="bi bi-save me-1"></i>Save
</button>
</div>
</form>
</div>
</div>
{% endif %}
{# ── Raw params ───────────────────────────────────────────────────── #}
<div class="card bg-dark border-secondary">
<a class="card-header text-decoration-none text-muted small fw-semibold text-uppercase d-flex justify-content-between"
data-bs-toggle="collapse" href="#rawParams">
<span><i class="bi bi-code-slash me-1"></i>Raw Params</span>
<i class="bi bi-chevron-down"></i>
</a>
<div class="collapse" id="rawParams">
<div class="card-body">
<pre class="text-muted small mb-0" style="white-space:pre-wrap;word-break:break-all">{{ device.params_json }}</pre>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const _socket = io();
document.querySelectorAll(".sonoff-ch-btn").forEach(btn => {
btn.addEventListener("click", async function() {
const url = this.dataset.url;
const did = this.dataset.device;
const ch = parseInt(this.dataset.ch);
this.disabled = true;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json" },
});
const data = await resp.json();
if (data.ok) _applyChannelState(did, ch, data.state);
} catch(e) { console.error(e); }
finally { this.disabled = false; }
});
});
function _applyChannelState(did, ch, isOn) {
const container = document.getElementById(`ch-${did}-${ch}`);
if (!container) return;
const [onBtn, offBtn] = container.querySelectorAll(".sonoff-ch-btn");
if (isOn) {
onBtn.className = onBtn.className.replace("btn-outline-success", "btn-success");
offBtn.className = offBtn.className.replace("btn-danger", "btn-outline-danger");
} else {
onBtn.className = onBtn.className.replace("btn-success", "btn-outline-success");
offBtn.className = offBtn.className.replace("btn-outline-danger", "btn-danger");
}
}
_socket.on("sonoff_update", function(data) {
if (data.device_id !== "{{ device.device_id }}") return;
_applyChannelState(data.device_id, data.channel, data.state);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,260 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} Sonoff Gateway{% endblock %}
{% block content %}
<div class="container-fluid py-3">
{# ── Page header ──────────────────────────────────────────────────── #}
<div class="d-flex align-items-center justify-content-between mb-4 gap-2 flex-wrap">
<div class="d-flex align-items-center gap-3">
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<div>
<h4 class="mb-0 fw-bold">
<i class="bi bi-cloud-lightning-fill text-warning me-2"></i>{{ board.name }}
</h4>
<small class="text-muted">Sonoff eWeLink Gateway
{% if board.is_online %}
<span class="badge bg-success ms-2">Online</span>
{% else %}
<span class="badge bg-secondary ms-2">Offline</span>
{% endif %}
</small>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
{% if current_user.is_admin() %}
<form method="post" action="{{ url_for('sonoff.sync_devices', board_id=board.id) }}">
<button class="btn btn-primary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Sync Devices
</button>
</form>
<a href="{{ url_for('sonoff.auth_settings', board_id=board.id) }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-key me-1"></i>eWeLink Settings
</a>
{% endif %}
</div>
</div>
{# ── Flash messages ───────────────────────────────────────────────── #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show py-2" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{# ── No credentials warning ───────────────────────────────────────── #}
{% if not has_credentials %}
<div class="alert alert-warning d-flex align-items-center gap-3" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
<div>
<strong>eWeLink credentials not configured.</strong>
<a href="{{ url_for('sonoff.auth_settings', board_id=board.id) }}" class="alert-link">
Set up your account
</a> to discover and control devices.
</div>
</div>
{% endif %}
{# ── Device grid ──────────────────────────────────────────────────── #}
{% if devices %}
<div class="row g-3">
{% for dev in devices %}
<div class="col-12 col-md-6 col-xl-4" id="sonoff-card-{{ dev.device_id }}">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<div class="d-flex align-items-center gap-2">
{# 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') %}
<i class="bi {{ kind_icon }} text-warning"></i>
<span class="fw-semibold text-truncate" style="max-width:160px"
title="{{ dev.name }}">{{ dev.name or dev.device_id }}</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if dev.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
<a href="{{ url_for('sonoff.device_detail', board_id=board.id, device_id=dev.device_id) }}"
class="btn btn-outline-secondary btn-sm px-2 py-0">
<i class="bi bi-sliders"></i>
</a>
</div>
</div>
<div class="card-body py-2">
<div class="text-muted small mb-2">
{{ dev.device_label }}
{% if dev.firmware %}<span class="ms-2 opacity-50">v{{ dev.firmware }}</span>{% endif %}
</div>
{# ── Switch/relay device ────────────────────────────────────── #}
{% if dev.kind in ('switch', 'light', 'fan') and dev.num_channels > 0 %}
<div class="d-flex flex-wrap gap-2">
{% for ch in dev.all_channel_states() %}
{% set ch_on = ch.state %}
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-2 py-1"
id="ch-{{ dev.device_id }}-{{ ch.channel }}"
style="min-width:min-content">
<span class="small text-muted">{{ ch.label }}</span>
<div class="d-flex gap-1">
<button
class="btn btn-sm px-2 py-0 sonoff-ch-btn {{ 'btn-success' if ch_on else 'btn-outline-success' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=dev.device_id, channel=ch.channel, action='on') }}"
data-device="{{ dev.device_id }}" data-ch="{{ ch.channel }}" data-target="on"
title="Turn ON"
>ON</button>
<button
class="btn btn-sm px-2 py-0 sonoff-ch-btn {{ 'btn-danger' if not ch_on else 'btn-outline-danger' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=dev.device_id, channel=ch.channel, action='off') }}"
data-device="{{ dev.device_id }}" data-ch="{{ ch.channel }}" data-target="off"
title="Turn OFF"
>OFF</button>
</div>
</div>
{% endfor %}
</div>
{# ── Sensor device ──────────────────────────────────────────── #}
{% elif dev.kind == 'sensor' %}
<div class="d-flex flex-wrap gap-3">
{% if dev.temperature is not none %}
<div class="text-center">
<div class="fs-4 fw-bold text-info">{{ dev.temperature }}°C</div>
<div class="small text-muted">Temperature</div>
</div>
{% endif %}
{% if dev.humidity is not none %}
<div class="text-center">
<div class="fs-4 fw-bold text-primary">{{ dev.humidity }}%</div>
<div class="small text-muted">Humidity</div>
</div>
{% endif %}
</div>
{# ── Power meter ────────────────────────────────────────────── #}
{% if dev.has_power_meter %}
<div class="d-flex flex-wrap gap-3 mt-2">
{% if dev.power is not none %}
<div class="text-center">
<div class="fs-5 fw-bold text-warning">{{ dev.power }}W</div>
<div class="small text-muted">Power</div>
</div>
{% endif %}
{% if dev.voltage is not none %}
<div class="text-center">
<div class="fs-5 fw-bold text-success">{{ dev.voltage }}V</div>
<div class="small text-muted">Voltage</div>
</div>
{% endif %}
{% if dev.current is not none %}
<div class="text-center">
<div class="fs-5 fw-bold text-danger">{{ dev.current }}A</div>
<div class="small text-muted">Current</div>
</div>
{% endif %}
</div>
{% endif %}
{# ── RF Bridge / Remote ─────────────────────────────────────── #}
{% elif dev.kind == 'remote' %}
<div class="text-muted small"><i class="bi bi-broadcast me-1"></i>RF Bridge cloud only</div>
{% endif %}
<div class="text-muted mt-2" style="font-size:.7rem">
ID: {{ dev.device_id }}
{% if dev.ip_address %}
&nbsp;·&nbsp;<i class="bi bi-wifi"></i> {{ dev.ip_address }}
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-cloud-slash fs-1 d-block mb-3"></i>
<p class="fs-5">No devices found.</p>
{% if has_credentials %}
<form method="post" action="{{ url_for('sonoff.sync_devices', board_id=board.id) }}">
<button class="btn btn-primary">
<i class="bi bi-arrow-repeat me-1"></i>Sync from eWeLink
</button>
</form>
{% else %}
<a href="{{ url_for('sonoff.auth_settings', board_id=board.id) }}" class="btn btn-warning">
<i class="bi bi-key me-1"></i>Configure eWeLink Account
</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
const _socket = io();
// ── AJAX channel control ──────────────────────────────────────────────────────
document.querySelectorAll(".sonoff-ch-btn").forEach(btn => {
btn.addEventListener("click", async function() {
const url = this.dataset.url;
const did = this.dataset.device;
const ch = parseInt(this.dataset.ch);
const tgt = this.dataset.target; // "on" or "off"
this.disabled = true;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json",
"X-Requested-With": "XMLHttpRequest" },
});
const data = await resp.json();
if (data.ok) {
_applyChannelState(did, ch, data.state);
}
} catch(e) { console.error(e); }
finally { this.disabled = false; }
});
});
function _applyChannelState(did, ch, isOn) {
const container = document.getElementById(`ch-${did}-${ch}`);
if (!container) return;
const [onBtn, offBtn] = container.querySelectorAll(".sonoff-ch-btn");
if (isOn) {
onBtn.className = onBtn.className.replace("btn-outline-success", "btn-success");
offBtn.className = offBtn.className.replace("btn-danger", "btn-outline-danger");
} else {
onBtn.className = onBtn.className.replace("btn-success", "btn-outline-success");
offBtn.className = offBtn.className.replace("btn-outline-danger", "btn-danger");
}
}
// ── SocketIO live updates ─────────────────────────────────────────────────────
_socket.on("sonoff_update", function(data) {
if (data.board_id !== {{ board.id }}) return;
_applyChannelState(data.device_id, data.channel, data.state);
// Optional: flash the card border briefly
const card = document.getElementById("sonoff-card-" + data.device_id);
if (card) {
card.querySelector(".card").classList.add("border-info");
setTimeout(() => card.querySelector(".card").classList.remove("border-info"), 800);
}
});
</script>
{% endblock %}

View File

@@ -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