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:
@@ -32,9 +32,11 @@ 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
|
||||
# Gateway boards have their own dedicated page
|
||||
if board.board_type == "sonoff_ewelink":
|
||||
return redirect(url_for("sonoff.gateway", board_id=board_id))
|
||||
if board.board_type == "tuya_cloud":
|
||||
return redirect(url_for("tuya.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)
|
||||
@@ -57,14 +59,17 @@ def add_board():
|
||||
num_relays = int(request.form.get("num_relays", 4))
|
||||
num_inputs = int(request.form.get("num_inputs", 4))
|
||||
|
||||
# Sonoff gateway doesn't need a real host address
|
||||
is_gateway = board_type == "sonoff_ewelink"
|
||||
# Gateway boards don't need a real host address
|
||||
is_gateway = board_type in ("sonoff_ewelink", "tuya_cloud")
|
||||
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"
|
||||
if board_type == "tuya_cloud":
|
||||
host = host or "openapi.tuyaeu.com"
|
||||
else:
|
||||
host = host or "ewelink.cloud"
|
||||
num_relays = 0
|
||||
num_inputs = 0
|
||||
|
||||
@@ -84,6 +89,11 @@ def add_board():
|
||||
register_webhook(board, server_url)
|
||||
|
||||
flash(f"Board '{name}' added successfully.", "success")
|
||||
# Send gateway boards straight to their auth/settings page
|
||||
if board_type == "sonoff_ewelink":
|
||||
return redirect(url_for("sonoff.auth_settings", board_id=board.id))
|
||||
if board_type == "tuya_cloud":
|
||||
return redirect(url_for("tuya.auth_settings", board_id=board.id))
|
||||
return redirect(url_for("boards.board_detail", board_id=board.id))
|
||||
|
||||
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
|
||||
|
||||
@@ -9,6 +9,7 @@ from app import db
|
||||
from app.models.layout import Layout
|
||||
from app.models.board import Board
|
||||
from app.models.sonoff_device import SonoffDevice
|
||||
from app.models.tuya_device import TuyaDevice
|
||||
|
||||
layouts_bp = Blueprint("layouts", __name__)
|
||||
|
||||
@@ -53,6 +54,7 @@ def builder(layout_id: int):
|
||||
for b in boards:
|
||||
bd = {"id": b.id, "name": b.name, "online": b.is_online,
|
||||
"relays": [], "inputs": [], "sonoff_channels": [],
|
||||
"tuya_channels": [],
|
||||
"board_type": b.board_type}
|
||||
|
||||
# ── Standard relay/input boards ──────────────────────────────────────
|
||||
@@ -104,6 +106,33 @@ def builder(layout_id: int):
|
||||
"isOnline": dev.is_online,
|
||||
})
|
||||
|
||||
# ── Tuya Cloud sub-devices ────────────────────────────────────────────
|
||||
if b.board_type == "tuya_cloud":
|
||||
TUYA_KIND_ICON = {
|
||||
"switch": "bi-toggles",
|
||||
"light": "bi-lightbulb-fill",
|
||||
"fan": "bi-fan",
|
||||
"sensor": "bi-thermometer-half",
|
||||
"cover": "bi-door-open",
|
||||
}
|
||||
tuya_devs = TuyaDevice.query.filter_by(board_id=b.id).order_by(
|
||||
TuyaDevice.name).all()
|
||||
for dev in tuya_devs:
|
||||
icon = TUYA_KIND_ICON.get(dev.kind, "bi-plug")
|
||||
for dp in dev.switch_dps:
|
||||
idx = dev.switch_dps.index(dp)
|
||||
label = (dev.name if dev.num_channels == 1
|
||||
else f"{dev.name} – Ch{idx + 1}")
|
||||
bd["tuya_channels"].append({
|
||||
"deviceId": dev.device_id,
|
||||
"channel": dp, # dp_code string, e.g. "switch_1"
|
||||
"name": label,
|
||||
"icon": icon,
|
||||
"kind": dev.kind,
|
||||
"isOn": dev.status.get(dp, False),
|
||||
"isOnline": dev.is_online,
|
||||
})
|
||||
|
||||
boards_data.append(bd)
|
||||
|
||||
return render_template(
|
||||
|
||||
250
app/routes/tuya.py
Normal file
250
app/routes/tuya.py
Normal 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))
|
||||
Reference in New Issue
Block a user