Files
location_managemet/app/routes/layouts.py
ske087 1e89323035 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
2026-02-27 16:06:48 +02:00

173 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"))