Add Tuya Cloud integration; 2-step add-board wizard; DP value formatting

- Tuya Cloud Gateway driver (tuya-device-sharing-sdk):
  - QR-code auth flow: user-provided user_code, terminal_id/endpoint
    returned by Tuya login_result (mirrors HA implementation)
  - Device sync, toggle DP, rename, per-device detail page
  - category → kind mapping; detect_switch_dps helper
  - format_dp_value: temperature (÷10 + °C/°F), humidity (+ %)
    registered as 'tuya_dp' Jinja2 filter

- TuyaDevice model (tuya_devices table)

- Templates:
  - tuya/gateway.html: device grid with live-reading sensor cards
    (config/threshold keys hidden from card, shown on detail page)
  - tuya/device.html: full status table with formatted DP values
  - tuya/auth_settings.html: user_code input + QR scan flow

- Add-board wizard refactored to 2-step flow:
  - Step 1: choose board type (Cloud Gateways vs Hardware)
  - Step 2: type-specific fields; gateways skip IP/relay fields

- Layout builder: Tuya chip support (makeTuyaChip, tuya_update socket)

- requirements.txt: tuya-device-sharing-sdk, cryptography
This commit is contained in:
ske087
2026-02-27 16:06:48 +02:00
parent 90cbf4e1f0
commit 1e89323035
19 changed files with 1873 additions and 84 deletions

250
app/routes/tuya.py Normal file
View File

@@ -0,0 +1,250 @@
"""Tuya Cloud Gateway routes.
URL structure:
GET /tuya/<board_id> gateway overview
GET /tuya/<board_id>/auth auth settings page
POST /tuya/<board_id>/auth/qr (AJAX) generate QR code
GET /tuya/<board_id>/auth/poll (AJAX) poll login result
POST /tuya/<board_id>/auth/save (AJAX) persist tokens
POST /tuya/<board_id>/sync sync devices from cloud
GET /tuya/<board_id>/device/<device_id> device detail
POST /tuya/<board_id>/device/<device_id>/dp/<dp>/toggle toggle a DP
POST /tuya/<board_id>/device/<device_id>/rename rename a device
"""
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.tuya_device import TuyaDevice
from app.drivers.registry import registry
from app.drivers.tuya_cloud.driver import (
TUYA_CLIENT_ID, TUYA_SCHEMA, category_kind, KIND_ICON,
)
tuya_bp = Blueprint("tuya", __name__, url_prefix="/tuya")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_gateway(board_id: int) -> Board:
board = db.get_or_404(Board, board_id)
if board.board_type != "tuya_cloud":
abort(404)
return board
def _get_driver(board: Board):
drv = registry.get("tuya_cloud")
if drv is None:
abort(500)
return drv
# ── Gateway overview ──────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>")
@login_required
def gateway(board_id: int):
board = _get_gateway(board_id)
devices = (
TuyaDevice.query.filter_by(board_id=board_id)
.order_by(TuyaDevice.name)
.all()
)
has_token = bool(board.config.get("tuya_token_info"))
return render_template(
"tuya/gateway.html",
board=board,
devices=devices,
has_token=has_token,
)
# ── Auth settings ─────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/auth")
@login_required
def auth_settings(board_id: int):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
return render_template("tuya/auth_settings.html", board=board)
@tuya_bp.route("/<int:board_id>/auth/qr", methods=["POST"])
@login_required
def generate_qr(board_id: int):
"""AJAX: generate a new QR-code token for this board.
The caller must supply a ``user_code`` in the JSON body. This is a
meaningful identifier chosen by the user (e.g. a short string they
recognise) that Tuya uses to link the scan to their account. It must
**not** be auto-generated Tuya validates it server-side.
"""
if not current_user.is_admin():
abort(403)
_get_gateway(board_id)
data = request.get_json(silent=True) or {}
user_code = data.get("user_code", "").strip()
if not user_code:
return jsonify({"ok": False, "error": "A user code is required to generate the QR code."}), 400
try:
from tuya_sharing import LoginControl
lc = LoginControl()
response = lc.qr_code(TUYA_CLIENT_ID, TUYA_SCHEMA, user_code)
if not response.get("success"):
return jsonify({"ok": False, "error": response.get("msg", "QR generation failed")}), 400
qr_token = response["result"]["qrcode"]
# The URI that Smart Life / Tuya Smart app decodes from the QR:
qr_data = f"tuyaSmart--qrLogin?token={qr_token}"
return jsonify({
"ok": True,
"qr_data": qr_data,
"qr_token": qr_token,
"user_code": user_code,
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
@tuya_bp.route("/<int:board_id>/auth/poll")
@login_required
def poll_login(board_id: int):
"""AJAX: check whether the user has scanned the QR code yet."""
qr_token = request.args.get("token", "")
user_code = request.args.get("user_code", "")
if not qr_token or not user_code:
return jsonify({"ok": False, "error": "Missing token or user_code"}), 400
try:
from tuya_sharing import LoginControl
lc = LoginControl()
ret, info = lc.login_result(qr_token, TUYA_CLIENT_ID, user_code)
if ret:
return jsonify({"ok": True, "info": info})
return jsonify({"ok": False, "pending": True})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
@tuya_bp.route("/<int:board_id>/auth/save", methods=["POST"])
@login_required
def save_auth(board_id: int):
"""AJAX: persist tokens after successful QR scan."""
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
data = request.get_json(silent=True) or {}
info = data.get("info", {})
user_code = data.get("user_code", "")
if not info or not user_code:
return jsonify({"ok": False, "error": "Missing data"}), 400
cfg = board.config
# Store the token blob (t, uid, expire_time, access_token, refresh_token)
cfg["tuya_token_info"] = {
"t": info.get("t", 0),
"uid": info.get("uid", ""),
"expire_time": info.get("expire_time", 7776000),
"access_token": info.get("access_token", ""),
"refresh_token": info.get("refresh_token", ""),
}
cfg["tuya_user_code"] = user_code
# terminal_id and endpoint are returned by Tuya in the login_result info dict
cfg["tuya_terminal_id"] = info.get("terminal_id", "")
cfg["tuya_endpoint"] = info.get("endpoint", "https://apigw.iotbing.com")
board.config = cfg
db.session.commit()
return jsonify({"ok": True, "redirect": url_for("tuya.gateway", board_id=board_id)})
# ── Sync devices ──────────────────────────────────────────────────────────────
@tuya_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:
n = drv.sync_devices(board)
db.session.commit()
flash(f"✓ Synced {n} Tuya device(s).", "success")
except Exception as exc:
flash(f"Sync failed: {exc}", "danger")
return redirect(url_for("tuya.gateway", board_id=board_id))
# ── Device detail ─────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/device/<device_id>")
@login_required
def device_detail(board_id: int, device_id: str):
board = _get_gateway(board_id)
device = TuyaDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
return render_template("tuya/device.html", board=board, device=device)
# ── Toggle ────────────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/device/<device_id>/dp/<dp_code>/toggle", methods=["POST"])
@login_required
def toggle_dp(board_id: int, device_id: str, dp_code: str):
board = _get_gateway(board_id)
device = TuyaDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
drv = _get_driver(board)
ok = drv.toggle_dp(board, device_id, dp_code)
new_state = device.status.get(dp_code, False)
# Broadcast to all connected clients
socketio.emit("tuya_update", {
"board_id": board_id,
"device_id": device_id,
"dp_code": dp_code,
"state": new_state,
})
if request.headers.get("Accept") == "application/json" or \
request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"ok": ok, "state": new_state})
return redirect(url_for("tuya.device_detail", board_id=board_id, device_id=device_id))
# ── Rename ────────────────────────────────────────────────────────────────────
@tuya_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)
device = TuyaDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
new_name = request.form.get("name", "").strip()
if new_name:
device.name = new_name
db.session.commit()
flash(f"Device renamed to '{new_name}'.", "success")
return redirect(url_for("tuya.device_detail", board_id=board_id, device_id=device_id))