Initial commit: Location Management Flask app

This commit is contained in:
ske087
2026-02-26 19:24:17 +02:00
commit 7a22575dab
52 changed files with 3481 additions and 0 deletions

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services package."""

View File

@@ -0,0 +1,112 @@
"""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,
})
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()]
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=4)
# ── 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)

View File

@@ -0,0 +1,71 @@
"""Workflow execution engine.
When a board input fires a webhook, this module evaluates all matching
enabled workflows and triggers the configured relay action.
"""
import logging
from datetime import datetime
from app import db, socketio
from app.models.workflow import Workflow
from app.models.board import Board
from app.services import board_service
logger = logging.getLogger(__name__)
def process_input_event(board_id: int, input_num: int, new_state: bool) -> None:
"""Called from the webhook route. Finds and fires matching workflows."""
# Determine event type from state
event = "press" if new_state else "release"
workflows = Workflow.query.filter_by(
trigger_board_id=board_id,
trigger_input=input_num,
is_enabled=True,
).all()
for wf in workflows:
if wf.trigger_event not in (event, "both"):
continue
target = db.session.get(Board, wf.action_board_id)
if target is None:
logger.warning("Workflow %d: target board %d not found", wf.id, wf.action_board_id)
continue
success = False
new_relay_state = None
if wf.action_type == "on":
success = board_service.set_relay(target, wf.action_relay, True)
new_relay_state = True
elif wf.action_type == "off":
success = board_service.set_relay(target, wf.action_relay, False)
new_relay_state = False
elif wf.action_type == "toggle":
new_relay_state = board_service.toggle_relay(target, wf.action_relay)
success = new_relay_state is not None
if success:
wf.last_triggered = datetime.utcnow()
# Update cached relay state in DB
if new_relay_state is not None:
states = target.relay_states
states[f"relay_{wf.action_relay}"] = new_relay_state
target.relay_states = states
db.session.commit()
logger.info(
"Workflow '%s' fired: Board#%d.input%d=%s → Board#%d.relay%d [%s]",
wf.name, board_id, input_num, new_state,
target.id, wf.action_relay, wf.action_type,
)
# Push update so dashboard reflects new relay state instantly
socketio.emit("board_update", {
"board_id": target.id,
"relay_states": target.relay_states,
"is_online": target.is_online,
})
else:
logger.warning("Workflow '%s' failed to reach board %s", wf.name, target.name)