"""Board service — thin dispatcher layer. All board-type-specific logic lives in ``app/drivers//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)