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:
ske087
2026-02-27 13:34:44 +02:00
parent 30806560a6
commit 90cbf4e1f0
15 changed files with 2006 additions and 177 deletions

View File

@@ -36,6 +36,7 @@ def create_app(config_name: str = "default") -> Flask:
from app.routes.api import api_bp
from app.routes.admin import admin_bp
from app.routes.sonoff import sonoff_bp
from app.routes.layouts import layouts_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -44,6 +45,7 @@ def create_app(config_name: str = "default") -> Flask:
app.register_blueprint(api_bp, url_prefix="/api")
app.register_blueprint(admin_bp, url_prefix="/admin")
app.register_blueprint(sonoff_bp)
app.register_blueprint(layouts_bp, url_prefix="/layouts")
# ── user loader ───────────────────────────────────────────────────────────
from app.models.user import User
@@ -62,6 +64,7 @@ def create_app(config_name: str = "default") -> Flask:
# Import all models so their tables are registered before create_all
from app.models import board, user, workflow # noqa: F401
from app.models import sonoff_device # noqa: F401
from app.models import layout # noqa: F401
db.create_all()
_seed_admin(app)
_add_entities_column(app)

27
app/models/layout.py Normal file
View File

@@ -0,0 +1,27 @@
"""Layout model stores a floor-plan / property map with placed device widgets."""
from datetime import datetime
from app import db
class Layout(db.Model):
__tablename__ = "layouts"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False)
description = db.Column(db.String(256), nullable=True)
# Custom JSON schema (not raw Konva JSON) so we stay schema-independent:
# {
# "structure": [ {id, tool, x, y, w, h, rotation, text, points}, … ],
# "devices": [ {id, boardId, entityType, entityNum, x, y}, … ]
# }
canvas_json = db.Column(db.Text, nullable=True)
# PNG data-url for thumbnail shown on the list page
thumbnail_b64 = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<Layout {self.name!r} id={self.id}>"

View File

@@ -1,10 +1,12 @@
"""REST API board webhook receiver and JSON relay control."""
from datetime import datetime
from flask import Blueprint, request, jsonify, abort
from flask import Blueprint, request, jsonify, abort, current_app
from flask_login import login_required
from app import db, socketio
from app.models.board import Board
from app.services import workflow_engine
from app.services.board_service import poll_board
api_bp = Blueprint("api", __name__)
@@ -46,6 +48,28 @@ def webhook(board_id: int):
return jsonify({"status": "ok"})
# ── Manual ping / status check ───────────────────────────────────────────────
@api_bp.route("/boards/<int:board_id>/ping", methods=["POST"])
@login_required
def ping_board(board_id: int):
"""Trigger an immediate poll for any board (online or offline).
Called by the 'Check Status' button in the UI. The poll result is both
returned as JSON *and* broadcast via socket.io so all open tabs update.
"""
db.get_or_404(Board, board_id) # 404 if unknown
poll_board(current_app._get_current_object(), board_id)
board = db.session.get(Board, board_id)
return jsonify({
"board_id": board.id,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
"last_seen": board.last_seen.isoformat() if board.last_seen else None,
})
# ── JSON relay status ─────────────────────────────────────────────────────────
@api_bp.route("/boards/<int:board_id>/relays")

143
app/routes/layouts.py Normal file
View 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"))

View File

@@ -84,14 +84,14 @@ def poll_board(app, board_id: int) -> None:
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
"last_seen": board.last_seen.isoformat() if board.last_seen else None,
})
def poll_all_boards(app) -> None:
"""Poll every registered board in parallel."""
with app.app_context():
board_ids = [r[0] for r in db.session.query(Board.id).all()]
def _poll_boards_by_ids(app, board_ids: list) -> None:
"""Spawn one thread per board_id and poll them in parallel."""
if not board_ids:
return
threads = [
threading.Thread(target=poll_board, args=(app, bid), daemon=True)
for bid in board_ids
@@ -99,7 +99,30 @@ def poll_all_boards(app) -> None:
for t in threads:
t.start()
for t in threads:
t.join(timeout=4)
t.join(timeout=6)
def poll_online_boards(app) -> None:
"""Poll only boards currently marked online (fast background loop)."""
with app.app_context():
board_ids = [
r[0] for r in db.session.query(Board.id).filter_by(is_online=True).all()
]
_poll_boards_by_ids(app, board_ids)
def recheck_offline_boards(app) -> None:
"""Single-pass connectivity check for boards marked offline.
Called infrequently (default every 60 s) so we don't flood the network
with timeout requests for devices that are simply powered off.
Also triggered immediately when the user clicks 'Check Status'.
"""
with app.app_context():
board_ids = [
r[0] for r in db.session.query(Board.id).filter_by(is_online=False).all()
]
_poll_boards_by_ids(app, board_ids)
# ── webhook registration ──────────────────────────────────────────────────────

View File

@@ -51,6 +51,15 @@
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* ── Spinner for ping button ────────────────────────────────────────────── */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spin {
display: inline-block;
animation: spin 0.8s linear infinite;
}
/* ── Relay buttons ──────────────────────────────────────────────────────── */
.relay-btn {
min-width: 90px;

File diff suppressed because it is too large Load Diff

12
app/static/vendor/konva/konva.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -37,6 +37,12 @@
<i class="bi bi-diagram-3 me-2"></i>Workflows
</a>
</li>
<li>
<a href="{{ url_for('layouts.list_layouts') }}"
class="nav-link text-white {% if 'layouts.' in request.endpoint %}active{% endif %}">
<i class="bi bi-map me-2"></i>Layouts
</a>
</li>
{% if current_user.is_admin() %}
<li>
<a href="{{ url_for('admin.list_users') }}"

View File

@@ -11,6 +11,79 @@
{% endif %}
</div>
<!-- ── Board cards ──────────────────────────────────────────────────────────── -->
{% if boards %}
<div class="row g-3 mb-4" id="board-grid">
{% for board in boards %}
<div class="col-md-6 col-xl-4" id="board-card-{{ board.id }}">
<div class="card board-card border-0 rounded-4 h-100 {% if board.is_online %}border-start border-3 border-success{% else %}border-start border-3 border-secondary{% endif %}">
<div class="card-header bg-transparent d-flex justify-content-between align-items-center pt-3">
<div>
<h5 class="mb-0 fw-semibold">{{ board.name }}</h5>
<span class="badge text-bg-secondary small">{{ board.board_type }}</span>
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} small ms-1" id="online-badge-{{ board.id }}">
{% if board.is_online %}Online{% else %}Offline{% endif %}
</span>
</div>
<a href="{% if board.board_type == 'sonoff_ewelink' %}{{ url_for('sonoff.gateway', board_id=board.id) }}{% else %}{{ url_for('boards.board_detail', board_id=board.id) }}{% endif %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="card-body">
<p class="text-secondary small mb-2"><i class="bi bi-hdd-network me-1"></i>{{ board.host }}:{{ board.port }}</p>
<!-- Quick relay controls -->
<div class="d-flex flex-wrap gap-2">
{% for n in range(1, board.num_relays + 1) %}
{% set relay_key = "relay_" ~ n %}
{% set is_on = board.relay_states.get(relay_key, false) %}
{% set e = board.get_relay_entity(n) %}
<button type="button"
class="btn btn-sm relay-btn {% if is_on %}btn-{{ e.on_color }}{% else %}btn-outline-secondary{% endif %}"
data-relay="{{ n }}" data-board="{{ board.id }}"
data-toggle-url="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}"
title="{{ e.name }}"
onclick="dashToggleRelay(this)">
<i class="bi {{ e.icon }}"></i>
{{ e.name }}
</button>
{% endfor %}
</div>
<!-- Input states -->
{% if board.num_inputs > 0 %}
<div class="mt-2 d-flex flex-wrap gap-1">
{% for n in range(1, board.num_inputs + 1) %}
{% set input_key = "input_" ~ n %}
{% set raw_state = board.input_states.get(input_key, true) %}
{% set is_active = not raw_state %}
{% set e = board.get_input_entity(n) %}
<span class="badge {% if is_active %}text-bg-{{ e.active_color }}{% else %}text-bg-dark{% endif %} input-badge"
data-input="{{ n }}" data-board="{{ board.id }}">
<i class="bi {{ e.icon }}"></i> {{ e.name }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-footer bg-transparent text-secondary small d-flex justify-content-between align-items-center">
<span id="last-seen-{{ board.id }}">
{% if board.last_seen %}Last seen {{ board.last_seen.strftime('%H:%M:%S') }}{% else %}Never polled{% endif %}
</span>
<button class="btn btn-sm btn-outline-warning ping-btn {% if board.is_online %}d-none{% endif %}"
id="ping-btn-{{ board.id }}"
data-ping-url="{{ url_for('api.ping_board', board_id=board.id) }}"
onclick="pingBoard(this, {{ board.id }})">
<i class="bi bi-arrow-clockwise"></i> Check
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- ── Board table ────────────────────────────────────────────────────────── -->
{% if boards %}
<div class="table-responsive">
<table class="table table-hover align-middle">
@@ -36,14 +109,27 @@
<td>{{ b.num_relays }}</td>
<td>{{ b.num_inputs }}</td>
<td>
<span class="badge {% if b.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %}">
<span class="badge {% if b.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %}" id="tbl-badge-{{ b.id }}">
{% if b.is_online %}Online{% else %}Offline{% endif %}
</span>
</td>
<td class="small text-secondary">
<td class="small text-secondary" id="tbl-lastseen-{{ b.id }}">
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
</td>
<td>
{% if not b.is_online %}
<button class="btn btn-sm btn-outline-warning me-1 tbl-ping-btn" id="tbl-ping-{{ b.id }}"
data-ping-url="{{ url_for('api.ping_board', board_id=b.id) }}"
onclick="pingBoard(this, {{ b.id }})" title="Check status">
<i class="bi bi-arrow-clockwise"></i>
</button>
{% else %}
<button class="btn btn-sm btn-outline-warning me-1 tbl-ping-btn d-none" id="tbl-ping-{{ b.id }}"
data-ping-url="{{ url_for('api.ping_board', board_id=b.id) }}"
onclick="pingBoard(this, {{ b.id }})" title="Check status">
<i class="bi bi-arrow-clockwise"></i>
</button>
{% endif %}
<a href="{% if b.board_type == 'sonoff_ewelink' %}{{ url_for('sonoff.gateway', board_id=b.id) }}{% else %}{{ url_for('boards.board_detail', board_id=b.id) }}{% endif %}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.edit_board', board_id=b.id) }}" class="btn btn-sm btn-outline-secondary me-1"><i class="bi bi-pencil"></i></a>
@@ -68,3 +154,137 @@
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// ── Entity config for all boards (embedded from server) ─────────────────────
const BOARD_ENTITIES = {
{% for board in boards %}
{{ board.id }}: {
relays: {
{% for n in range(1, board.num_relays + 1) %}{% set e = board.get_relay_entity(n) %}
{{ n }}: {icon:"{{ e.icon }}",onColor:"{{ e.on_color }}",offColor:"{{ e.off_color }}"},
{% endfor %}
},
inputs: {
{% for n in range(1, board.num_inputs + 1) %}{% set e = board.get_input_entity(n) %}
{{ n }}: {icon:"{{ e.icon }}",activeColor:"{{ e.active_color }}",idleColor:"{{ e.idle_color }}"},
{% endfor %}
}
},
{% endfor %}
};
const socket = io();
socket.on("board_update", function(data) {
const bid = data.board_id;
const ent = BOARD_ENTITIES[bid] || {relays:{}, inputs:{}};
// card online badge
const onlineBadge = document.getElementById("online-badge-" + bid);
if (onlineBadge) {
onlineBadge.textContent = data.is_online ? "Online" : "Offline";
onlineBadge.className = "badge small ms-1 " + (data.is_online ? "text-bg-success" : "text-bg-secondary");
}
// table badge
const tblBadge = document.getElementById("tbl-badge-" + bid);
if (tblBadge) {
tblBadge.textContent = data.is_online ? "Online" : "Offline";
tblBadge.className = "badge " + (data.is_online ? "text-bg-success" : "text-bg-secondary");
}
// show/hide Check buttons (card + table)
const pingBtn = document.getElementById("ping-btn-" + bid);
const tblPingBtn = document.getElementById("tbl-ping-" + bid);
[pingBtn, tblPingBtn].forEach(btn => {
if (!btn) return;
btn.disabled = false;
btn.querySelector("i").className = "bi bi-arrow-clockwise";
if (data.is_online) btn.classList.add("d-none");
else btn.classList.remove("d-none");
});
// last-seen in card footer
if (data.last_seen) {
const ls = document.getElementById("last-seen-" + bid);
if (ls) ls.textContent = "Last seen " + new Date(data.last_seen).toLocaleTimeString();
}
// relay buttons
if (data.relay_states) {
for (const [key, isOn] of Object.entries(data.relay_states)) {
const n = parseInt(key.split("_")[1]);
const e = ent.relays[n] || {};
document.querySelectorAll(`[data-relay="${n}"][data-board="${bid}"]`).forEach(btn => {
btn.className = `btn btn-sm relay-btn ${isOn ? 'btn-' + (e.onColor||'success') : 'btn-outline-secondary'}`;
const icon = btn.querySelector("i");
if (icon && e.icon) icon.className = `bi ${e.icon}`;
});
}
}
// input badges — NC inversion: raw true = resting = idle
if (data.input_states) {
for (const [key, rawState] of Object.entries(data.input_states)) {
const n = parseInt(key.split("_")[1]);
const e = ent.inputs[n] || {};
const isActive = !rawState;
document.querySelectorAll(`[data-input="${n}"][data-board="${bid}"]`).forEach(span => {
span.className = `badge input-badge text-bg-${isActive ? (e.activeColor||'info') : (e.idleColor||'dark')}`;
});
}
}
});
// ── Manual board ping ────────────────────────────────────────────────────────
function pingBoard(btn, boardId) {
const url = btn.getAttribute("data-ping-url");
btn.disabled = true;
btn.querySelector("i").className = "bi bi-arrow-clockwise spin";
fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
})
.then(r => r.json())
.then(data => {
// socket.io board_update will handle badge + button visibility;
// also update last_seen in the table row right away
const tblLs = document.getElementById("tbl-lastseen-" + boardId);
if (tblLs) {
tblLs.textContent = data.last_seen
? new Date(data.last_seen).toLocaleString()
: (data.is_online ? "just now" : "—");
}
// re-enable in case socket.io doesn't fire (e.g. still offline)
btn.disabled = false;
btn.querySelector("i").className = "bi bi-arrow-clockwise";
})
.catch(() => {
btn.disabled = false;
btn.querySelector("i").className = "bi bi-arrow-clockwise";
});
}
// ── Relay toggle (AJAX — no page navigation) ─────────────────────────────────
function dashToggleRelay(btn) {
const url = btn.getAttribute("data-toggle-url");
btn.disabled = true;
fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
if (!data.hw_ok) {
btn.title = "(board unreachable — local state updated)";
setTimeout(() => btn.title = btn.getAttribute("data-label") || "", 3000);
}
})
.catch(() => { btn.disabled = false; });
}
</script>
{% endblock %}

View File

@@ -48,163 +48,4 @@
</div>
</div>
<!-- ── Board grid ─────────────────────────────────────────────────────────── -->
{% if boards %}
<div class="row g-3" id="board-grid">
{% for board in boards %}
<div class="col-md-6 col-xl-4" id="board-card-{{ board.id }}">
<div class="card board-card border-0 rounded-4 h-100 {% if board.is_online %}border-start border-3 border-success{% else %}border-start border-3 border-secondary{% endif %}">
<div class="card-header bg-transparent d-flex justify-content-between align-items-center pt-3">
<div>
<h5 class="mb-0 fw-semibold">{{ board.name }}</h5>
<span class="badge text-bg-secondary small">{{ board.board_type }}</span>
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} small ms-1" id="online-badge-{{ board.id }}">
{% if board.is_online %}Online{% else %}Offline{% endif %}
</span>
</div>
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="card-body">
<p class="text-secondary small mb-2"><i class="bi bi-hdd-network me-1"></i>{{ board.host }}:{{ board.port }}</p>
<!-- Quick relay controls -->
<div class="d-flex flex-wrap gap-2">
{% for n in range(1, board.num_relays + 1) %}
{% set relay_key = "relay_" ~ n %}
{% set is_on = board.relay_states.get(relay_key, false) %}
{% set e = board.get_relay_entity(n) %}
<button type="button"
class="btn btn-sm relay-btn {% if is_on %}btn-{{ e.on_color }}{% else %}btn-outline-secondary{% endif %}"
data-relay="{{ n }}" data-board="{{ board.id }}"
data-toggle-url="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}"
title="{{ e.name }}"
onclick="dashToggleRelay(this)">
<i class="bi {{ e.icon }}"></i>
{{ e.name }}
</button>
{% endfor %}
</div>
<!-- Input states -->
{% if board.num_inputs > 0 %}
<div class="mt-2 d-flex flex-wrap gap-1">
{% for n in range(1, board.num_inputs + 1) %}
{% set input_key = "input_" ~ n %}
{% set raw_state = board.input_states.get(input_key, true) %}
{% set is_active = not raw_state %}
{% set e = board.get_input_entity(n) %}
<span class="badge {% if is_active %}text-bg-{{ e.active_color }}{% else %}text-bg-dark{% endif %} input-badge"
data-input="{{ n }}" data-board="{{ board.id }}">
<i class="bi {{ e.icon }}"></i> {{ e.name }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-footer bg-transparent text-secondary small">
{% if board.last_seen %}
Last seen {{ board.last_seen.strftime('%H:%M:%S') }}
{% else %}
Never polled
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-motherboard display-2"></i>
<p class="mt-3 fs-5">No boards added yet.</p>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add your first board
</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// ── Entity config for all boards (embedded from server) ─────────────────────
const BOARD_ENTITIES = {
{% for board in boards %}
{{ board.id }}: {
relays: {
{% for n in range(1, board.num_relays + 1) %}{% set e = board.get_relay_entity(n) %}
{{ n }}: {icon:"{{ e.icon }}",onColor:"{{ e.on_color }}",offColor:"{{ e.off_color }}"},
{% endfor %}
},
inputs: {
{% for n in range(1, board.num_inputs + 1) %}{% set e = board.get_input_entity(n) %}
{{ n }}: {icon:"{{ e.icon }}",activeColor:"{{ e.active_color }}",idleColor:"{{ e.idle_color }}"},
{% endfor %}
}
},
{% endfor %}
};
const socket = io();
socket.on("board_update", function(data) {
const bid = data.board_id;
const ent = BOARD_ENTITIES[bid] || {relays:{}, inputs:{}};
// online badge
const onlineBadge = document.getElementById("online-badge-" + bid);
if (onlineBadge) {
onlineBadge.textContent = data.is_online ? "Online" : "Offline";
onlineBadge.className = "badge small ms-1 " + (data.is_online ? "text-bg-success" : "text-bg-secondary");
}
// relay buttons
if (data.relay_states) {
for (const [key, isOn] of Object.entries(data.relay_states)) {
const n = parseInt(key.split("_")[1]);
const e = ent.relays[n] || {};
document.querySelectorAll(`[data-relay="${n}"][data-board="${bid}"]`).forEach(btn => {
btn.className = `btn btn-sm relay-btn ${isOn ? 'btn-' + (e.onColor||'success') : 'btn-outline-secondary'}`;
const icon = btn.querySelector("i");
if (icon && e.icon) icon.className = `bi ${e.icon}`;
});
}
}
// input badges — NC inversion: raw true = resting = idle
if (data.input_states) {
for (const [key, rawState] of Object.entries(data.input_states)) {
const n = parseInt(key.split("_")[1]);
const e = ent.inputs[n] || {};
const isActive = !rawState;
document.querySelectorAll(`[data-input="${n}"][data-board="${bid}"]`).forEach(span => {
span.className = `badge input-badge text-bg-${isActive ? (e.activeColor||'info') : (e.idleColor||'dark')}`;
});
}
}
});
// ── Dashboard relay toggle (AJAX — no page navigation) ───────────────────────
function dashToggleRelay(btn) {
const url = btn.getAttribute("data-toggle-url");
btn.disabled = true;
fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
})
.then(r => r.json())
.then(data => {
// SocketIO will update the button colour; just re-enable it
btn.disabled = false;
if (!data.hw_ok) {
// Brief visual indicator that hardware was unreachable
btn.title = "(board unreachable — local state updated)";
setTimeout(() => btn.title = btn.getAttribute("data-label") || "", 3000);
}
})
.catch(() => { btn.disabled = false; });
}
</script>
{% endblock %}

View File

@@ -0,0 +1,333 @@
{% extends "base.html" %}
{% block title %}Builder {{ layout.name }}{% endblock %}
{% block content %}
<style>
/* Make builder fill the full content area with no extra padding */
#page-content { padding: 0 !important; overflow: hidden; }
/* ── Builder shell ───────────────────────────────────────────────────────── */
#lb-wrap {
display: flex;
height: calc(100vh);
background: #0d1117;
font-size: .85rem;
}
/* ── Left sidebar ────────────────────────────────────────────────────────── */
#lb-sidebar {
width: 230px;
min-width: 230px;
background: #161b22;
border-right: 1px solid #30363d;
display: flex;
flex-direction: column;
overflow: hidden;
}
#lb-sidebar-header {
padding: .75rem 1rem;
font-weight: 600;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: .5rem;
white-space: nowrap;
overflow: hidden;
}
#lb-sidebar-body {
flex: 1;
overflow-y: auto;
padding: .5rem;
}
/* ── Tool palette ────────────────────────────────────────────────────────── */
.lb-tool-btn {
display: flex;
align-items: center;
gap: .5rem;
width: 100%;
padding: .45rem .7rem;
border: 1px solid transparent;
border-radius: .4rem;
background: transparent;
color: #8b949e;
cursor: pointer;
text-align: left;
transition: background .15s, color .15s, border-color .15s;
margin-bottom: 2px;
font-size: .82rem;
}
.lb-tool-btn:hover { background: #1f2937; color: #e6edf3; }
.lb-tool-btn.active {
background: rgba(79,140,205,.15);
border-color: #4f8ccd;
color: #4f8ccd;
}
.lb-section-label {
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .06em;
color: #484f58;
padding: .4rem .3rem .2rem;
margin-top: .25rem;
}
/* ── Device chips (draggable) ────────────────────────────────────────────── */
.lb-board-group { margin-bottom: .5rem; }
.lb-board-name {
font-size: .72rem;
font-weight: 600;
color: #58a6ff;
padding: .2rem .3rem;
text-transform: uppercase;
letter-spacing: .05em;
display: flex;
align-items: center;
gap: .3rem;
}
.lb-device-chip {
display: flex;
align-items: center;
gap: .45rem;
padding: .35rem .6rem;
margin-bottom: 3px;
border-radius: .4rem;
border: 1px solid #30363d;
background: #1c2129;
color: #c9d1d9;
cursor: grab;
user-select: none;
font-size: .8rem;
transition: border-color .15s, background .15s;
}
.lb-device-chip:hover { border-color: #58a6ff; background: #1f2937; }
.lb-device-chip:active { cursor: grabbing; }
.lb-device-chip .chip-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Canvas area ─────────────────────────────────────────────────────────── */
#lb-canvas-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
#lb-toolbar {
background: #161b22;
border-bottom: 1px solid #30363d;
padding: .4rem .75rem;
display: flex;
align-items: center;
gap: .5rem;
flex-wrap: wrap;
}
#lb-toolbar .tb-sep { width: 1px; height: 24px; background: #30363d; margin: 0 .25rem; }
#lb-toolbar .tb-label { font-size: .72rem; color: #484f58; font-weight: 600; text-transform: uppercase; }
.tb-btn {
padding: .25rem .55rem;
border-radius: .35rem;
border: 1px solid #30363d;
background: #1c2129;
color: #8b949e;
cursor: pointer;
font-size: .8rem;
display: flex;
align-items: center;
gap: .3rem;
transition: background .15s, color .15s, border-color .15s;
white-space: nowrap;
}
.tb-btn:hover { background: #21262d; color: #e6edf3; }
.tb-btn.active { background: rgba(79,140,205,.18); border-color: #4f8ccd; color: #4f8ccd; }
.tb-btn.tb-mode { font-weight: 600; }
#lb-save-status {
font-size: .75rem;
color: #484f58;
margin-left: auto;
white-space: nowrap;
}
#lb-save-status.saving { color: #f0883e; }
#lb-save-status.saved { color: #3fb950; }
/* ── Konva stage container ───────────────────────────────────────────────── */
#lb-stage { flex: 1; overflow: hidden; position: relative; }
#konva-container { width: 100%; height: 100%; }
#konva-container.drag-over { outline: 2px dashed #4f8ccd; outline-offset: -2px; }
/* Ghost that follows cursor during drag-over canvas */
#lb-drag-ghost {
position: fixed;
pointer-events: none;
background: #1f2937;
border: 1px solid #58a6ff;
border-radius: .4rem;
padding: .3rem .6rem;
font-size: .8rem;
color: #58a6ff;
display: none;
z-index: 9999;
}
</style>
<div id="lb-wrap">
<!-- ══ Sidebar ══════════════════════════════════════════════════════════════ -->
<div id="lb-sidebar">
<div id="lb-sidebar-header">
<i class="bi bi-map text-info"></i>
<span class="text-truncate" title="{{ layout.name }}">{{ layout.name }}</span>
</div>
<div id="lb-sidebar-body">
<!-- ── Structure tools (shown in structure mode) ──────────────────── -->
<div id="lb-structure-palette">
<div class="lb-section-label">Draw tool</div>
<button class="lb-tool-btn active" data-tool="select">
<i class="bi bi-cursor"></i> Select / Move
</button>
<button class="lb-tool-btn" data-tool="pan">
<i class="bi bi-arrows-move"></i> Pan
</button>
<div class="lb-section-label" style="margin-top:.5rem">Structure shapes</div>
<button class="lb-tool-btn" data-tool="wall" style="border-left:3px solid #6e7681">
<i class="bi bi-square-fill" style="color:#6e7681"></i> Wall
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="room" style="border-left:3px solid #4f8ccd">
<i class="bi bi-square" style="color:#4f8ccd"></i> Room
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="door" style="border-left:3px solid #f0883e">
<i class="bi bi-slash-circle" style="color:#f0883e"></i> Door
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="window" style="border-left:3px solid #79c0ff">
<i class="bi bi-grip-horizontal" style="color:#79c0ff"></i> Window
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="fence" style="border-left:3px solid #8b5cf6">
<i class="bi bi-border-all" style="color:#8b5cf6"></i> Fence
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="text" style="border-left:3px solid #f0883e">
<i class="bi bi-fonts" style="color:#f0883e"></i> Text label
<span class="ms-auto text-secondary" style="font-size:.7rem">click</span>
</button>
<div class="lb-section-label" style="margin-top:.5rem">Canvas</div>
<button class="lb-tool-btn" id="btn-clear-structure" onclick="LB.clearStructure()">
<i class="bi bi-eraser text-danger"></i> Clear structure
</button>
</div>
<!-- ── Device palette (shown in devices mode) ─────────────────────── -->
<div id="lb-devices-palette" style="display:none">
<!-- Selected device panel (visible when a device is clicked) -->
<div id="lb-device-selected" style="display:none">
<div class="lb-section-label">Selected</div>
<div id="lb-sel-card" class="p-2 rounded mb-2" style="background:#1c2129;border:1px solid #4f8ccd">
<div id="lb-sel-name" style="color:#c9d1d9;font-weight:600;font-size:.84rem"></div>
<div id="lb-sel-board" class="text-secondary" style="font-size:.72rem"></div>
<div id="lb-sel-type" style="font-size:.7rem;color:#f0883e;margin-top:.15rem"></div>
</div>
<div class="text-secondary mb-2" style="font-size:.75rem;padding:.1rem .3rem">
<i class="bi bi-arrows-move me-1"></i>Drag to reposition
</div>
<button class="lb-tool-btn" onclick="LB.removeSelectedDevice()">
<i class="bi bi-trash text-danger"></i> Remove from canvas
</button>
<button class="lb-tool-btn" onclick="LB.deselectDevice()">
<i class="bi bi-x-circle" style="color:#8b949e"></i> Deselect
</button>
<hr style="border-color:#30363d;margin:.4rem 0">
</div>
<div class="lb-section-label" id="lb-drag-hint">Drag onto canvas</div>
<div id="lb-device-list">
<!-- populated by JS from LB_CONFIG.boards -->
</div>
</div>
<!-- ── View mode info (shown in view mode) ─────────────────────────── -->
<div id="lb-view-info" style="display:none; padding:.5rem; color:#8b949e; font-size:.8rem; line-height:1.5">
<i class="bi bi-info-circle me-1"></i>
<strong>Live view</strong><br>
Click relay / switch devices to toggle them.<br>
State updates in real-time via WebSocket.
</div>
</div><!-- sidebar-body -->
</div><!-- sidebar -->
<!-- ══ Canvas area ══════════════════════════════════════════════════════════ -->
<div id="lb-canvas-area">
<!-- Toolbar -->
<div id="lb-toolbar">
<span class="tb-label">Mode</span>
<button class="tb-btn tb-mode active" data-mode="structure">
<i class="bi bi-pencil-square"></i> Structure
</button>
<button class="tb-btn tb-mode" data-mode="devices">
<i class="bi bi-grid-3x3-gap"></i> Devices
</button>
<button class="tb-btn tb-mode" data-mode="view">
<i class="bi bi-eye"></i> Live View
</button>
<div class="tb-sep"></div>
<span class="tb-label">Zoom</span>
<button class="tb-btn" onclick="LB.zoom(1.2)"><i class="bi bi-zoom-in"></i></button>
<button class="tb-btn" onclick="LB.zoom(1/1.2)"><i class="bi bi-zoom-out"></i></button>
<button class="tb-btn" onclick="LB.resetView()"><i class="bi bi-fullscreen"></i></button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="LB.clearDevices()" id="btn-clear-devices" title="Remove all devices from canvas">
<i class="bi bi-trash text-warning"></i> Clear devices
</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="LB.save()" id="btn-save">
<i class="bi bi-floppy"></i> Save
</button>
<span id="lb-save-status">Unsaved</span>
</div>
<!-- Konva container -->
<div id="lb-stage">
<div id="konva-container"></div>
</div>
</div><!-- canvas-area -->
</div><!-- lb-wrap -->
<!-- Drag ghost tooltip -->
<div id="lb-drag-ghost"></div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/konva/konva.min.js') }}"></script>
<script>
// ── Server-injected config ────────────────────────────────────────────────────
window.LB_CONFIG = {
layoutId: {{ layout.id }},
saveUrl: "{{ url_for('layouts.save_layout', layout_id=layout.id) }}",
canvasJson: {{ (layout.canvas_json or 'null') | safe }},
boards: {{ boards_data | tojson | safe }},
toggleBase: "/boards/{boardId}/relay/{relayNum}/toggle",
};
</script>
<script src="{{ url_for('static', filename='js/layout_builder.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Layouts Location Management{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="fw-bold mb-0"><i class="bi bi-map me-2 text-info"></i>Layouts</h2>
{% if current_user.is_admin() %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newLayoutModal">
<i class="bi bi-plus-circle me-1"></i> New Layout
</button>
{% endif %}
</div>
{% if layouts %}
<div class="row g-3">
{% for layout in layouts %}
<div class="col-sm-6 col-xl-4">
<div class="card border-0 rounded-4 h-100">
<!-- thumbnail or placeholder -->
<div class="rounded-top-4 overflow-hidden d-flex align-items-center justify-content-center bg-dark"
style="height:180px">
{% if layout.thumbnail_b64 %}
<img src="{{ layout.thumbnail_b64 }}" alt="thumbnail"
style="max-width:100%;max-height:100%;object-fit:contain" />
{% else %}
<i class="bi bi-map display-1 text-secondary opacity-25"></i>
{% endif %}
</div>
<div class="card-body">
<h5 class="fw-semibold mb-1">{{ layout.name }}</h5>
{% if layout.description %}
<p class="text-secondary small mb-0">{{ layout.description }}</p>
{% endif %}
</div>
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center">
<span class="text-secondary small">
{% if layout.updated_at %}
Updated {{ layout.updated_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
Created {{ layout.created_at.strftime('%Y-%m-%d') }}
{% endif %}
</span>
<div class="d-flex gap-1">
<a href="{{ url_for('layouts.builder', layout_id=layout.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil-square me-1"></i>Edit
</a>
{% if current_user.is_admin() %}
<form method="POST" action="{{ url_for('layouts.delete_layout', layout_id=layout.id) }}"
class="d-inline" onsubmit="return confirm('Delete layout \'{{ layout.name }}\'?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-map display-2 opacity-25"></i>
<p class="mt-3 fs-5">No layouts yet.</p>
{% if current_user.is_admin() %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newLayoutModal">
<i class="bi bi-plus-circle me-1"></i> Create your first layout
</button>
{% endif %}
</div>
{% endif %}
<!-- ── New layout modal ───────────────────────────────────────────────────── -->
{% if current_user.is_admin() %}
<div class="modal fade" id="newLayoutModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('layouts.create_layout') }}">
<div class="modal-content">
<div class="modal-header border-0">
<h5 class="modal-title"><i class="bi bi-map me-2 text-info"></i>New Layout</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required maxlength="128"
placeholder="e.g. Ground Floor, Garden" autofocus />
</div>
<div class="mb-3">
<label class="form-label text-secondary">Description <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" name="description" maxlength="256"
placeholder="Short description" />
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-right-circle me-1"></i>Create & Open Builder
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -14,8 +14,10 @@ class Config:
f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'location_mgmt.db')}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# How often (seconds) the board poller updates relay states in the background
# How often (seconds) the fast poller updates ONLINE boards
BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 10))
# How often (seconds) offline boards are rechecked to see if they came back
OFFLINE_RECHECK_INTERVAL = int(os.environ.get("OFFLINE_RECHECK_INTERVAL", 60))
# Base URL this server is reachable at (boards will POST webhooks here)
SERVER_BASE_URL = os.environ.get("SERVER_BASE_URL", "http://localhost:5000")

33
run.py
View File

@@ -11,31 +11,48 @@ import threading
import time
from app import create_app, socketio
from app.services.board_service import poll_all_boards
from app.services.board_service import poll_online_boards, recheck_offline_boards
app = create_app(os.environ.get("FLASK_ENV", "development"))
def _background_poller():
"""Poll all boards in a loop every BOARD_POLL_INTERVAL seconds."""
def _online_poller():
"""Fast loop — polls only online boards every BOARD_POLL_INTERVAL seconds."""
interval = app.config.get("BOARD_POLL_INTERVAL", 10)
while True:
try:
poll_all_boards(app)
poll_online_boards(app)
except Exception as exc:
app.logger.warning("Poller error: %s", exc)
app.logger.warning("Online poller error: %s", exc)
time.sleep(interval)
def _offline_recheck_poller():
"""Slow loop — probes offline boards every OFFLINE_RECHECK_INTERVAL seconds.
Waits one full interval before the first check so startup is clean.
"""
interval = app.config.get("OFFLINE_RECHECK_INTERVAL", 60)
while True:
time.sleep(interval) # wait first, then check
try:
recheck_offline_boards(app)
except Exception as exc:
app.logger.warning("Offline recheck error: %s", exc)
def create_socketio_app():
"""WSGI callable for gunicorn / production."""
return socketio.middleware(app)
if __name__ == "__main__":
# Start background board poller
t = threading.Thread(target=_background_poller, daemon=True)
t.start()
# Werkzeug debug-reloader starts two processes; only start the poller in
# the actual worker (WERKZEUG_RUN_MAIN=true) to avoid duplicate polling.
# In non-debug mode (WERKZEUG_RUN_MAIN is unset) we always start it.
if not app.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true":
threading.Thread(target=_online_poller, daemon=True).start()
threading.Thread(target=_offline_recheck_poller, daemon=True).start()
port = int(os.environ.get("PORT", 5000))
socketio.run(app, host="0.0.0.0", port=port, debug=app.debug, allow_unsafe_werkzeug=True)