Files
location_managemet/app/routes/layouts.py
ske087 90cbf4e1f0 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)
2026-02-27 13:34:44 +02:00

144 lines
5.8 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
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"))