Files
location_managemet/app/routes/sonoff.py
ske087 30806560a6 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.
2026-02-26 20:13:07 +02:00

243 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))