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:
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))
|
||||
Reference in New Issue
Block a user