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:
@@ -35,6 +35,7 @@ def create_app(config_name: str = "default") -> Flask:
|
|||||||
from app.routes.workflows import workflows_bp
|
from app.routes.workflows import workflows_bp
|
||||||
from app.routes.api import api_bp
|
from app.routes.api import api_bp
|
||||||
from app.routes.admin import admin_bp
|
from app.routes.admin import admin_bp
|
||||||
|
from app.routes.sonoff import sonoff_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_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(workflows_bp, url_prefix="/workflows")
|
||||||
app.register_blueprint(api_bp, url_prefix="/api")
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||||
|
app.register_blueprint(sonoff_bp)
|
||||||
|
|
||||||
# ── user loader ───────────────────────────────────────────────────────────
|
# ── user loader ───────────────────────────────────────────────────────────
|
||||||
from app.models.user import User
|
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 ───────────────────────────────
|
# ── create tables & seed admin on first run ───────────────────────────────
|
||||||
with app.app_context():
|
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()
|
db.create_all()
|
||||||
_seed_admin(app)
|
_seed_admin(app)
|
||||||
_add_entities_column(app)
|
_add_entities_column(app)
|
||||||
|
_add_config_json_column(app)
|
||||||
_migrate_board_types(app)
|
_migrate_board_types(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -77,6 +83,18 @@ def _add_entities_column(app: Flask) -> None:
|
|||||||
db.session.rollback() # Column already exists — safe to ignore
|
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:
|
def _migrate_board_types(app: Flask) -> None:
|
||||||
"""Rename legacy board_type values to current driver IDs."""
|
"""Rename legacy board_type values to current driver IDs."""
|
||||||
from app.models.board import Board
|
from app.models.board import Board
|
||||||
|
|||||||
0
app/drivers/sonoff_ewelink/__init__.py
Normal file
0
app/drivers/sonoff_ewelink/__init__.py
Normal file
306
app/drivers/sonoff_ewelink/driver.py
Normal file
306
app/drivers/sonoff_ewelink/driver.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"""Sonoff eWeLink Gateway Driver.
|
||||||
|
|
||||||
|
This driver represents an entire eWeLink account as a single "board".
|
||||||
|
Each discovered Sonoff device is stored as a SonoffDevice record linked
|
||||||
|
to the board. Device control goes through the LAN (if device IP is known)
|
||||||
|
with automatic cloud fallback.
|
||||||
|
|
||||||
|
Key differences from hardware boards:
|
||||||
|
- relay_num / input_num are NOT used (Sonoff devices are managed separately)
|
||||||
|
- poll() syncs the SonoffDevice table from cloud
|
||||||
|
- set_relay() / toggle_relay() are stubs (use set_device_channel() instead)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.drivers.base import BoardDriver
|
||||||
|
from .ewelink_api import (
|
||||||
|
EWeLinkAuthError,
|
||||||
|
get_devices,
|
||||||
|
login,
|
||||||
|
parse_device,
|
||||||
|
send_lan_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.board import Board
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SonoffEweLinkDriver(BoardDriver):
|
||||||
|
DRIVER_ID = "sonoff_ewelink"
|
||||||
|
DISPLAY_NAME = "Sonoff eWeLink Gateway"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Control all Sonoff devices in an eWeLink account over LAN and/or Cloud"
|
||||||
|
)
|
||||||
|
DEFAULT_NUM_RELAYS = 0
|
||||||
|
DEFAULT_NUM_INPUTS = 0
|
||||||
|
|
||||||
|
# ── Abstract method stubs (not used for gateway boards) ───────────────────
|
||||||
|
|
||||||
|
def get_relay_status(self, board: "Board", relay_num: int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def toggle_relay(self, board: "Board", relay_num: int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_webhook(self, board: "Board", callback_url: str) -> bool:
|
||||||
|
return True # No webhook concept for this driver
|
||||||
|
|
||||||
|
def poll(self, board: "Board") -> dict:
|
||||||
|
"""Sync device list + states from eWeLink cloud.
|
||||||
|
|
||||||
|
Returns a minimal dict so board_service doesn't break.
|
||||||
|
Actual device states are stored in SonoffDevice records.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.sync_devices(board)
|
||||||
|
board.is_online = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sonoff poll failed for board %d: %s", board.id, exc)
|
||||||
|
board.is_online = False
|
||||||
|
return {
|
||||||
|
"relay_states": {},
|
||||||
|
"input_states": {},
|
||||||
|
"is_online": board.is_online,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Gateway-specific public API ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def ensure_token(self, board: "Board") -> bool:
|
||||||
|
"""Authenticate with eWeLink if no valid token is stored.
|
||||||
|
Updates board.config in-place (caller must commit).
|
||||||
|
Returns True if token is available.
|
||||||
|
"""
|
||||||
|
cfg = board.config
|
||||||
|
at = cfg.get("ewelink_at")
|
||||||
|
if at:
|
||||||
|
return True
|
||||||
|
|
||||||
|
username = cfg.get("ewelink_username", "")
|
||||||
|
password = cfg.get("ewelink_password", "")
|
||||||
|
country = cfg.get("ewelink_country_code", "+1")
|
||||||
|
if not username or not password:
|
||||||
|
logger.error("Board %d has no eWeLink credentials", board.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth = login(username, password, country)
|
||||||
|
cfg["ewelink_at"] = auth["at"]
|
||||||
|
cfg["ewelink_region"] = auth["region"]
|
||||||
|
cfg["ewelink_apikey"] = auth["apikey"]
|
||||||
|
board.config = cfg
|
||||||
|
logger.info("eWeLink login OK for board %d (region=%s)", board.id, auth["region"])
|
||||||
|
return True
|
||||||
|
except EWeLinkAuthError as exc:
|
||||||
|
logger.error("eWeLink auth error for board %d: %s", board.id, exc)
|
||||||
|
cfg.pop("ewelink_at", None)
|
||||||
|
board.config = cfg
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("eWeLink login failed for board %d: %s", board.id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def sync_devices(self, board: "Board") -> int:
|
||||||
|
"""Fetch device list from cloud, upsert SonoffDevice records.
|
||||||
|
Returns number of devices synced.
|
||||||
|
"""
|
||||||
|
from app import db
|
||||||
|
from app.models.sonoff_device import SonoffDevice
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
if not self.ensure_token(board):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cfg = board.config
|
||||||
|
at = cfg["ewelink_at"]
|
||||||
|
region = cfg.get("ewelink_region", "eu")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_devices = get_devices(at, region)
|
||||||
|
except Exception as exc:
|
||||||
|
# Token may have expired — clear it and retry once
|
||||||
|
if "401" in str(exc) or "token" in str(exc).lower():
|
||||||
|
cfg.pop("ewelink_at", None)
|
||||||
|
board.config = cfg
|
||||||
|
if not self.ensure_token(board):
|
||||||
|
return 0
|
||||||
|
at = board.config["ewelink_at"]
|
||||||
|
region = board.config.get("ewelink_region", "eu")
|
||||||
|
raw_devices = get_devices(at, region)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Build lookup of existing records
|
||||||
|
existing: dict[str, SonoffDevice] = {
|
||||||
|
d.device_id: d
|
||||||
|
for d in SonoffDevice.query.filter_by(board_id=board.id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
synced = 0
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
for raw in raw_devices:
|
||||||
|
info = parse_device(raw)
|
||||||
|
did = info["device_id"]
|
||||||
|
if not did:
|
||||||
|
continue
|
||||||
|
seen_ids.add(did)
|
||||||
|
|
||||||
|
dev = existing.get(did)
|
||||||
|
if dev is None:
|
||||||
|
from app.models.sonoff_device import _uiid_info
|
||||||
|
dev = SonoffDevice(board_id=board.id, device_id=did)
|
||||||
|
db.session.add(dev)
|
||||||
|
|
||||||
|
# Always update these from cloud
|
||||||
|
if not dev.name:
|
||||||
|
dev.name = info["name"]
|
||||||
|
dev.uiid = info["uiid"]
|
||||||
|
dev.model = info["model"]
|
||||||
|
dev.firmware = info["firmware"]
|
||||||
|
dev.device_key = info["device_key"]
|
||||||
|
dev.api_key = info["api_key"]
|
||||||
|
dev.is_online = info["online"]
|
||||||
|
|
||||||
|
from app.models.sonoff_device import _uiid_info
|
||||||
|
dev.num_channels = _uiid_info(info["uiid"]).get("channels", 1)
|
||||||
|
|
||||||
|
if info["params"]:
|
||||||
|
dev.params = info["params"]
|
||||||
|
|
||||||
|
if info["ip_address"]:
|
||||||
|
dev.ip_address = info["ip_address"]
|
||||||
|
dev.port = info["port"] or 8081
|
||||||
|
|
||||||
|
if info["online"]:
|
||||||
|
dev.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
logger.info("Board %d: synced %d Sonoff devices", board.id, synced)
|
||||||
|
return synced
|
||||||
|
|
||||||
|
def set_device_channel(
|
||||||
|
self,
|
||||||
|
board: "Board",
|
||||||
|
device_id: str,
|
||||||
|
channel: int,
|
||||||
|
state: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Turn a Sonoff device channel ON or OFF.
|
||||||
|
Tries LAN first, falls back to cloud.
|
||||||
|
"""
|
||||||
|
from app.models.sonoff_device import SonoffDevice
|
||||||
|
dev = SonoffDevice.query.filter_by(
|
||||||
|
board_id=board.id, device_id=device_id
|
||||||
|
).first()
|
||||||
|
if not dev:
|
||||||
|
logger.error("Device %s not found on board %d", device_id, board.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
switch_val = "on" if state else "off"
|
||||||
|
|
||||||
|
# Build params dict
|
||||||
|
if dev.num_channels == 1:
|
||||||
|
params = {"switch": switch_val}
|
||||||
|
command = "switch"
|
||||||
|
else:
|
||||||
|
params = {"switches": [{"outlet": channel, "switch": switch_val}]}
|
||||||
|
command = "switches"
|
||||||
|
|
||||||
|
# ── Try LAN first ───────────────────────────────────────────────────
|
||||||
|
if dev.ip_address:
|
||||||
|
ok = send_lan_command(
|
||||||
|
ip=dev.ip_address,
|
||||||
|
port=dev.port or 8081,
|
||||||
|
device_id=device_id,
|
||||||
|
device_key=dev.device_key or "",
|
||||||
|
params=params,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
self._apply_state(dev, channel, state)
|
||||||
|
from app import db
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
logger.debug("LAN failed for %s, trying cloud", device_id)
|
||||||
|
|
||||||
|
# ── Cloud fallback ───────────────────────────────────────────────────
|
||||||
|
if not self.ensure_token(board):
|
||||||
|
return False
|
||||||
|
|
||||||
|
cfg = board.config
|
||||||
|
ok = self._cloud_set(
|
||||||
|
at=cfg["ewelink_at"],
|
||||||
|
region=cfg.get("ewelink_region", "eu"),
|
||||||
|
device_id=device_id,
|
||||||
|
api_key=dev.api_key or cfg.get("ewelink_apikey", ""),
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
self._apply_state(dev, channel, state)
|
||||||
|
from app import db
|
||||||
|
db.session.commit()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_state(dev, channel: int, state: bool) -> None:
|
||||||
|
"""Update the SonoffDevice params in-memory after a successful command."""
|
||||||
|
p = dev.params
|
||||||
|
switch_val = "on" if state else "off"
|
||||||
|
if dev.num_channels == 1:
|
||||||
|
p["switch"] = switch_val
|
||||||
|
else:
|
||||||
|
switches = p.get("switches", [])
|
||||||
|
updated = False
|
||||||
|
for s in switches:
|
||||||
|
if s.get("outlet") == channel:
|
||||||
|
s["switch"] = switch_val
|
||||||
|
updated = True
|
||||||
|
break
|
||||||
|
if not updated:
|
||||||
|
switches.append({"outlet": channel, "switch": switch_val})
|
||||||
|
p["switches"] = switches
|
||||||
|
dev.params = p
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cloud_set(at: str, region: str, device_id: str,
|
||||||
|
api_key: str, params: dict) -> bool:
|
||||||
|
"""Send a device update via the eWeLink cloud REST API (simple HTTP POST).
|
||||||
|
|
||||||
|
This uses the v2 update-device endpoint rather than the WebSocket,
|
||||||
|
which is simpler for synchronous Flask code.
|
||||||
|
"""
|
||||||
|
import requests as req
|
||||||
|
from .ewelink_api import API, APPID
|
||||||
|
import time
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID}
|
||||||
|
payload = {
|
||||||
|
"deviceid": device_id,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
url = f"{API[region]}/v2/device/thing/status"
|
||||||
|
try:
|
||||||
|
r = req.post(url, json=payload, headers=headers, timeout=8)
|
||||||
|
resp = r.json()
|
||||||
|
ok = resp.get("error") == 0
|
||||||
|
if ok:
|
||||||
|
logger.debug("Cloud %s → %s OK", device_id, params)
|
||||||
|
else:
|
||||||
|
logger.warning("Cloud %s → %s ERR %s", device_id, params, resp)
|
||||||
|
return ok
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Cloud send failed for %s: %s", device_id, exc)
|
||||||
|
return False
|
||||||
353
app/drivers/sonoff_ewelink/ewelink_api.py
Normal file
353
app/drivers/sonoff_ewelink/ewelink_api.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"""Synchronous eWeLink / Sonoff cloud API wrapper.
|
||||||
|
|
||||||
|
Uses the same authentication scheme as SonoffLAN (requests-based, no asyncio).
|
||||||
|
Supports:
|
||||||
|
- Login with email / phone number
|
||||||
|
- Device list retrieval (cloud)
|
||||||
|
- LAN relay control (HTTP to device, with optional AES encryption)
|
||||||
|
- Cloud relay control (HTTP fallback, not WebSocket — state only)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── API endpoints ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
APPID = "R8Oq3y0eSZSYdKccHlrQzT1ACCOUT9Gv"
|
||||||
|
|
||||||
|
API = {
|
||||||
|
"cn": "https://cn-apia.coolkit.cn",
|
||||||
|
"as": "https://as-apia.coolkit.cc",
|
||||||
|
"us": "https://us-apia.coolkit.cc",
|
||||||
|
"eu": "https://eu-apia.coolkit.cc",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Country code → (Country, region) — subset of full list, used only for
|
||||||
|
# auto-detecting the default region during login.
|
||||||
|
REGIONS: dict[str, tuple[str, str]] = {
|
||||||
|
"+1": ("United States", "us"),
|
||||||
|
"+7": ("Russia", "eu"),
|
||||||
|
"+20": ("Egypt", "eu"),
|
||||||
|
"+27": ("South Africa", "eu"),
|
||||||
|
"+30": ("Greece", "eu"),
|
||||||
|
"+31": ("Netherlands", "eu"),
|
||||||
|
"+32": ("Belgium", "eu"),
|
||||||
|
"+33": ("France", "eu"),
|
||||||
|
"+34": ("Spain", "eu"),
|
||||||
|
"+36": ("Hungary", "eu"),
|
||||||
|
"+39": ("Italy", "eu"),
|
||||||
|
"+40": ("Romania", "eu"),
|
||||||
|
"+41": ("Switzerland", "eu"),
|
||||||
|
"+43": ("Austria", "eu"),
|
||||||
|
"+44": ("UK", "eu"),
|
||||||
|
"+45": ("Denmark", "eu"),
|
||||||
|
"+46": ("Sweden", "eu"),
|
||||||
|
"+47": ("Norway", "eu"),
|
||||||
|
"+48": ("Poland", "eu"),
|
||||||
|
"+49": ("Germany", "eu"),
|
||||||
|
"+51": ("Peru", "us"),
|
||||||
|
"+52": ("Mexico", "us"),
|
||||||
|
"+54": ("Argentina", "us"),
|
||||||
|
"+55": ("Brazil", "us"),
|
||||||
|
"+56": ("Chile", "us"),
|
||||||
|
"+57": ("Colombia", "us"),
|
||||||
|
"+58": ("Venezuela", "us"),
|
||||||
|
"+60": ("Malaysia", "as"),
|
||||||
|
"+61": ("Australia", "us"),
|
||||||
|
"+62": ("Indonesia", "as"),
|
||||||
|
"+63": ("Philippines", "as"),
|
||||||
|
"+64": ("New Zealand", "us"),
|
||||||
|
"+65": ("Singapore", "as"),
|
||||||
|
"+66": ("Thailand", "as"),
|
||||||
|
"+81": ("Japan", "as"),
|
||||||
|
"+82": ("South Korea", "as"),
|
||||||
|
"+84": ("Vietnam", "as"),
|
||||||
|
"+86": ("China", "cn"),
|
||||||
|
"+90": ("Turkey", "as"),
|
||||||
|
"+91": ("India", "as"),
|
||||||
|
"+92": ("Pakistan", "as"),
|
||||||
|
"+94": ("Sri Lanka", "as"),
|
||||||
|
"+351": ("Portugal", "eu"),
|
||||||
|
"+352": ("Luxembourg", "eu"),
|
||||||
|
"+353": ("Ireland", "eu"),
|
||||||
|
"+354": ("Iceland", "eu"),
|
||||||
|
"+358": ("Finland", "eu"),
|
||||||
|
"+359": ("Bulgaria", "eu"),
|
||||||
|
"+370": ("Lithuania", "eu"),
|
||||||
|
"+371": ("Latvia", "eu"),
|
||||||
|
"+372": ("Estonia", "eu"),
|
||||||
|
"+380": ("Ukraine", "eu"),
|
||||||
|
"+381": ("Serbia", "eu"),
|
||||||
|
"+385": ("Croatia", "eu"),
|
||||||
|
"+386": ("Slovenia", "eu"),
|
||||||
|
"+420": ("Czech", "eu"),
|
||||||
|
"+421": ("Slovakia", "eu"),
|
||||||
|
"+886": ("Taiwan, China", "as"),
|
||||||
|
"+966": ("Saudi Arabia", "as"),
|
||||||
|
"+971": ("United Arab Emirates", "as"),
|
||||||
|
"+972": ("Israel", "as"),
|
||||||
|
"+974": ("Qatar", "as"),
|
||||||
|
"+351": ("Portugal", "eu"),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_REGION = "eu"
|
||||||
|
|
||||||
|
# ── HMAC signing ───────────────────────────────────────────────────────────────
|
||||||
|
# Pre-computed from SonoffLAN's obfuscated key-derivation algorithm.
|
||||||
|
# The algorithm builds a base64 string from the full REGIONS dict and indexes
|
||||||
|
# into it — the result is always the same 32-byte ASCII key below.
|
||||||
|
_SIGN_KEY: bytes = b"1ve5Qk9GXfUhKAn1svnKwpAlxXkMarru"
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(data: bytes) -> str:
|
||||||
|
digest = hmac.new(_SIGN_KEY, data, hashlib.sha256).digest()
|
||||||
|
return base64.b64encode(digest).decode()
|
||||||
|
|
||||||
|
# ── LAN encryption (mirrors SonoffLAN local.py) ───────────────────────────────
|
||||||
|
|
||||||
|
def _pad(data: bytes, block_size: int = 16) -> bytes:
|
||||||
|
padding_len = block_size - len(data) % block_size
|
||||||
|
return data + bytes([padding_len]) * padding_len
|
||||||
|
|
||||||
|
|
||||||
|
def _unpad(data: bytes) -> bytes:
|
||||||
|
return data[: -data[-1]]
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_payload(payload: dict, device_key: str) -> dict:
|
||||||
|
"""AES-CBC encrypt the 'data' field of a LAN payload."""
|
||||||
|
try:
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Hash import MD5
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pycryptodome is required for encrypted LAN control. "
|
||||||
|
"Install with: pip install pycryptodome"
|
||||||
|
)
|
||||||
|
|
||||||
|
key = MD5.new(device_key.encode()).digest()
|
||||||
|
iv = get_random_bytes(16)
|
||||||
|
plaintext = json.dumps(payload["data"]).encode()
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||||
|
ciphertext = cipher.encrypt(_pad(plaintext))
|
||||||
|
|
||||||
|
payload = dict(payload)
|
||||||
|
payload["encrypt"] = True
|
||||||
|
payload["data"] = base64.b64encode(ciphertext).decode()
|
||||||
|
payload["iv"] = base64.b64encode(iv).decode()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_payload(data_b64: str, iv_b64: str, device_key: str) -> dict:
|
||||||
|
try:
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Hash import MD5
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
key = MD5.new(device_key.encode()).digest()
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC, iv=base64.b64decode(iv_b64))
|
||||||
|
plaintext = _unpad(cipher.decrypt(base64.b64decode(data_b64)))
|
||||||
|
return json.loads(plaintext)
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class EWeLinkAuthError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def login(username: str, password: str, country_code: str = "+1") -> dict:
|
||||||
|
"""Authenticate with eWeLink and return auth dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"at": <access_token>, "region": <region>, "apikey": <user_apikey>}
|
||||||
|
Raises:
|
||||||
|
EWeLinkAuthError on wrong credentials.
|
||||||
|
requests.RequestException on network errors.
|
||||||
|
"""
|
||||||
|
region = REGIONS.get(country_code, (None, DEFAULT_REGION))[1]
|
||||||
|
|
||||||
|
payload: dict = {"password": password, "countryCode": country_code}
|
||||||
|
if "@" in username:
|
||||||
|
payload["email"] = username
|
||||||
|
elif username.startswith("+"):
|
||||||
|
payload["phoneNumber"] = username
|
||||||
|
else:
|
||||||
|
payload["phoneNumber"] = "+" + username
|
||||||
|
|
||||||
|
data = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Sign " + _sign(data),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CK-Appid": APPID,
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post(f"{API[region]}/v2/user/login",
|
||||||
|
data=data, headers=headers, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
|
||||||
|
# eWeLink may redirect to a different region
|
||||||
|
if resp.get("error") == 10004:
|
||||||
|
region = resp["data"]["region"]
|
||||||
|
r = requests.post(f"{API[region]}/v2/user/login",
|
||||||
|
data=data, headers=headers, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
|
||||||
|
if resp.get("error") != 0:
|
||||||
|
raise EWeLinkAuthError(resp.get("msg", "Login failed"))
|
||||||
|
|
||||||
|
d = resp["data"]
|
||||||
|
return {
|
||||||
|
"at": d["at"],
|
||||||
|
"region": region,
|
||||||
|
"apikey": d["user"]["apikey"],
|
||||||
|
"username": username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_devices(at: str, region: str) -> list[dict]:
|
||||||
|
"""Retrieve all devices from eWeLink cloud.
|
||||||
|
|
||||||
|
Each device dict has at least:
|
||||||
|
deviceid, name, params, extra.uiid, devicekey, online,
|
||||||
|
localtype, ip (if LAN-reachable)
|
||||||
|
"""
|
||||||
|
headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID}
|
||||||
|
url = f"{API[region]}/v2/device/thing"
|
||||||
|
|
||||||
|
r = requests.get(url, headers=headers, params={"num": 0}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
|
||||||
|
if resp.get("error") != 0:
|
||||||
|
raise Exception(resp.get("msg", "Failed to fetch devices"))
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for item in resp["data"].get("thingList", []):
|
||||||
|
d = item.get("itemData", {})
|
||||||
|
if "deviceid" in d:
|
||||||
|
devices.append(d)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def send_lan_command(
|
||||||
|
ip: str,
|
||||||
|
port: int,
|
||||||
|
device_id: str,
|
||||||
|
device_key: str,
|
||||||
|
params: dict,
|
||||||
|
command: str = "switch",
|
||||||
|
timeout: int = 5,
|
||||||
|
) -> bool:
|
||||||
|
"""Send a control command to a Sonoff device via local LAN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip: Device IP address (without port)
|
||||||
|
port: Device HTTP port (usually 8081)
|
||||||
|
device_id: 10-char eWeLink device ID
|
||||||
|
device_key: AES encryption key (blank string for DIY/unencrypted devices)
|
||||||
|
params: e.g. {"switch": "on"} or {"switches": [{"outlet": 0, "switch": "on"}]}
|
||||||
|
command: zeroconf command path (switch / switches / dimmable / etc.)
|
||||||
|
timeout: HTTP request timeout in seconds
|
||||||
|
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
"""
|
||||||
|
sequence = str(int(time.time() * 1000))
|
||||||
|
payload: dict = {
|
||||||
|
"sequence": sequence,
|
||||||
|
"deviceid": device_id,
|
||||||
|
"selfApikey": "123",
|
||||||
|
"data": params,
|
||||||
|
}
|
||||||
|
|
||||||
|
if device_key:
|
||||||
|
payload = _encrypt_payload(payload, device_key)
|
||||||
|
|
||||||
|
url = f"http://{ip}:{port}/zeroconf/{command}"
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url, json=payload,
|
||||||
|
headers={"Connection": "close"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
resp = r.json()
|
||||||
|
ok = resp.get("error") == 0
|
||||||
|
if ok:
|
||||||
|
logger.debug("LAN %s → %s OK", device_id, params)
|
||||||
|
else:
|
||||||
|
logger.warning("LAN %s → %s ERR %s", device_id, params, resp)
|
||||||
|
return ok
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.debug("LAN %s → %s FAILED: %s", device_id, params, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_state_lan(ip: str, port: int, device_id: str,
|
||||||
|
device_key: str, timeout: int = 5) -> dict | None:
|
||||||
|
"""Poll a device's current state via LAN.
|
||||||
|
Returns params dict or None on failure.
|
||||||
|
"""
|
||||||
|
sequence = str(int(time.time() * 1000))
|
||||||
|
payload: dict = {
|
||||||
|
"sequence": sequence,
|
||||||
|
"deviceid": device_id,
|
||||||
|
"selfApikey": "123",
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
if device_key:
|
||||||
|
payload = _encrypt_payload(payload, device_key)
|
||||||
|
|
||||||
|
url = f"http://{ip}:{port}/zeroconf/info"
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url, json=payload,
|
||||||
|
headers={"Connection": "close"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
resp = r.json()
|
||||||
|
if resp.get("error") != 0:
|
||||||
|
return None
|
||||||
|
# If encrypted, decrypt; otherwise data is already dict
|
||||||
|
if resp.get("encrypt") and device_key:
|
||||||
|
return _decrypt_payload(resp["data"], resp["iv"], device_key)
|
||||||
|
data = resp.get("data", {})
|
||||||
|
if isinstance(data, str):
|
||||||
|
return json.loads(data)
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("LAN getState %s FAILED: %s", device_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_device(raw: dict) -> dict:
|
||||||
|
"""Normalise a raw eWeLink cloud device dict into a clean summary."""
|
||||||
|
extra = raw.get("extra", {})
|
||||||
|
params = raw.get("params", {})
|
||||||
|
return {
|
||||||
|
"device_id": raw.get("deviceid", ""),
|
||||||
|
"name": raw.get("name", ""),
|
||||||
|
"uiid": extra.get("uiid", 1),
|
||||||
|
"model": extra.get("model", ""),
|
||||||
|
"firmware": params.get("fwVersion", ""),
|
||||||
|
"device_key": raw.get("devicekey", ""),
|
||||||
|
"api_key": raw.get("apikey", ""),
|
||||||
|
"online": raw.get("online", False),
|
||||||
|
"params": params,
|
||||||
|
# LAN info (if available in this snapshot — usually not in REST response)
|
||||||
|
"ip_address": raw.get("localNetworkInfo", {}).get("ip", ""),
|
||||||
|
"port": raw.get("localNetworkInfo", {}).get("port", 8081),
|
||||||
|
}
|
||||||
5
app/drivers/sonoff_ewelink/manifest.json
Normal file
5
app/drivers/sonoff_ewelink/manifest.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"driver_id": "sonoff_ewelink",
|
||||||
|
"name": "Sonoff eWeLink Gateway",
|
||||||
|
"description": "Control all Sonoff devices via eWeLink cloud account (LAN + Cloud)"
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@
|
|||||||
from .user import User
|
from .user import User
|
||||||
from .board import Board
|
from .board import Board
|
||||||
from .workflow import Workflow
|
from .workflow import Workflow
|
||||||
|
from .sonoff_device import SonoffDevice
|
||||||
|
|
||||||
__all__ = ["User", "Board", "Workflow"]
|
__all__ = ["User", "Board", "Workflow"]
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class Board(db.Model):
|
|||||||
firmware_version = db.Column(db.String(64), nullable=True)
|
firmware_version = db.Column(db.String(64), nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
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 ────────────────────────────────────────────────
|
# ── relationships ────────────────────────────────────────────────
|
||||||
workflows_trigger = db.relationship(
|
workflows_trigger = db.relationship(
|
||||||
"Workflow", foreign_keys="Workflow.trigger_board_id",
|
"Workflow", foreign_keys="Workflow.trigger_board_id",
|
||||||
@@ -49,6 +52,10 @@ class Board(db.Model):
|
|||||||
"Workflow", foreign_keys="Workflow.action_board_id",
|
"Workflow", foreign_keys="Workflow.action_board_id",
|
||||||
back_populates="action_board"
|
back_populates="action_board"
|
||||||
)
|
)
|
||||||
|
sonoff_devices = db.relationship(
|
||||||
|
"SonoffDevice", back_populates="board",
|
||||||
|
cascade="all, delete-orphan", lazy="dynamic"
|
||||||
|
)
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────
|
# ── helpers ──────────────────────────────────────────────────────
|
||||||
@property
|
@property
|
||||||
@@ -130,6 +137,21 @@ class Board(db.Model):
|
|||||||
"idle_label": tdef["idle"][1],
|
"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:
|
def get_relay_label(self, relay_num: int) -> str:
|
||||||
return self.get_relay_entity(relay_num)["name"]
|
return self.get_relay_entity(relay_num)["name"]
|
||||||
|
|
||||||
|
|||||||
210
app/models/sonoff_device.py
Normal file
210
app/models/sonoff_device.py
Normal 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}>"
|
||||||
@@ -32,6 +32,9 @@ def list_boards():
|
|||||||
@login_required
|
@login_required
|
||||||
def board_detail(board_id: int):
|
def board_detail(board_id: int):
|
||||||
board = db.get_or_404(Board, board_id)
|
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
|
# Refresh states from device
|
||||||
poll_board(current_app._get_current_object(), board_id)
|
poll_board(current_app._get_current_object(), board_id)
|
||||||
board = db.session.get(Board, 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_relays = int(request.form.get("num_relays", 4))
|
||||||
num_inputs = int(request.form.get("num_inputs", 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")
|
flash("Name and host are required.", "danger")
|
||||||
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
|
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(
|
board = Board(
|
||||||
name=name,
|
name=name,
|
||||||
board_type=board_type,
|
board_type=board_type,
|
||||||
|
|||||||
242
app/routes/sonoff.py
Normal file
242
app/routes/sonoff.py
Normal 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))
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('boards.list_boards') }}"
|
<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
|
<i class="bi bi-motherboard me-2"></i>Boards
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -28,10 +28,12 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="form-text text-secondary" id="driver_desc"></div>
|
<div class="form-text text-secondary" id="driver_desc"></div>
|
||||||
</div>
|
</div>
|
||||||
|
{# ── Hardware board fields (hidden for Sonoff gateway) ── #}
|
||||||
|
<div id="hw_fields">
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<label class="form-label">IP Address / Hostname</label>
|
<label class="form-label">IP Address / Hostname</label>
|
||||||
<input type="text" name="host" class="form-control font-monospace" placeholder="192.168.1.100" required />
|
<input type="text" name="host" id="host_input" class="form-control font-monospace" placeholder="192.168.1.100" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<label class="form-label">Port</label>
|
<label class="form-label">Port</label>
|
||||||
@@ -41,13 +43,29 @@
|
|||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label">Number of Relays</label>
|
<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" />
|
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="0" max="32" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label">Number of Inputs</label>
|
<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" />
|
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── 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">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
|
||||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
@@ -69,6 +87,12 @@ function updateDefaults(type) {
|
|||||||
document.getElementById("num_relays").value = d.relays;
|
document.getElementById("num_relays").value = d.relays;
|
||||||
document.getElementById("num_inputs").value = d.inputs;
|
document.getElementById("num_inputs").value = d.inputs;
|
||||||
document.getElementById("driver_desc").textContent = d.desc;
|
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
|
// init on page load
|
||||||
updateDefaults(document.getElementById("board_type_select").value);
|
updateDefaults(document.getElementById("board_type_select").value);
|
||||||
|
|||||||
@@ -22,7 +22,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for b in boards %}
|
{% for b in boards %}
|
||||||
<tr>
|
<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><span class="badge text-bg-secondary">{{ b.board_type }}</span></td>
|
||||||
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
|
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
|
||||||
<td>{{ b.num_relays }}</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 %}
|
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<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() %}
|
{% 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>
|
<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"
|
<form method="POST" action="{{ url_for('boards.delete_board', board_id=b.id) }}" class="d-inline"
|
||||||
|
|||||||
104
app/templates/sonoff/auth_settings.html
Normal file
104
app/templates/sonoff/auth_settings.html
Normal 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 %}
|
||||||
281
app/templates/sonoff/device.html
Normal file
281
app/templates/sonoff/device.html
Normal 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 %} · v{{ device.firmware }}{% endif %}
|
||||||
|
{% if device.model %} · {{ 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 %}
|
||||||
260
app/templates/sonoff/gateway.html
Normal file
260
app/templates/sonoff/gateway.html
Normal 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 %}
|
||||||
|
· <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 %}
|
||||||
@@ -11,3 +11,4 @@ python-dotenv==1.0.1
|
|||||||
WTForms==3.1.2
|
WTForms==3.1.2
|
||||||
SQLAlchemy==2.0.31
|
SQLAlchemy==2.0.31
|
||||||
greenlet>=3.1.1
|
greenlet>=3.1.1
|
||||||
|
pycryptodome==3.23.0
|
||||||
|
|||||||
Reference in New Issue
Block a user