- 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
173 lines
7.2 KiB
Python
173 lines
7.2 KiB
Python
"""Layout management routes."""
|
||
import json
|
||
from datetime import datetime
|
||
|
||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||
from flask_login import login_required, current_user
|
||
|
||
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__)
|
||
|
||
|
||
# ── list ──────────────────────────────────────────────────────────────────────
|
||
|
||
@layouts_bp.route("/")
|
||
@login_required
|
||
def list_layouts():
|
||
layouts = Layout.query.order_by(Layout.name).all()
|
||
return render_template("layouts/list.html", layouts=layouts)
|
||
|
||
|
||
# ── create ────────────────────────────────────────────────────────────────────
|
||
|
||
@layouts_bp.route("/create", methods=["POST"])
|
||
@login_required
|
||
def create_layout():
|
||
if not current_user.is_admin():
|
||
abort(403)
|
||
name = request.form.get("name", "").strip()
|
||
description = request.form.get("description", "").strip()
|
||
if not name:
|
||
flash("Layout name is required.", "danger")
|
||
return redirect(url_for("layouts.list_layouts"))
|
||
layout = Layout(name=name, description=description or None)
|
||
db.session.add(layout)
|
||
db.session.commit()
|
||
return redirect(url_for("layouts.builder", layout_id=layout.id))
|
||
|
||
|
||
# ── builder ───────────────────────────────────────────────────────────────────
|
||
|
||
@layouts_bp.route("/<int:layout_id>/builder")
|
||
@login_required
|
||
def builder(layout_id: int):
|
||
layout = db.get_or_404(Layout, layout_id)
|
||
boards = Board.query.order_by(Board.name).all()
|
||
|
||
# Build a boards palette for the JS device sidebar
|
||
boards_data = []
|
||
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 ──────────────────────────────────────
|
||
for n in range(1, b.num_relays + 1):
|
||
e = b.get_relay_entity(n)
|
||
bd["relays"].append({
|
||
"num": n,
|
||
"name": e["name"],
|
||
"icon": e["icon"],
|
||
"onColor": e["on_color"],
|
||
"offColor": e["off_color"],
|
||
"isOn": b.relay_states.get(f"relay_{n}", False),
|
||
})
|
||
for n in range(1, b.num_inputs + 1):
|
||
e = b.get_input_entity(n)
|
||
bd["inputs"].append({
|
||
"num": n,
|
||
"name": e["name"],
|
||
"icon": e["icon"],
|
||
"activeColor": e["active_color"],
|
||
"idleColor": e["idle_color"],
|
||
"rawState": b.input_states.get(f"input_{n}", True),
|
||
})
|
||
|
||
# ── Sonoff eWeLink sub-devices ────────────────────────────────────────
|
||
if b.board_type == "sonoff_ewelink":
|
||
KIND_ICON = {
|
||
"switch": "bi-toggles",
|
||
"light": "bi-lightbulb-fill",
|
||
"fan": "bi-fan",
|
||
"sensor": "bi-thermometer-half",
|
||
"remote": "bi-broadcast",
|
||
}
|
||
devices = SonoffDevice.query.filter_by(board_id=b.id).order_by(
|
||
SonoffDevice.name).all()
|
||
for dev in devices:
|
||
icon = KIND_ICON.get(dev.kind, "bi-toggles")
|
||
num_ch = max(dev.num_channels, 1)
|
||
for ch in range(num_ch):
|
||
label = dev.name if num_ch == 1 else f"{dev.name} – Ch{ch + 1}"
|
||
bd["sonoff_channels"].append({
|
||
"deviceId": dev.device_id,
|
||
"deviceDbId": dev.id,
|
||
"channel": ch,
|
||
"name": label,
|
||
"icon": icon,
|
||
"kind": dev.kind,
|
||
"isOn": dev.get_channel_state(ch),
|
||
"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(
|
||
"layouts/builder.html",
|
||
layout=layout,
|
||
boards_data=boards_data,
|
||
)
|
||
|
||
|
||
# ── save (AJAX) ───────────────────────────────────────────────────────────────
|
||
|
||
@layouts_bp.route("/<int:layout_id>/save", methods=["POST"])
|
||
@login_required
|
||
def save_layout(layout_id: int):
|
||
layout = db.get_or_404(Layout, layout_id)
|
||
data = request.get_json(silent=True) or {}
|
||
if "canvas" in data:
|
||
layout.canvas_json = json.dumps(data["canvas"])
|
||
if data.get("thumbnail"):
|
||
layout.thumbnail_b64 = data["thumbnail"]
|
||
layout.updated_at = datetime.utcnow()
|
||
db.session.commit()
|
||
return jsonify({"status": "ok"})
|
||
|
||
|
||
# ── delete ────────────────────────────────────────────────────────────────────
|
||
|
||
@layouts_bp.route("/<int:layout_id>/delete", methods=["POST"])
|
||
@login_required
|
||
def delete_layout(layout_id: int):
|
||
if not current_user.is_admin():
|
||
abort(403)
|
||
layout = db.get_or_404(Layout, layout_id)
|
||
db.session.delete(layout)
|
||
db.session.commit()
|
||
flash(f"Layout '{layout.name}' deleted.", "warning")
|
||
return redirect(url_for("layouts.list_layouts"))
|