- Move board cards from dashboard to top of boards list page - Fix Werkzeug duplicate polling (WERKZEUG_RUN_MAIN guard) - Smart offline polling: fast loop for online boards, slow recheck for offline - Add manual ping endpoint POST /api/boards/<id>/ping - Add spin animation CSS for ping button Layouts module (new): - app/models/layout.py: Layout model (canvas_json, thumbnail_b64) - app/routes/layouts.py: 5 routes (list, create, builder, save, delete) - app/templates/layouts/: list and builder templates - app/static/js/layout_builder.js: full Konva.js builder engine - app/static/vendor/konva/: vendored Konva.js 9 - Structure mode: wall, room, door, window, fence, text shapes - Devices mode: drag relay/input/Sonoff channels onto canvas - Live view mode: click relays/Sonoff to toggle, socket.io state updates - Device selection: click to select, remove individual device, Delete key - Fix door/Arc size persistence across save/reload (outerRadius, scaleX/Y) - Fix Sonoff devices missing from palette (add makeSonoffChip function)
144 lines
5.8 KiB
Python
144 lines
5.8 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
|
||
|
||
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": [],
|
||
"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,
|
||
})
|
||
|
||
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"))
|