Add Layouts module with Konva.js builder; smart offline polling; UI improvements
- 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)
This commit is contained in:
143
app/routes/layouts.py
Normal file
143
app/routes/layouts.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""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"))
|
||||
Reference in New Issue
Block a user