Fix SonoffLAN signing key: hardcode pre-computed HMAC key

The previous _compute_sign_key() function indexed into a base64 string
derived from the full SonoffLAN REGIONS dict (~243 entries). Our partial
dict only produced a 7876-char a string but needed index 7872+, so the
function must use the full dict.

Solution: pre-compute the key once from the full dict and hardcode the
resulting 32-byte ASCII key. This is deterministic — the SonoffLAN
algorithm always produces the same output regardless of when it runs.

The sonoff_ewelink driver now loads cleanly alongside all other drivers.
This commit is contained in:
ske087
2026-02-26 20:13:07 +02:00
parent 7a22575dab
commit 30806560a6
17 changed files with 1864 additions and 19 deletions

View File

@@ -32,6 +32,9 @@ def list_boards():
@login_required
def board_detail(board_id: int):
board = db.get_or_404(Board, board_id)
# Sonoff eWeLink gateway boards have their own page
if board.board_type == "sonoff_ewelink":
return redirect(url_for("sonoff.gateway", board_id=board_id))
# Refresh states from device
poll_board(current_app._get_current_object(), board_id)
board = db.session.get(Board, board_id)
@@ -54,10 +57,17 @@ def add_board():
num_relays = int(request.form.get("num_relays", 4))
num_inputs = int(request.form.get("num_inputs", 4))
if not name or not host:
# Sonoff gateway doesn't need a real host address
is_gateway = board_type == "sonoff_ewelink"
if not name or (not host and not is_gateway):
flash("Name and host are required.", "danger")
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
if is_gateway:
host = host or "ewelink.cloud"
num_relays = 0
num_inputs = 0
board = Board(
name=name,
board_type=board_type,

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

@@ -0,0 +1,242 @@
"""Sonoff eWeLink Gateway routes.
URL structure:
GET /sonoff/<board_id> gateway overview
GET /sonoff/<board_id>/auth auth settings form
POST /sonoff/<board_id>/auth save credentials + login
POST /sonoff/<board_id>/sync sync devices from cloud
GET /sonoff/<board_id>/device/<device_id> device detail
POST /sonoff/<board_id>/device/<device_id>/ch/<ch>/on|off|toggle relay control
POST /sonoff/<board_id>/device/<device_id>/rename rename a device
"""
import json
from flask import (
Blueprint,
abort,
flash,
jsonify,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user, login_required
from app import db, socketio
from app.models.board import Board
from app.models.sonoff_device import SonoffDevice
from app.drivers.registry import registry
sonoff_bp = Blueprint("sonoff", __name__, url_prefix="/sonoff")
def _get_gateway(board_id: int) -> Board:
board = db.get_or_404(Board, board_id)
if board.board_type != "sonoff_ewelink":
abort(404)
return board
def _get_driver(board: Board):
drv = registry.get("sonoff_ewelink")
if drv is None:
abort(500)
return drv
# ── Gateway overview ──────────────────────────────────────────────────────────
@sonoff_bp.route("/<int:board_id>")
@login_required
def gateway(board_id: int):
board = _get_gateway(board_id)
devices = SonoffDevice.query.filter_by(board_id=board_id).order_by(
SonoffDevice.name
).all()
has_credentials = bool(board.config.get("ewelink_username") and
board.config.get("ewelink_password"))
return render_template(
"sonoff/gateway.html",
board=board,
devices=devices,
has_credentials=has_credentials,
)
# ── Auth settings ─────────────────────────────────────────────────────────────
@sonoff_bp.route("/<int:board_id>/auth", methods=["GET", "POST"])
@login_required
def auth_settings(board_id: int):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "").strip()
country = request.form.get("country_code", "+1").strip()
if not username or not password:
flash("Username and password are required.", "danger")
return redirect(url_for("sonoff.auth_settings", board_id=board_id))
cfg = board.config
cfg["ewelink_username"] = username
cfg["ewelink_password"] = password
cfg["ewelink_country_code"] = country
cfg.pop("ewelink_at", None) # force re-login
board.config = cfg
db.session.commit()
# Try to login immediately
drv = _get_driver(board)
if drv.ensure_token(board):
db.session.commit()
flash("✓ Connected to eWeLink successfully.", "success")
return redirect(url_for("sonoff.gateway", board_id=board_id))
else:
flash("Login failed — check credentials.", "danger")
return redirect(url_for("sonoff.auth_settings", board_id=board_id))
# Country codes for dropdown
from app.drivers.sonoff_ewelink.ewelink_api import REGIONS
countries = sorted(
[(code, f"{info[0]} ({code})") for code, info in REGIONS.items()],
key=lambda x: x[1]
)
return render_template(
"sonoff/auth_settings.html",
board=board,
countries=countries,
current_country=board.config.get("ewelink_country_code", "+1"),
)
# ── Sync devices ──────────────────────────────────────────────────────────────
@sonoff_bp.route("/<int:board_id>/sync", methods=["POST"])
@login_required
def sync_devices(board_id: int):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
drv = _get_driver(board)
try:
count = drv.sync_devices(board)
db.session.commit()
flash(f"✓ Synced {count} devices from eWeLink.", "success")
except Exception as exc:
flash(f"Sync failed: {exc}", "danger")
return redirect(url_for("sonoff.gateway", board_id=board_id))
# ── Device detail ─────────────────────────────────────────────────────────────
@sonoff_bp.route("/<int:board_id>/device/<device_id>")
@login_required
def device_detail(board_id: int, device_id: str):
board = _get_gateway(board_id)
dev = SonoffDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
return render_template("sonoff/device.html", board=board, device=dev)
# ── Relay / channel control ───────────────────────────────────────────────────
@sonoff_bp.route(
"/<int:board_id>/device/<device_id>/ch/<int:channel>/<action>",
methods=["POST"],
)
@login_required
def control_channel(board_id: int, device_id: str, channel: int, action: str):
board = _get_gateway(board_id)
dev = SonoffDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
drv = _get_driver(board)
if action == "on":
new_state = True
elif action == "off":
new_state = False
elif action == "toggle":
new_state = not dev.get_channel_state(channel)
else:
abort(400)
ok = drv.set_device_channel(board, device_id, channel, new_state)
# Emit SocketIO update
socketio.emit("sonoff_update", {
"board_id": board_id,
"device_id": device_id,
"channel": channel,
"state": new_state,
"ok": ok,
})
if request.accept_mimetypes.best == "application/json" or \
request.headers.get("Accept") == "application/json":
return jsonify({
"ok": ok,
"state": new_state,
"device_id": device_id,
"channel": channel,
})
if not ok:
flash(f"⚠ Command sent but device may not have responded.", "warning")
return redirect(url_for("sonoff.gateway", board_id=board_id))
# ── Rename device ─────────────────────────────────────────────────────────────
@sonoff_bp.route(
"/<int:board_id>/device/<device_id>/rename", methods=["POST"]
)
@login_required
def rename_device(board_id: int, device_id: str):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
dev = SonoffDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
new_name = request.form.get("name", "").strip()[:64]
if new_name:
dev.name = new_name
db.session.commit()
if request.headers.get("Accept") == "application/json":
return jsonify({"ok": True, "name": dev.name})
return redirect(url_for("sonoff.device_detail",
board_id=board_id, device_id=device_id))
# ── Update LAN IP for a device ────────────────────────────────────────────────
@sonoff_bp.route(
"/<int:board_id>/device/<device_id>/set_ip", methods=["POST"]
)
@login_required
def set_device_ip(board_id: int, device_id: str):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
dev = SonoffDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
ip = request.form.get("ip", "").strip()
port = int(request.form.get("port", 8081))
dev.ip_address = ip
dev.port = port
db.session.commit()
flash(f"✓ LAN address updated for {dev.name}.", "success")
return redirect(url_for("sonoff.device_detail",
board_id=board_id, device_id=device_id))