Files
location_managemet/app/services/board_service.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

136 lines
4.7 KiB
Python

"""Board service — thin dispatcher layer.
All board-type-specific logic lives in ``app/drivers/<board_type>/driver.py``.
This module just resolves the right driver from the registry and calls it.
"""
from __future__ import annotations
import logging
import threading
from datetime import datetime
from app import db, socketio
from app.models.board import Board
from app.drivers.registry import registry
logger = logging.getLogger(__name__)
def _driver(board: Board):
"""Resolve the driver for a board, falling back gracefully."""
drv = registry.get(board.board_type)
if drv is None:
logger.warning(
"No driver found for board_type='%s' (board '%s'). "
"Is the driver folder present in app/drivers/?",
board.board_type, board.name,
)
return drv
# ── relay control (public API used by routes) ─────────────────────────────────
def get_relay_status(board: Board, relay_num: int) -> bool | None:
drv = _driver(board)
return drv.get_relay_status(board, relay_num) if drv else None
def set_relay(board: Board, relay_num: int, state: bool) -> bool:
drv = _driver(board)
return drv.set_relay(board, relay_num, state) if drv else False
def toggle_relay(board: Board, relay_num: int) -> bool | None:
drv = _driver(board)
return drv.toggle_relay(board, relay_num) if drv else None
# ── polling ───────────────────────────────────────────────────────────────────
def poll_board(app, board_id: int) -> None:
"""Fetch all I/O states for one board and persist to DB."""
with app.app_context():
board = db.session.get(Board, board_id)
if board is None:
return
drv = _driver(board)
if drv is None:
return
result = drv.poll(board)
# Inputs: hardware is source of truth — always overwrite from poll
if result.get("input_states"):
board.input_states = result["input_states"]
# Relays: server is source of truth — only sync from hardware when the
# board was previously offline (re-sync after reconnect), not during
# normal operation (to avoid overwriting optimistic state from commands)
was_offline = not board.is_online
if was_offline and result.get("relay_states"):
board.relay_states = result["relay_states"]
board.is_online = result.get("is_online", False)
if result.get("firmware_version"):
board.firmware_version = result["firmware_version"]
if board.is_online:
board.last_seen = datetime.utcnow()
db.session.commit()
socketio.emit("board_update", {
"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,
})
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
]
for t in threads:
t.start()
for t in threads:
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 ──────────────────────────────────────────────────────
def register_webhook(board: Board, server_base_url: str) -> bool:
drv = _driver(board)
if drv is None:
return False
callback_url = f"{server_base_url}/api/webhook/{board.id}"
return drv.register_webhook(board, callback_url)