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

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
# Virtual environment
.venv/
venv/
env/
# Flask / SQLite
instance/
*.db
*.sqlite3
# Logs
*.log
*.log.*
# OS
.DS_Store
Thumbs.db
# Environment variables
.env
.env.*
# IDE
.vscode/
.idea/
# pytest
.pytest_cache/
htmlcov/
.coverage

111
app/__init__.py Normal file
View File

@@ -0,0 +1,111 @@
"""Flask application factory."""
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_socketio import SocketIO
from config import config_map
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
socketio = SocketIO()
def create_app(config_name: str = "default") -> Flask:
app = Flask(__name__, template_folder="templates", static_folder="static")
app.config.from_object(config_map[config_name])
# ── extensions ────────────────────────────────────────────────────────────
db.init_app(app)
migrate.init_app(app, db)
socketio.init_app(app, cors_allowed_origins="*", async_mode="threading")
login_manager.init_app(app)
login_manager.login_view = "auth.login"
login_manager.login_message = "Please log in to access this page."
login_manager.login_message_category = "warning"
# ── blueprints ────────────────────────────────────────────────────────────
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
from app.routes.boards import boards_bp
from app.routes.workflows import workflows_bp
from app.routes.api import api_bp
from app.routes.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(boards_bp, url_prefix="/boards")
app.register_blueprint(workflows_bp, url_prefix="/workflows")
app.register_blueprint(api_bp, url_prefix="/api")
app.register_blueprint(admin_bp, url_prefix="/admin")
# ── user loader ───────────────────────────────────────────────────────────
from app.models.user import User
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
# ── discover board drivers ─────────────────────────────────────────────────
from app.drivers.registry import registry
registry.discover()
app.logger.info("Board drivers loaded: %s", [d.DRIVER_ID for d in registry.all()])
# ── create tables & seed admin on first run ───────────────────────────────
with app.app_context():
db.create_all()
_seed_admin(app)
_add_entities_column(app)
_migrate_board_types(app)
return app
def _add_entities_column(app: Flask) -> None:
"""Add entities_json column to boards table if it doesn't exist yet."""
from sqlalchemy import text
with app.app_context():
try:
db.session.execute(text("ALTER TABLE boards ADD COLUMN entities_json TEXT DEFAULT '{}'"))
db.session.commit()
app.logger.info("Added entities_json column to boards table")
except Exception:
db.session.rollback() # Column already exists — safe to ignore
def _migrate_board_types(app: Flask) -> None:
"""Rename legacy board_type values to current driver IDs."""
from app.models.board import Board
renames = {
"olimex_esp32_c6": "olimex_esp32_c6_evb",
}
total = 0
for old, new in renames.items():
rows = Board.query.filter_by(board_type=old).all()
for b in rows:
b.board_type = new
total += 1
if total:
db.session.commit()
app.logger.info("Migrated %d board(s) to updated driver IDs", total)
def _seed_admin(app: Flask) -> None:
"""Create default admin account if no users exist."""
from app.models.user import User
from werkzeug.security import generate_password_hash
if User.query.count() == 0:
admin = User(
username="admin",
password_hash=generate_password_hash("admin"),
role="admin",
is_active=True,
)
db.session.add(admin)
db.session.commit()
app.logger.info("Default admin user created (username=admin password=admin)")

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

@@ -0,0 +1 @@
"""Drivers package — auto-discovered plugins."""

76
app/drivers/base.py Normal file
View File

@@ -0,0 +1,76 @@
"""Base class every board driver must implement.
To create a new board driver:
1. Make a subfolder inside ``app/drivers/`` (e.g. ``my_custom_board/``)
2. Add a ``driver.py`` that subclasses ``BoardDriver``
3. Set the class-level metadata attributes
4. The registry will auto-discover and register it at startup — no other
changes needed anywhere in the application.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.board import Board
class BoardDriver(ABC):
"""Abstract interface all board drivers must satisfy."""
# ── Metadata (override in subclass) ──────────────────────────────────────
#: Unique identifier stored in Board.board_type column
DRIVER_ID: str = ""
#: Human-readable name shown in the UI
DISPLAY_NAME: str = ""
#: Short description shown on the Add-board form
DESCRIPTION: str = ""
#: Default I/O counts pre-filled when this type is selected
DEFAULT_NUM_RELAYS: int = 4
DEFAULT_NUM_INPUTS: int = 4
#: Firmware download link shown in the UI (optional)
FIRMWARE_URL: str = ""
# ── Core API ──────────────────────────────────────────────────────────────
@abstractmethod
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
"""Return current relay state or None on comm error."""
@abstractmethod
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
"""Set relay ON/OFF. Return True on success."""
@abstractmethod
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
"""Toggle relay. Return new state or None on error."""
@abstractmethod
def poll(self, board: "Board") -> dict:
"""Fetch all I/O states.
Must return::
{
"relay_states": {"relay_1": True, ...},
"input_states": {"input_1": False, ...},
"is_online": True,
"firmware_version": "1.0.0", # optional
}
"""
@abstractmethod
def register_webhook(self, board: "Board", callback_url: str) -> bool:
"""Tell the board where to POST input events. Return True on success."""
# ── Optional hooks ────────────────────────────────────────────────────────
def on_board_added(self, board: "Board", server_base_url: str) -> None:
"""Called once right after a board is created in the DB."""
self.register_webhook(board, f"{server_base_url}/api/webhook/{board.id}")
def on_board_deleted(self, board: "Board") -> None:
"""Called just before a board is deleted from the DB."""
def __repr__(self) -> str:
return f"<Driver {self.DRIVER_ID}>"

View File

@@ -0,0 +1 @@
"""Generic ESP32 driver package."""

View File

@@ -0,0 +1,90 @@
"""Generic ESP32 board driver (custom firmware).
This is a template driver for custom ESP32 firmware.
Copy this folder, rename it, and implement the HTTP endpoints
to match your own firmware's API.
Expected firmware endpoints (same shape as Olimex by default):
POST /relay/on?relay=<n>
POST /relay/off?relay=<n>
POST /relay/toggle?relay=<n>
GET /relay/status?relay=<n> → {"state": <bool>}
GET /input/status?input=<n> → {"state": <bool>}
POST /register?callback_url=<url>
"""
from __future__ import annotations
import logging
import requests
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
_TIMEOUT = 3
def _get(url):
try:
r = requests.get(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("GET %s%s", url, exc)
return None
def _post(url):
try:
r = requests.post(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("POST %s%s", url, exc)
return None
class GenericESP32Driver(BoardDriver):
"""Generic ESP32 driver — uses the same REST conventions as the Olimex board.
Customise the endpoint paths below to match your firmware."""
DRIVER_ID = "generic_esp32"
DISPLAY_NAME = "Generic ESP32"
DESCRIPTION = "Custom ESP32 firmware · same REST API shape as Olimex"
DEFAULT_NUM_RELAYS = 4
DEFAULT_NUM_INPUTS = 4
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
action = "on" if state else "off"
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def poll(self, board: "Board") -> dict:
relay_states, input_states, online = {}, {}, False
for n in range(1, board.num_relays + 1):
data = _get(f"{board.base_url}/relay/status?relay={n}")
if data is not None:
relay_states[f"relay_{n}"] = bool(data.get("state", False))
online = True
for n in range(1, board.num_inputs + 1):
data = _get(f"{board.base_url}/input/status?input={n}")
if data is not None:
input_states[f"input_{n}"] = bool(data.get("state", False))
online = True
return {"relay_states": relay_states, "input_states": input_states, "is_online": online}
def register_webhook(self, board: "Board", callback_url: str) -> bool:
return _post(f"{board.base_url}/register?callback_url={callback_url}") is not None

View File

@@ -0,0 +1 @@
"""Generic ESP8266 driver package."""

View File

@@ -0,0 +1,80 @@
"""Generic ESP8266 board driver (custom firmware).
Lighter board — defaults to 2 relays and 2 inputs.
Adjust DEFAULT_NUM_RELAYS / DEFAULT_NUM_INPUTS to match your hardware.
"""
from __future__ import annotations
import logging
import requests
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
_TIMEOUT = 3
def _get(url):
try:
r = requests.get(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("GET %s%s", url, exc)
return None
def _post(url):
try:
r = requests.post(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("POST %s%s", url, exc)
return None
class GenericESP8266Driver(BoardDriver):
"""Generic ESP8266 driver — lighter board variant."""
DRIVER_ID = "generic_esp8266"
DISPLAY_NAME = "Generic ESP8266"
DESCRIPTION = "Custom ESP8266 firmware · 2 relays · 2 inputs (configurable)"
DEFAULT_NUM_RELAYS = 2
DEFAULT_NUM_INPUTS = 2
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
action = "on" if state else "off"
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def poll(self, board: "Board") -> dict:
relay_states, input_states, online = {}, {}, False
for n in range(1, board.num_relays + 1):
data = _get(f"{board.base_url}/relay/status?relay={n}")
if data is not None:
relay_states[f"relay_{n}"] = bool(data.get("state", False))
online = True
for n in range(1, board.num_inputs + 1):
data = _get(f"{board.base_url}/input/status?input={n}")
if data is not None:
input_states[f"input_{n}"] = bool(data.get("state", False))
online = True
return {"relay_states": relay_states, "input_states": input_states, "is_online": online}
def register_webhook(self, board: "Board", callback_url: str) -> bool:
return _post(f"{board.base_url}/register?callback_url={callback_url}") is not None

View File

@@ -0,0 +1 @@
"""Olimex ESP32-C6-EVB driver package."""

View File

@@ -0,0 +1,117 @@
"""Olimex ESP32-C6-EVB board driver.
Hardware
--------
- 4 relays (outputs)
- 4 digital inputs
- HTTP REST API served directly by the board on port 80
API endpoints
-------------
POST /relay/on?relay=<n> → {"state": true}
POST /relay/off?relay=<n> → {"state": false}
POST /relay/toggle?relay=<n> → {"state": <new>}
GET /relay/status?relay=<n> → {"state": <bool>}
GET /input/status?input=<n> → {"state": <bool>}
POST /register?callback_url=<url> → {"status": "ok"}
Webhook (board → server)
------------------------
The board POSTs to the registered callback_url whenever an input changes:
POST <callback_url>
{"input": <n>, "state": <bool>}
"""
from __future__ import annotations
import logging
import requests
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
_TIMEOUT = 3
def _get(url: str) -> dict | None:
try:
r = requests.get(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("GET %s%s", url, exc)
return None
def _post(url: str) -> dict | None:
try:
r = requests.post(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("POST %s%s", url, exc)
return None
class OlimexESP32C6EVBDriver(BoardDriver):
"""Driver for the Olimex ESP32-C6-EVB board."""
DRIVER_ID = "olimex_esp32_c6_evb"
DISPLAY_NAME = "Olimex ESP32-C6-EVB"
DESCRIPTION = "4 relays · 4 digital inputs · HTTP REST + webhook callbacks"
DEFAULT_NUM_RELAYS = 4
DEFAULT_NUM_INPUTS = 4
FIRMWARE_URL = "https://github.com/OLIMEX/ESP32-C6-EVB"
# ── relay control ─────────────────────────────────────────────────────────
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
action = "on" if state else "off"
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
return bool(data["state"]) if data is not None else None
# ── poll ──────────────────────────────────────────────────────────────────
def poll(self, board: "Board") -> dict:
relay_states: dict = {}
input_states: dict = {}
online = False
for n in range(1, board.num_relays + 1):
data = _get(f"{board.base_url}/relay/status?relay={n}")
if data is not None:
relay_states[f"relay_{n}"] = bool(data.get("state", False))
online = True
for n in range(1, board.num_inputs + 1):
data = _get(f"{board.base_url}/input/status?input={n}")
if data is not None:
input_states[f"input_{n}"] = bool(data.get("state", True))
online = True
return {
"relay_states": relay_states,
"input_states": input_states,
"is_online": online,
}
# ── webhook registration ──────────────────────────────────────────────────
def register_webhook(self, board: "Board", callback_url: str) -> bool:
url = f"{board.base_url}/register?callback_url={callback_url}"
ok = _post(url) is not None
if ok:
logger.info("Webhook registered on board '%s'%s", board.name, callback_url)
else:
logger.warning("Webhook registration failed for board '%s'", board.name)
return ok

View File

@@ -0,0 +1,18 @@
{
"driver_id": "olimex_esp32_c6_evb",
"display_name": "Olimex ESP32-C6-EVB",
"description": "Olimex ESP32-C6-EVB board with 4 relays and 4 digital inputs. Communicates via HTTP REST API with webhook callbacks.",
"manufacturer": "Olimex",
"chip": "ESP32-C6 WROOM-1",
"default_num_relays": 4,
"default_num_inputs": 4,
"firmware_url": "https://github.com/OLIMEX/ESP32-C6-EVB",
"api": {
"relay_on": "POST /relay/on?relay={n}",
"relay_off": "POST /relay/off?relay={n}",
"relay_toggle": "POST /relay/toggle?relay={n}",
"relay_status": "GET /relay/status?relay={n}",
"input_status": "GET /input/status?input={n}",
"register": "POST /register?callback_url={url}"
}
}

104
app/drivers/registry.py Normal file
View File

@@ -0,0 +1,104 @@
"""Driver registry — auto-discovers board drivers from sub-folders.
At application startup call ``registry.discover()``. Every sub-folder of
``app/drivers/`` that contains a ``driver.py`` with a ``BoardDriver`` subclass
is registered automatically. No manual imports needed.
Usage
-----
::
from app.drivers.registry import registry
driver = registry.get("olimex_esp32_c6_evb")
driver.set_relay(board, 1, True)
# All registered drivers (for UI drop-downs):
all_drivers = registry.all()
"""
from __future__ import annotations
import importlib
import inspect
import logging
import os
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
_DRIVERS_DIR = os.path.dirname(__file__)
class DriverRegistry:
def __init__(self):
self._drivers: dict[str, BoardDriver] = {}
# ── discovery ─────────────────────────────────────────────────────────────
def discover(self) -> None:
"""Scan ``app/drivers/`` sub-folders and register found drivers."""
for entry in sorted(os.scandir(_DRIVERS_DIR), key=lambda e: e.name):
if not entry.is_dir() or entry.name.startswith(("_", ".")):
continue
driver_module_path = os.path.join(entry.path, "driver.py")
if not os.path.isfile(driver_module_path):
logger.debug("Skipping %s — no driver.py found", entry.name)
continue
module_name = f"app.drivers.{entry.name}.driver"
try:
mod = importlib.import_module(module_name)
self._register_from_module(mod, entry.name)
except Exception as exc:
logger.error("Failed to load driver '%s': %s", entry.name, exc, exc_info=True)
def _register_from_module(self, mod, folder_name: str) -> None:
for _name, obj in inspect.getmembers(mod, inspect.isclass):
if (
issubclass(obj, BoardDriver)
and obj is not BoardDriver
and obj.DRIVER_ID
):
instance = obj()
self._drivers[obj.DRIVER_ID] = instance
logger.info(
"Board driver registered: '%s' (%s) from folder '%s'",
obj.DRIVER_ID, obj.DISPLAY_NAME, folder_name,
)
return
logger.warning(
"Folder '%s' has driver.py but no BoardDriver subclass with DRIVER_ID found",
folder_name,
)
# ── lookup ────────────────────────────────────────────────────────────────
def get(self, driver_id: str) -> BoardDriver | None:
return self._drivers.get(driver_id)
def get_or_default(self, driver_id: str) -> BoardDriver:
"""Return driver or fall back to the generic_esp32 stub."""
return self._drivers.get(driver_id) or self._drivers.get("generic_esp32") or next(iter(self._drivers.values()))
def all(self) -> list[BoardDriver]:
return list(self._drivers.values())
def choices(self) -> list[tuple[str, str]]:
"""Return list of (DRIVER_ID, DISPLAY_NAME) for form select fields."""
return [(d.DRIVER_ID, d.DISPLAY_NAME) for d in self._drivers.values()]
def is_registered(self, driver_id: str) -> bool:
return driver_id in self._drivers
def __len__(self) -> int:
return len(self._drivers)
def __repr__(self) -> str:
return f"<DriverRegistry drivers={list(self._drivers.keys())}>"
# Singleton — import this everywhere
registry = DriverRegistry()

6
app/models/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Models package."""
from .user import User
from .board import Board
from .workflow import Workflow
__all__ = ["User", "Board", "Workflow"]

144
app/models/board.py Normal file
View File

@@ -0,0 +1,144 @@
"""Board model.
Supported board types
---------------------
olimex_esp32_c6 Olimex ESP32-C6-EVB (4 relays, 4 digital inputs)
Communicates via HTTP REST + webhook callbacks
generic_esp32 Generic ESP32 with custom firmware (configurable I/O)
generic_esp8266 Generic ESP8266 with custom firmware (configurable I/O)
"""
import json
from datetime import datetime
from app import db
class Board(db.Model):
__tablename__ = "boards"
id = db.Column(db.Integer, primary_key=True)
# Human-readable local name
name = db.Column(db.String(128), nullable=False)
board_type = db.Column(db.String(32), nullable=False, default="olimex_esp32_c6_evb")
host = db.Column(db.String(128), nullable=False)
port = db.Column(db.Integer, nullable=False, default=80)
# Number of relays / outputs exposed by this board
num_relays = db.Column(db.Integer, nullable=False, default=4)
# Number of digital inputs
num_inputs = db.Column(db.Integer, nullable=False, default=4)
# JSON blob: {"relay_1": true, "relay_2": false, ...}
relay_states_json = db.Column(db.Text, default="{}")
# JSON blob: {"input_1": false, "input_2": true, ...}
input_states_json = db.Column(db.Text, default="{}")
# Relay and input labels (legacy, kept for backward compat)
labels_json = db.Column(db.Text, default="{}")
# Entity config: {"relay_1": {"type": "light", "name": "...", "icon": ""}, ...}
entities_json = db.Column(db.Text, default="{}")
# Whether this board is currently reachable
is_online = db.Column(db.Boolean, default=False)
last_seen = db.Column(db.DateTime, nullable=True)
# Firmware version string reported by the board
firmware_version = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# ── relationships ────────────────────────────────────────────────
workflows_trigger = db.relationship(
"Workflow", foreign_keys="Workflow.trigger_board_id",
back_populates="trigger_board", cascade="all, delete-orphan"
)
workflows_target = db.relationship(
"Workflow", foreign_keys="Workflow.action_board_id",
back_populates="action_board"
)
# ── helpers ──────────────────────────────────────────────────────
@property
def relay_states(self) -> dict:
try:
return json.loads(self.relay_states_json or "{}")
except (ValueError, TypeError):
return {}
@relay_states.setter
def relay_states(self, value: dict):
self.relay_states_json = json.dumps(value)
@property
def input_states(self) -> dict:
try:
return json.loads(self.input_states_json or "{}")
except (ValueError, TypeError):
return {}
@input_states.setter
def input_states(self, value: dict):
self.input_states_json = json.dumps(value)
@property
def labels(self) -> dict:
try:
return json.loads(self.labels_json or "{}")
except (ValueError, TypeError):
return {}
@labels.setter
def labels(self, value: dict):
self.labels_json = json.dumps(value)
@property
def entities(self) -> dict:
try:
return json.loads(self.entities_json or "{}")
except (ValueError, TypeError):
return {}
@entities.setter
def entities(self, value: dict):
self.entities_json = json.dumps(value)
def get_relay_entity(self, n: int) -> dict:
from app.models.entity_types import RELAY_ENTITY_TYPES
cfg = self.entities.get(f"relay_{n}", {})
etype = cfg.get("type", "switch")
tdef = RELAY_ENTITY_TYPES.get(etype, RELAY_ENTITY_TYPES["switch"])
# name: entities > legacy labels > type default
name = cfg.get("name", "").strip() or self.labels.get(f"relay_{n}", "") or f"{tdef['label']} {n}"
icon = cfg.get("icon", "").strip() or tdef["icon"]
return {
"name": name,
"icon": icon,
"type": etype,
"on_color": tdef["on"][0],
"on_label": tdef["on"][1],
"off_color": tdef["off"][0],
"off_label": tdef["off"][1],
}
def get_input_entity(self, n: int) -> dict:
from app.models.entity_types import INPUT_ENTITY_TYPES
cfg = self.entities.get(f"input_{n}", {})
etype = cfg.get("type", "generic")
tdef = INPUT_ENTITY_TYPES.get(etype, INPUT_ENTITY_TYPES["generic"])
name = cfg.get("name", "").strip() or self.labels.get(f"input_{n}", "") or f"{tdef['label']} {n}"
icon = cfg.get("icon", "").strip() or tdef["icon"]
return {
"name": name,
"icon": icon,
"type": etype,
"active_color": tdef["active"][0],
"active_label": tdef["active"][1],
"idle_color": tdef["idle"][0],
"idle_label": tdef["idle"][1],
}
def get_relay_label(self, relay_num: int) -> str:
return self.get_relay_entity(relay_num)["name"]
def get_input_label(self, input_num: int) -> str:
return self.get_input_entity(input_num)["name"]
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
def __repr__(self) -> str:
return f"<Board {self.name} ({self.board_type}) @ {self.host}:{self.port}>"

View File

@@ -0,0 +1,95 @@
"""Entity type definitions for relay outputs and digital inputs.
Each type declares its default icon (Bootstrap Icons class), display label,
and the color/text to show for each state. These drive the UI automatically
so adding a new type here is the only step required.
"""
from collections import OrderedDict
# ── Relay / output entity types ───────────────────────────────────────────────
# key → {icon, label, on: (color, text), off: (color, text)}
RELAY_ENTITY_TYPES: dict = OrderedDict([
("switch", {"icon": "bi-toggles", "label": "Switch", "on": ("success", "ON"), "off": ("secondary", "OFF")}),
("light", {"icon": "bi-lightbulb-fill", "label": "Light", "on": ("warning", "ON"), "off": ("secondary", "OFF")}),
("outlet", {"icon": "bi-plug-fill", "label": "Outlet", "on": ("success", "ON"), "off": ("secondary", "OFF")}),
("fan", {"icon": "bi-fan", "label": "Fan", "on": ("info", "RUN"), "off": ("secondary", "STOP")}),
("pump", {"icon": "bi-water", "label": "Pump", "on": ("primary", "RUN"), "off": ("secondary", "STOP")}),
("heater", {"icon": "bi-thermometer-high", "label": "Heater", "on": ("danger", "ON"), "off": ("secondary", "OFF")}),
("lock", {"icon": "bi-lock-fill", "label": "Lock", "on": ("danger", "LOCKED"), "off": ("success", "OPEN")}),
("gate", {"icon": "bi-door-open-fill", "label": "Gate", "on": ("warning", "OPEN"), "off": ("secondary", "CLOSED")}),
("valve", {"icon": "bi-moisture", "label": "Valve", "on": ("info", "OPEN"), "off": ("secondary", "CLOSED")}),
("siren", {"icon": "bi-megaphone-fill", "label": "Siren", "on": ("danger", "ALERT"), "off": ("secondary", "SILENT")}),
])
# ── Input / sensor entity types ───────────────────────────────────────────────
# NC contacts: raw True = resting → is_active = False (idle)
# raw False = triggered → is_active = True (active)
# key → {icon, label, active: (color, text), idle: (color, text)}
INPUT_ENTITY_TYPES: dict = OrderedDict([
("generic", {"icon": "bi-circle-fill", "label": "Input", "active": ("info", "ACTIVE"), "idle": ("secondary", "IDLE")}),
("button", {"icon": "bi-hand-index-thumb-fill", "label": "Button", "active": ("warning", "PRESSED"), "idle": ("secondary", "IDLE")}),
("door", {"icon": "bi-door-open-fill", "label": "Door", "active": ("warning", "OPEN"), "idle": ("success", "CLOSED")}),
("window", {"icon": "bi-window-dash", "label": "Window", "active": ("warning", "OPEN"), "idle": ("success", "CLOSED")}),
("motion", {"icon": "bi-person-walking", "label": "Motion", "active": ("danger", "MOTION"), "idle": ("secondary", "CLEAR")}),
("contact", {"icon": "bi-magnet-fill", "label": "Contact", "active": ("warning", "OPEN"), "idle": ("success", "CLOSED")}),
("smoke", {"icon": "bi-cloud-haze2", "label": "Smoke", "active": ("danger", "ALARM"), "idle": ("success", "CLEAR")}),
("flood", {"icon": "bi-droplet-fill", "label": "Flood", "active": ("danger", "FLOOD"), "idle": ("success", "DRY")}),
("vibration", {"icon": "bi-activity", "label": "Vibration", "active": ("warning", "VIBRATION"), "idle": ("secondary", "IDLE")}),
])
# ── Icon picker palette (Bootstrap Icons, for custom override) ────────────────
ICON_PALETTE = [
# Lighting / power
("bi-lightbulb-fill", "Bulb"),
("bi-lamp-fill", "Lamp"),
("bi-sun-fill", "Sun"),
("bi-plug-fill", "Plug"),
("bi-toggles", "Toggle"),
("bi-power", "Power"),
# Climate
("bi-thermometer-high", "Heater"),
("bi-thermometer-low", "Cool"),
("bi-fan", "Fan"),
("bi-wind", "Wind"),
("bi-snow2", "AC"),
("bi-moisture", "Moisture"),
# Water / pumps
("bi-water", "Water"),
("bi-droplet-fill", "Drop"),
("bi-droplet-half", "Drip"),
("bi-bucket-fill", "Bucket"),
# Doors / security
("bi-door-open-fill", "Door"),
("bi-door-closed-fill", "Door (c)"),
("bi-lock-fill", "Lock"),
("bi-unlock-fill", "Unlock"),
("bi-shield-fill", "Shield"),
("bi-key-fill", "Key"),
("bi-megaphone-fill", "Siren"),
("bi-bell-fill", "Bell"),
# Cameras / access
("bi-camera-video-fill", "Camera"),
("bi-eye-fill", "Eye"),
# Motion / sensors
("bi-person-walking", "Motion"),
("bi-activity", "Sensor"),
("bi-magnet-fill", "Magnet"),
("bi-cloud-haze2", "Smoke"),
("bi-hand-index-thumb-fill", "Button"),
("bi-window-dash", "Window"),
# Outdoors / location
("bi-house-fill", "House"),
("bi-building", "Building"),
("bi-car-front-fill", "Car"),
("bi-tree-fill", "Tree"),
("bi-sign-yield-fill", "Gate"),
# Misc
("bi-gear-fill", "Gear"),
("bi-cpu-fill", "CPU"),
("bi-wifi", "WiFi"),
("bi-tv-fill", "TV"),
("bi-speaker-fill", "Speaker"),
("bi-music-note-beamed", "Music"),
("bi-controller", "Control"),
("bi-circle-fill", "Circle"),
]

19
app/models/user.py Normal file
View File

@@ -0,0 +1,19 @@
"""User model."""
from flask_login import UserMixin
from app import db
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), nullable=False, default="user") # "admin" | "user"
is_active = db.Column(db.Boolean, default=True, nullable=False)
def is_admin(self) -> bool:
return self.role == "admin"
def __repr__(self) -> str:
return f"<User {self.username} ({self.role})>"

51
app/models/workflow.py Normal file
View File

@@ -0,0 +1,51 @@
"""Workflow model.
A workflow fires an action (set relay ON/OFF/TOGGLE) on a *target* board
whenever a specific input on a *trigger* board changes state.
"""
from datetime import datetime
from app import db
class Workflow(db.Model):
__tablename__ = "workflows"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False)
is_enabled = db.Column(db.Boolean, default=True, nullable=False)
# ── trigger ──────────────────────────────────────────────────────
trigger_board_id = db.Column(
db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
trigger_input = db.Column(db.Integer, nullable=False) # 1-based
# "press" (rising edge), "release" (falling), "both"
trigger_event = db.Column(db.String(16), nullable=False, default="press")
# ── action ───────────────────────────────────────────────────────
action_board_id = db.Column(
db.Integer, db.ForeignKey("boards.id"), nullable=False
)
action_relay = db.Column(db.Integer, nullable=False) # 1-based
# "on", "off", "toggle"
action_type = db.Column(db.String(16), nullable=False, default="toggle")
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_triggered = db.Column(db.DateTime, nullable=True)
# ── relationships ────────────────────────────────────────────────
trigger_board = db.relationship(
"Board", foreign_keys=[trigger_board_id],
back_populates="workflows_trigger"
)
action_board = db.relationship(
"Board", foreign_keys=[action_board_id],
back_populates="workflows_target"
)
def __repr__(self) -> str:
return (
f"<Workflow '{self.name}' "
f"Board#{self.trigger_board_id}.in{self.trigger_input} "
f"→ Board#{self.action_board_id}.relay{self.action_relay} [{self.action_type}]>"
)

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

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

87
app/routes/admin.py Normal file
View File

@@ -0,0 +1,87 @@
"""Admin routes user management (admin only)."""
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from werkzeug.security import generate_password_hash
from app import db
from app.models.user import User
admin_bp = Blueprint("admin", __name__)
def _require_admin():
if not current_user.is_authenticated or not current_user.is_admin():
abort(403)
@admin_bp.route("/users")
@login_required
def list_users():
_require_admin()
users = User.query.order_by(User.username).all()
return render_template("admin/users.html", users=users)
@admin_bp.route("/users/add", methods=["GET", "POST"])
@login_required
def add_user():
_require_admin()
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
role = request.form.get("role", "user")
if not username or not password:
flash("Username and password are required.", "danger")
return render_template("admin/user_form.html", user=None)
if User.query.filter_by(username=username).first():
flash("Username already exists.", "danger")
return render_template("admin/user_form.html", user=None)
user = User(
username=username,
password_hash=generate_password_hash(password),
role=role,
is_active=True,
)
db.session.add(user)
db.session.commit()
flash(f"User '{username}' created.", "success")
return redirect(url_for("admin.list_users"))
return render_template("admin/user_form.html", user=None)
@admin_bp.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
@login_required
def edit_user(user_id: int):
_require_admin()
user = db.get_or_404(User, user_id)
if request.method == "POST":
user.username = request.form.get("username", user.username).strip()
user.role = request.form.get("role", user.role)
user.is_active = "is_active" in request.form
new_password = request.form.get("password", "").strip()
if new_password:
user.password_hash = generate_password_hash(new_password)
db.session.commit()
flash("User updated.", "success")
return redirect(url_for("admin.list_users"))
return render_template("admin/user_form.html", user=user)
@admin_bp.route("/users/<int:user_id>/delete", methods=["POST"])
@login_required
def delete_user(user_id: int):
_require_admin()
if user_id == current_user.id:
flash("You cannot delete your own account.", "danger")
return redirect(url_for("admin.list_users"))
user = db.get_or_404(User, user_id)
db.session.delete(user)
db.session.commit()
flash(f"User '{user.username}' deleted.", "warning")
return redirect(url_for("admin.list_users"))

81
app/routes/api.py Normal file
View File

@@ -0,0 +1,81 @@
"""REST API board webhook receiver and JSON relay control."""
from datetime import datetime
from flask import Blueprint, request, jsonify, abort
from app import db, socketio
from app.models.board import Board
from app.services import workflow_engine
api_bp = Blueprint("api", __name__)
# ── webhook endpoint (boards POST input events here) ──────────────────────────
@api_bp.route("/webhook/<int:board_id>", methods=["POST"])
def webhook(board_id: int):
board = db.session.get(Board, board_id)
if board is None:
abort(404)
data = request.get_json(silent=True) or {}
input_num = data.get("input")
state = data.get("state")
if input_num is None or state is None:
return jsonify({"error": "missing input or state"}), 400
# Update cached input state
states = board.input_states
states[f"input_{input_num}"] = bool(state)
board.input_states = states
board.is_online = True
board.last_seen = datetime.utcnow()
db.session.commit()
# Let the workflow engine decide what to do
workflow_engine.process_input_event(board_id, int(input_num), bool(state))
# Push live update to all connected clients immediately
socketio.emit("board_update", {
"board_id": board_id,
"is_online": True,
"input_states": board.input_states,
"relay_states": board.relay_states,
})
return jsonify({"status": "ok"})
# ── JSON relay status ─────────────────────────────────────────────────────────
@api_bp.route("/boards/<int:board_id>/relays")
def relay_states(board_id: int):
board = db.get_or_404(Board, board_id)
return jsonify({
"board_id": board.id,
"name": board.name,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
})
# ── JSON board list ───────────────────────────────────────────────────────────
@api_bp.route("/boards")
def board_list():
boards = Board.query.order_by(Board.name).all()
return jsonify([
{
"id": b.id,
"name": b.name,
"board_type": b.board_type,
"host": b.host,
"port": b.port,
"is_online": b.is_online,
"last_seen": b.last_seen.isoformat() if b.last_seen else None,
"relay_states": b.relay_states,
"input_states": b.input_states,
}
for b in boards
])

38
app/routes/auth.py Normal file
View File

@@ -0,0 +1,38 @@
"""Authentication routes."""
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.models.user import User
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("dashboard.index"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
remember = bool(request.form.get("remember"))
user = User.query.filter_by(username=username).first()
if user and user.is_active and check_password_hash(user.password_hash, password):
login_user(user, remember=remember)
next_page = request.args.get("next")
flash(f"Welcome back, {user.username}!", "success")
return redirect(next_page or url_for("dashboard.index"))
flash("Invalid username or password.", "danger")
return render_template("auth/login.html")
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
flash("You have been logged out.", "info")
return redirect(url_for("auth.login"))

258
app/routes/boards.py Normal file
View File

@@ -0,0 +1,258 @@
"""Board management routes."""
import json
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.board import Board
from app.services.board_service import poll_board, register_webhook, set_relay
from app.drivers.registry import registry
from flask import current_app
boards_bp = Blueprint("boards", __name__)
def _board_types():
"""Dynamic list read from the driver registry — auto-updates when drivers are added."""
return registry.choices()
# ── list ──────────────────────────────────────────────────────────────────────
@boards_bp.route("/")
@login_required
def list_boards():
boards = Board.query.order_by(Board.name).all()
return render_template("boards/list.html", boards=boards)
# ── detail / quick controls ───────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>")
@login_required
def board_detail(board_id: int):
board = db.get_or_404(Board, board_id)
# Refresh states from device
poll_board(current_app._get_current_object(), board_id)
board = db.session.get(Board, board_id)
return render_template("boards/detail.html", board=board)
# ── add ───────────────────────────────────────────────────────────────────────
@boards_bp.route("/add", methods=["GET", "POST"])
@login_required
def add_board():
if not current_user.is_admin():
abort(403)
if request.method == "POST":
name = request.form.get("name", "").strip()
board_type = request.form.get("board_type", "olimex_esp32_c6_evb")
host = request.form.get("host", "").strip()
port = int(request.form.get("port", 80))
num_relays = int(request.form.get("num_relays", 4))
num_inputs = int(request.form.get("num_inputs", 4))
if not name or not host:
flash("Name and host are required.", "danger")
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
board = Board(
name=name,
board_type=board_type,
host=host,
port=port,
num_relays=num_relays,
num_inputs=num_inputs,
)
db.session.add(board)
db.session.commit()
# Try to register webhook immediately
server_url = current_app.config.get("SERVER_BASE_URL", "http://localhost:5000")
register_webhook(board, server_url)
flash(f"Board '{name}' added successfully.", "success")
return redirect(url_for("boards.board_detail", board_id=board.id))
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
# ── edit ──────────────────────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/edit", methods=["GET", "POST"])
@login_required
def edit_board(board_id: int):
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
if request.method == "POST":
board.name = request.form.get("name", board.name).strip()
board.host = request.form.get("host", board.host).strip()
board.port = int(request.form.get("port", board.port))
board.board_type = request.form.get("board_type", board.board_type)
board.num_relays = int(request.form.get("num_relays", board.num_relays))
board.num_inputs = int(request.form.get("num_inputs", board.num_inputs))
# Update labels
labels = {}
for n in range(1, board.num_relays + 1):
lbl = request.form.get(f"relay_{n}_label", "").strip()
if lbl:
labels[f"relay_{n}"] = lbl
for n in range(1, board.num_inputs + 1):
lbl = request.form.get(f"input_{n}_label", "").strip()
if lbl:
labels[f"input_{n}"] = lbl
board.labels = labels
db.session.commit()
flash("Board updated.", "success")
return redirect(url_for("boards.board_detail", board_id=board.id))
return render_template("boards/edit.html", board=board, board_types=_board_types())
# ── delete ────────────────────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/delete", methods=["POST"])
@login_required
def delete_board(board_id: int):
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
db.session.delete(board)
db.session.commit()
flash(f"Board '{board.name}' deleted.", "warning")
return redirect(url_for("boards.list_boards"))
# ── quick relay toggle ───────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/relay/<int:relay_num>/toggle", methods=["POST"])
@login_required
def toggle_relay_view(board_id: int, relay_num: int):
from app import socketio
board = db.get_or_404(Board, board_id)
# Always flip the local cached state first (optimistic update)
states = board.relay_states
current = states.get(f"relay_{relay_num}", False)
new_state = not current
states[f"relay_{relay_num}"] = new_state
board.relay_states = states
db.session.commit()
# Best-effort: send the command to the physical board using set_relay
# (uses /relay/on or /relay/off — same endpoints as the detail page ON/OFF buttons)
hw_ok = set_relay(board, relay_num, new_state)
# Push live update to all clients
socketio.emit("board_update", {
"board_id": board_id,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
})
label = board.get_relay_label(relay_num)
status_text = "ON" if new_state else "OFF"
hw_warning = not hw_ok # True when board was unreachable
# JSON response for AJAX callers (dashboard)
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
return jsonify({
"relay": relay_num,
"state": new_state,
"label": label,
"hw_ok": not hw_warning,
})
# HTML response for form-submit callers (detail page)
if hw_warning:
flash(f"{label}: {status_text} (board unreachable — local state updated)", "warning")
else:
flash(f"{label}: {status_text}", "info")
return redirect(url_for("boards.board_detail", board_id=board_id))
# ── quick relay set ───────────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/relay/<int:relay_num>/set", methods=["POST"])
@login_required
def set_relay_view(board_id: int, relay_num: int):
from app import socketio
board = db.get_or_404(Board, board_id)
state = request.form.get("state", "off") == "on"
# Always update local state
states = board.relay_states
states[f"relay_{relay_num}"] = state
board.relay_states = states
db.session.commit()
# Best-effort send to hardware
hw_ok = set_relay(board, relay_num, state)
socketio.emit("board_update", {
"board_id": board_id,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
})
label = board.get_relay_label(relay_num)
status_text = "ON" if state else "OFF"
if not hw_ok:
flash(f"{label}: {status_text} (board unreachable — local state updated)", "warning")
else:
flash(f"{label}: {status_text}", "info")
return redirect(url_for("boards.board_detail", board_id=board_id))
# ── edit entity configuration ─────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/entities", methods=["GET", "POST"])
@login_required
def edit_entities(board_id: int):
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
if request.method == "POST":
entities = {}
for n in range(1, board.num_relays + 1):
entities[f"relay_{n}"] = {
"type": request.form.get(f"relay_{n}_type", "switch"),
"name": request.form.get(f"relay_{n}_name", "").strip()[:20],
"icon": request.form.get(f"relay_{n}_icon", "").strip(),
}
for n in range(1, board.num_inputs + 1):
entities[f"input_{n}"] = {
"type": request.form.get(f"input_{n}_type", "generic"),
"name": request.form.get(f"input_{n}_name", "").strip()[:20],
"icon": request.form.get(f"input_{n}_icon", "").strip(),
}
board.entities = entities
db.session.commit()
flash("Entity configuration saved.", "success")
return redirect(url_for("boards.board_detail", board_id=board_id))
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES, ICON_PALETTE
return render_template(
"boards/edit_entities.html",
board=board,
relay_types=RELAY_ENTITY_TYPES,
input_types=INPUT_ENTITY_TYPES,
icon_palette=ICON_PALETTE,
)
# ── edit labels (legacy — redirects to edit_entities) ─────────────────────────
@boards_bp.route("/<int:board_id>/labels", methods=["GET", "POST"])
@login_required
def edit_labels(board_id: int):
return redirect(url_for("boards.edit_entities", board_id=board_id))

20
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,20 @@
"""Dashboard route."""
from flask import Blueprint, render_template
from flask_login import login_required
from app.models.board import Board
from app.models.workflow import Workflow
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.route("/")
@login_required
def index():
boards = Board.query.order_by(Board.name).all()
workflows = Workflow.query.filter_by(is_enabled=True).count()
return render_template(
"dashboard/index.html",
boards=boards,
active_workflows=workflows,
)

98
app/routes/workflows.py Normal file
View File

@@ -0,0 +1,98 @@
"""Workflow management routes."""
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from app import db
from app.models.board import Board
from app.models.workflow import Workflow
workflows_bp = Blueprint("workflows", __name__)
EVENTS = [("press", "Press (rising edge)"), ("release", "Release (falling)"), ("both", "Both")]
ACTIONS = [("on", "Turn ON"), ("off", "Turn OFF"), ("toggle", "Toggle")]
@workflows_bp.route("/")
@login_required
def list_workflows():
workflows = Workflow.query.order_by(Workflow.name).all()
return render_template("workflows/list.html", workflows=workflows)
@workflows_bp.route("/add", methods=["GET", "POST"])
@login_required
def add_workflow():
if not current_user.is_admin():
abort(403)
boards = Board.query.order_by(Board.name).all()
if request.method == "POST":
wf = Workflow(
name=request.form.get("name", "").strip(),
trigger_board_id=int(request.form["trigger_board_id"]),
trigger_input=int(request.form["trigger_input"]),
trigger_event=request.form.get("trigger_event", "press"),
action_board_id=int(request.form["action_board_id"]),
action_relay=int(request.form["action_relay"]),
action_type=request.form.get("action_type", "toggle"),
is_enabled=True,
)
if not wf.name:
flash("Name is required.", "danger")
return render_template("workflows/edit.html", wf=None, boards=boards,
events=EVENTS, actions=ACTIONS)
db.session.add(wf)
db.session.commit()
flash(f"Workflow '{wf.name}' created.", "success")
return redirect(url_for("workflows.list_workflows"))
return render_template("workflows/edit.html", wf=None, boards=boards,
events=EVENTS, actions=ACTIONS)
@workflows_bp.route("/<int:wf_id>/edit", methods=["GET", "POST"])
@login_required
def edit_workflow(wf_id: int):
if not current_user.is_admin():
abort(403)
wf = db.get_or_404(Workflow, wf_id)
boards = Board.query.order_by(Board.name).all()
if request.method == "POST":
wf.name = request.form.get("name", wf.name).strip()
wf.trigger_board_id = int(request.form.get("trigger_board_id", wf.trigger_board_id))
wf.trigger_input = int(request.form.get("trigger_input", wf.trigger_input))
wf.trigger_event = request.form.get("trigger_event", wf.trigger_event)
wf.action_board_id = int(request.form.get("action_board_id", wf.action_board_id))
wf.action_relay = int(request.form.get("action_relay", wf.action_relay))
wf.action_type = request.form.get("action_type", wf.action_type)
wf.is_enabled = "is_enabled" in request.form
db.session.commit()
flash("Workflow updated.", "success")
return redirect(url_for("workflows.list_workflows"))
return render_template("workflows/edit.html", wf=wf, boards=boards,
events=EVENTS, actions=ACTIONS)
@workflows_bp.route("/<int:wf_id>/delete", methods=["POST"])
@login_required
def delete_workflow(wf_id: int):
if not current_user.is_admin():
abort(403)
wf = db.get_or_404(Workflow, wf_id)
db.session.delete(wf)
db.session.commit()
flash(f"Workflow '{wf.name}' deleted.", "warning")
return redirect(url_for("workflows.list_workflows"))
@workflows_bp.route("/<int:wf_id>/toggle", methods=["POST"])
@login_required
def toggle_workflow(wf_id: int):
wf = db.get_or_404(Workflow, wf_id)
wf.is_enabled = not wf.is_enabled
db.session.commit()
state = "enabled" if wf.is_enabled else "disabled"
flash(f"Workflow '{wf.name}' {state}.", "info")
return redirect(url_for("workflows.list_workflows"))

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)

84
app/static/css/style.css Normal file
View File

@@ -0,0 +1,84 @@
/* ── Layout ─────────────────────────────────────────────────────────────── */
#wrapper {
min-height: 100vh;
}
#sidebar {
width: 240px;
min-height: 100vh;
position: sticky;
top: 0;
max-height: 100vh;
overflow-y: auto;
}
#page-content {
min-height: 100vh;
background: var(--bs-body-bg);
}
/* ── Sidebar nav pills ──────────────────────────────────────────────────── */
#sidebar .nav-link {
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
transition: background 0.15s;
}
#sidebar .nav-link:hover:not(.active) {
background: rgba(255, 255, 255, 0.08);
}
#sidebar .nav-link.active {
background: var(--bs-primary);
color: #fff !important;
}
/* ── Cards ──────────────────────────────────────────────────────────────── */
.card {
background: var(--bs-dark);
}
.stat-card {
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.board-card {
transition: box-shadow 0.2s;
}
.board-card:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* ── Relay buttons ──────────────────────────────────────────────────────── */
.relay-btn {
min-width: 90px;
font-size: 0.8rem;
}
/* ── Login page ─────────────────────────────────────────────────────────── */
body:not(:has(#wrapper)) {
background: #0d1117;
}
/* ── Misc ───────────────────────────────────────────────────────────────── */
.font-monospace {
font-size: 0.85em;
}
.input-badge {
font-size: 0.78rem;
cursor: default;
}
@media (max-width: 768px) {
#sidebar {
width: 100%;
min-height: auto;
position: relative;
}
#wrapper {
flex-direction: column;
}
}

22
app/static/js/app.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* app.js Global Socket.IO connection & UI helpers
*/
document.addEventListener("DOMContentLoaded", function () {
// ── Bootstrap alert auto-dismiss after 4 s ──────────────────────────────
document.querySelectorAll(".alert").forEach(function (el) {
setTimeout(function () {
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
if (bsAlert) bsAlert.close();
}, 4000);
});
// ── Confirm-before-submit for data-confirm forms ─────────────────────────
document.querySelectorAll("form[data-confirm]").forEach(function (form) {
form.addEventListener("submit", function (e) {
if (!confirm(form.getAttribute("data-confirm"))) {
e.preventDefault();
}
});
});
});

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
app/static/vendor/socket.io/socket.io.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}{% if user %}Edit User{% else %}Add User{% endif %} Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('admin.list_users') }}">Users</a></li>
<li class="breadcrumb-item active">{% if user %}Edit: {{ user.username }}{% else %}Add User{% endif %}</li>
</ol>
</nav>
<div class="card border-0 rounded-4" style="max-width:500px">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-person me-1"></i> {% if user %}Edit User{% else %}New User{% endif %}
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" value="{{ user.username if user else '' }}" required />
</div>
<div class="mb-3">
<label class="form-label">Password {% if user %}<span class="text-secondary small">(leave blank to keep)</span>{% endif %}</label>
<input type="password" name="password" class="form-control"
{% if not user %}required{% endif %}
placeholder="{% if user %}New password{% else %}Password{% endif %}" />
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" class="form-select">
<option value="user" {% if user and user.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Admin</option>
</select>
</div>
{% if user %}
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" name="is_active" id="is_active"
{% if user.is_active %}checked{% endif %} />
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Users 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-people me-2 text-primary"></i>Users</h2>
<a href="{{ url_for('admin.add_user') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add User
</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr><th>Username</th><th>Role</th><th>Active</th><th></th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="fw-semibold">
{{ u.username }}
{% if u.id == current_user.id %}
<span class="badge text-bg-info ms-1">you</span>
{% endif %}
</td>
<td><span class="badge {% if u.role == 'admin' %}text-bg-primary{% else %}text-bg-secondary{% endif %}">{{ u.role }}</span></td>
<td>
{% if u.is_active %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-danger"></i>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.edit_user', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary me-1">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != current_user.id %}
<form method="POST" action="{{ url_for('admin.delete_user', user_id=u.id) }}" class="d-inline"
onsubmit="return confirm('Delete user {{ u.username }}?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base_guest.html" %}
{% block title %}Sign In Location Management{% endblock %}
{% block content %}
<div class="card shadow-lg border-0 rounded-4">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-cpu-fill display-4 text-primary"></i>
<h2 class="mt-2 fw-bold">Location Management</h2>
<p class="text-secondary">Sign in to continue</p>
</div>
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username"
placeholder="admin" autocomplete="username" required autofocus />
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" class="form-control" id="password" name="password"
placeholder="••••••••" autocomplete="current-password" required />
</div>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="remember" name="remember" />
<label class="form-check-label" for="remember">Remember me</label>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold">
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
</button>
</form>
</div>
</div>
{% endblock %}

87
app/templates/base.html Normal file
View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Location Management{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.min.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
</head>
<body>
<!-- ── Sidebar navigation ───────────────────────────────────────────────── -->
<div class="d-flex" id="wrapper">
<nav id="sidebar" class="d-flex flex-column flex-shrink-0 p-3 bg-dark">
<a href="{{ url_for('dashboard.index') }}" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<i class="bi bi-cpu-fill me-2 fs-4 text-primary"></i>
<span class="fs-5 fw-semibold">Loc. Mgmt</span>
</a>
<hr class="text-secondary" />
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item">
<a href="{{ url_for('dashboard.index') }}"
class="nav-link text-white {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
<i class="bi bi-grid-1x2-fill me-2"></i>Dashboard
</a>
</li>
<li>
<a href="{{ url_for('boards.list_boards') }}"
class="nav-link text-white {% if 'boards.' in request.endpoint %}active{% endif %}">
<i class="bi bi-motherboard me-2"></i>Boards
</a>
</li>
<li>
<a href="{{ url_for('workflows.list_workflows') }}"
class="nav-link text-white {% if 'workflows.' in request.endpoint %}active{% endif %}">
<i class="bi bi-diagram-3 me-2"></i>Workflows
</a>
</li>
{% if current_user.is_admin() %}
<li>
<a href="{{ url_for('admin.list_users') }}"
class="nav-link text-white {% if 'admin.' in request.endpoint %}active{% endif %}">
<i class="bi bi-people me-2"></i>Users
</a>
</li>
{% endif %}
</ul>
<hr class="text-secondary" />
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-2 fs-5"></i>
<strong>{{ current_user.username }}</strong>
{% if current_user.is_admin() %}
<span class="badge bg-primary ms-2">admin</span>
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-dark text-small shadow">
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i> Sign out
</a></li>
</ul>
</div>
</nav>
<!-- ── Main content ─────────────────────────────────────────────────────── -->
<div id="page-content" class="flex-grow-1 p-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Location Management{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.min.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
</head>
<body class="guest-layout">
<div class="d-flex justify-content-center align-items-center min-vh-100">
<div style="width:100%;max-width:420px;padding:1rem">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Add Board Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item active">Add Board</li>
</ol>
</nav>
<div class="card border-0 rounded-4" style="max-width:640px">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-plus-circle me-1 text-primary"></i> Add New Board
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label">Local Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. Server Room Board" required />
</div>
<div class="mb-3">
<label class="form-label">Board Type</label>
<select name="board_type" class="form-select" id="board_type_select" onchange="updateDefaults(this.value)">
{% for value, label in board_types %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<div class="form-text text-secondary" id="driver_desc"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label">IP Address / Hostname</label>
<input type="text" name="host" class="form-control font-monospace" placeholder="192.168.1.100" required />
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label">Number of Relays</label>
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="1" max="32" />
</div>
<div class="col-6">
<label class="form-label">Number of Inputs</label>
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const driverDefaults = {
{% for d in drivers %}
"{{ d.DRIVER_ID }}": { relays: {{ d.DEFAULT_NUM_RELAYS }}, inputs: {{ d.DEFAULT_NUM_INPUTS }}, desc: "{{ d.DESCRIPTION }}" },
{% endfor %}
};
function updateDefaults(type) {
const d = driverDefaults[type] || { relays: 4, inputs: 4, desc: "" };
document.getElementById("num_relays").value = d.relays;
document.getElementById("num_inputs").value = d.inputs;
document.getElementById("driver_desc").textContent = d.desc;
}
// init on page load
updateDefaults(document.getElementById("board_type_select").value);
</script>
{% endblock %}

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item active">{{ board.name }}</li>
</ol>
</nav>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-0">{{ board.name }}</h2>
<span class="badge text-bg-secondary">{{ board.board_type }}</span>
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} ms-1">
{% if board.is_online %}Online{% else %}Offline{% endif %}
</span>
<span class="text-secondary small ms-2 font-monospace">{{ board.host }}:{{ board.port }}</span>
</div>
{% if current_user.is_admin() %}
<div class="d-flex gap-2">
<a href="{{ url_for('boards.edit_entities', board_id=board.id) }}" class="btn btn-outline-info">
<i class="bi bi-palette me-1"></i> Configure Entities
</a>
<a href="{{ url_for('boards.edit_board', board_id=board.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil me-1"></i> Edit
</a>
</div>
{% endif %}
</div>
<div class="row g-4">
<!-- ── Relay controls ────────────────────────────────────────────────────── -->
<div class="col-lg-6">
<div class="card border-0 rounded-4 h-100">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-lightning-charge me-1 text-warning"></i> Relay Controls
</div>
<div class="card-body">
<div class="row g-3">
{% 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) %}
<div class="col-6">
<div id="relay-card-{{ n }}" class="card rounded-3 border-{{ e.on_color if is_on else e.off_color }} h-100">
<div class="card-body text-center py-3">
<i id="relay-icon-{{ n }}" class="bi {{ e.icon }} mb-2 text-{{ e.on_color if is_on else e.off_color }}"
style="font-size:2rem"></i>
<div class="fs-6 fw-semibold mb-1">{{ e.name }}</div>
<div class="mb-3">
<span id="relay-badge-{{ n }}" class="badge text-bg-{{ e.on_color if is_on else e.off_color }}">
{{ e.on_label if is_on else e.off_label }}
</span>
</div>
<div class="d-flex justify-content-center gap-2">
<form method="POST" action="{{ url_for('boards.set_relay_view', board_id=board.id, relay_num=n) }}">
<button id="relay-on-btn-{{ n }}" class="btn btn-sm btn-{{ e.on_color }}" {% if is_on %}disabled{% endif %}>{{ e.on_label }}</button>
<input type="hidden" name="state" value="on" />
</form>
<form method="POST" action="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}">
<button class="btn btn-sm btn-outline-primary">Toggle</button>
</form>
<form method="POST" action="{{ url_for('boards.set_relay_view', board_id=board.id, relay_num=n) }}">
<button id="relay-off-btn-{{ n }}" class="btn btn-sm btn-{{ e.off_color }}" {% if not is_on %}disabled{% endif %}>{{ e.off_label }}</button>
<input type="hidden" name="state" value="off" />
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- ── Input states ──────────────────────────────────────────────────────── -->
<div class="col-lg-6">
<div class="card border-0 rounded-4 h-100">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-activity me-1 text-info"></i> Input States
</div>
<div class="card-body">
<div class="row g-3">
{% 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) %}
<div class="col-6">
<div id="input-card-{{ n }}" class="card rounded-3 border-{{ e.active_color if is_active else e.idle_color }}">
<div class="card-body text-center py-3">
<i id="input-icon-{{ n }}" class="bi {{ e.icon }} mb-2 text-{{ e.active_color if is_active else e.idle_color }}"
style="font-size:2rem"></i>
<div class="fs-6 fw-semibold mb-1">{{ e.name }}</div>
<span id="input-badge-{{ n }}" class="badge text-bg-{{ e.active_color if is_active else e.idle_color }}">
{{ e.active_label if is_active else e.idle_label }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- ── Board info ────────────────────────────────────────────────────────── -->
<div class="col-12">
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-info-circle me-1"></i> Board Information
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Board ID</dt><dd class="col-sm-9 font-monospace">{{ board.id }}</dd>
<dt class="col-sm-3">Type</dt><dd class="col-sm-9">{{ board.board_type }}</dd>
<dt class="col-sm-3">Host</dt><dd class="col-sm-9 font-monospace">{{ board.host }}:{{ board.port }}</dd>
<dt class="col-sm-3">Firmware</dt><dd class="col-sm-9">{{ board.firmware_version or '—' }}</dd>
<dt class="col-sm-3">Added</dt><dd class="col-sm-9">{{ board.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
<dt class="col-sm-3">Last Seen</dt>
<dd class="col-sm-9">{% if board.last_seen %}{{ board.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}Never{% endif %}</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const socket = io();
const boardId = {{ board.id }};
// ── Entity config embedded from server ──────────────────────────────────────
const ENTITY_CONFIG = {
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 }}",onLabel:"{{ e.on_label }}",offLabel:"{{ e.off_label }}"},
{% 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 }}",activeLabel:"{{ e.active_label }}",idleLabel:"{{ e.idle_label }}"},
{% endfor %}
}
};
function swapBorderColor(el, color) {
el.classList.forEach(c => {
if (c.startsWith('border-') && c !== 'border-0' && c !== 'border-3') el.classList.remove(c);
});
el.classList.add('border-' + color);
}
function applyRelayState(n, isOn) {
const e = ENTITY_CONFIG.relays[n]; if (!e) return;
const color = isOn ? e.onColor : e.offColor;
const label = isOn ? e.onLabel : e.offLabel;
const card = document.getElementById('relay-card-' + n);
const icon = document.getElementById('relay-icon-' + n);
const badge = document.getElementById('relay-badge-' + n);
const onBtn = document.getElementById('relay-on-btn-' + n);
const offBtn= document.getElementById('relay-off-btn-' + n);
if (card) swapBorderColor(card, color);
if (icon) { icon.className = `bi ${e.icon} mb-2 text-${color}`; icon.style.fontSize = '2rem'; }
if (badge) { badge.className = 'badge text-bg-' + color; badge.textContent = label; }
if (onBtn) onBtn.disabled = isOn;
if (offBtn) offBtn.disabled = !isOn;
}
function applyInputState(n, rawState) {
const e = ENTITY_CONFIG.inputs[n]; if (!e) return;
const isActive = !rawState; // NC contact: raw true = resting = idle
const color = isActive ? e.activeColor : e.idleColor;
const label = isActive ? e.activeLabel : e.idleLabel;
const card = document.getElementById('input-card-' + n);
const icon = document.getElementById('input-icon-' + n);
const badge = document.getElementById('input-badge-' + n);
if (card) swapBorderColor(card, color);
if (icon) { icon.className = `bi ${e.icon} mb-2 text-${color}`; icon.style.fontSize = '2rem'; }
if (badge) { badge.className = 'badge text-bg-' + color; badge.textContent = label; }
}
socket.on("board_update", function(data) {
if (data.board_id !== boardId) return;
if (data.relay_states) {
for (const [key, isOn] of Object.entries(data.relay_states)) {
applyRelayState(parseInt(key.split('_')[1]), isOn);
}
}
if (data.input_states) {
for (const [key, rawState] of Object.entries(data.input_states)) {
applyInputState(parseInt(key.split('_')[1]), rawState);
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Edit {{ board.name }} Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
<li class="breadcrumb-item active">Edit</li>
</ol>
</nav>
<div class="card border-0 rounded-4" style="max-width:700px">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-pencil me-1"></i> Edit Board — {{ board.name }}
</div>
<div class="card-body">
<form method="POST">
<!-- Connection -->
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Connection</h6>
<div class="mb-3">
<label class="form-label">Local Name</label>
<input type="text" name="name" class="form-control" value="{{ board.name }}" required />
</div>
<div class="mb-3">
<label class="form-label">Board Type</label>
<select name="board_type" class="form-select">
{% for value, label in board_types %}
<option value="{{ value }}" {% if board.board_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="row g-3 mb-4">
<div class="col-8">
<label class="form-label">IP / Hostname</label>
<input type="text" name="host" class="form-control font-monospace" value="{{ board.host }}" required />
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="{{ board.port }}" />
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label">Number of Relays</label>
<input type="number" name="num_relays" class="form-control" value="{{ board.num_relays }}" min="1" max="32" />
</div>
<div class="col-6">
<label class="form-label">Number of Inputs</label>
<input type="number" name="num_inputs" class="form-control" value="{{ board.num_inputs }}" min="0" max="32" />
</div>
</div>
<!-- Labels -->
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Relay Labels</h6>
<div class="row g-2 mb-4">
{% for n in range(1, board.num_relays + 1) %}
<div class="col-6">
<label class="form-label small">Relay {{ n }}</label>
<input type="text" name="relay_{{ n }}_label" class="form-control form-control-sm"
placeholder="Relay {{ n }}"
value="{{ board.labels.get('relay_' ~ n, '') }}" />
</div>
{% endfor %}
</div>
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Input Labels</h6>
<div class="row g-2 mb-4">
{% for n in range(1, board.num_inputs + 1) %}
<div class="col-6">
<label class="form-label small">Input {{ n }}</label>
<input type="text" name="input_{{ n }}_label" class="form-control form-control-sm"
placeholder="Input {{ n }}"
value="{{ board.labels.get('input_' ~ n, '') }}" />
</div>
{% endfor %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,318 @@
{% extends "base.html" %}
{% block title %}Configure Entities {{ board.name }}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
<li class="breadcrumb-item active">Configure Entities</li>
</ol>
</nav>
<h2 class="fw-bold mb-1">Configure Entities</h2>
<p class="text-secondary mb-4">
Set a type, custom name, and optional icon override for each relay and input.
The type determines the icon, status colors, and state labels shown everywhere.
</p>
<form method="POST" action="{{ url_for('boards.edit_entities', board_id=board.id) }}">
<!-- ── Relay entities ────────────────────────────────────────────────────── -->
<h5 class="fw-semibold mb-3"><i class="bi bi-lightning-charge text-warning me-1"></i> Relay Outputs</h5>
<div class="row g-3 mb-4">
{% for n in range(1, board.num_relays + 1) %}
{% set e = board.get_relay_entity(n) %}
<div class="col-md-6">
<div class="card border-0 rounded-4 entity-card" data-entity="relay_{{ n }}" data-kind="relay">
<div class="card-body">
<!-- Header: icon preview + number label -->
<div class="d-flex align-items-center gap-3 mb-3">
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-relay-{{ n }}"></i>
</div>
<div class="flex-grow-1">
<div class="text-secondary small mb-1">Relay {{ n }}</div>
<input type="text" class="form-control form-control-sm"
name="relay_{{ n }}_name"
value="{{ board.entities.get('relay_' ~ n, {}).get('name', '') }}"
maxlength="20" placeholder="{{ e.name }}" />
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
</div>
</div>
<!-- Type selector -->
<div class="mb-3">
<div class="small text-secondary mb-2">Entity type</div>
<div class="d-flex flex-wrap gap-2">
{% for tkey, tdef in relay_types.items() %}
<label class="entity-type-pill" title="{{ tdef.label }}"
data-icon="{{ tdef.icon }}"
data-target="relay_{{ n }}">
<input type="radio" name="relay_{{ n }}_type" value="{{ tkey }}" hidden
{% if e.type == tkey %}checked{% endif %} />
<i class="bi {{ tdef.icon }}"></i>
<span>{{ tdef.label }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- Custom icon override -->
<details class="mt-2">
<summary class="text-secondary small" style="cursor:pointer">
<i class="bi bi-palette me-1"></i> Custom icon override
</summary>
<div class="mt-2">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text" class="form-control icon-text-input"
name="relay_{{ n }}_icon"
value="{{ board.entities.get('relay_' ~ n, {}).get('icon', '') }}"
placeholder="e.g. bi-lightbulb-fill"
data-target="relay_{{ n }}" />
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
data-target="relay_{{ n }}">
<i class="bi bi-x"></i>
</button>
</div>
<div class="icon-palette d-flex flex-wrap gap-1">
{% for icon_cls, icon_name in icon_palette %}
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="relay_{{ n }}">
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
</button>
{% endfor %}
</div>
</div>
</details>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- ── Input entities ────────────────────────────────────────────────────── -->
<h5 class="fw-semibold mb-3"><i class="bi bi-activity text-info me-1"></i> Digital Inputs</h5>
<div class="row g-3 mb-4">
{% for n in range(1, board.num_inputs + 1) %}
{% set e = board.get_input_entity(n) %}
<div class="col-md-6">
<div class="card border-0 rounded-4 entity-card" data-entity="input_{{ n }}" data-kind="input">
<div class="card-body">
<!-- Header: icon preview + number label -->
<div class="d-flex align-items-center gap-3 mb-3">
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-input-{{ n }}"></i>
</div>
<div class="flex-grow-1">
<div class="text-secondary small mb-1">Input {{ n }}</div>
<input type="text" class="form-control form-control-sm"
name="input_{{ n }}_name"
value="{{ board.entities.get('input_' ~ n, {}).get('name', '') }}"
maxlength="20" placeholder="{{ e.name }}" />
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
</div>
</div>
<!-- Type selector -->
<div class="mb-3">
<div class="small text-secondary mb-2">Entity type</div>
<div class="d-flex flex-wrap gap-2">
{% for tkey, tdef in input_types.items() %}
<label class="entity-type-pill" title="{{ tdef.label }}"
data-icon="{{ tdef.icon }}"
data-target="input_{{ n }}">
<input type="radio" name="input_{{ n }}_type" value="{{ tkey }}" hidden
{% if e.type == tkey %}checked{% endif %} />
<i class="bi {{ tdef.icon }}"></i>
<span>{{ tdef.label }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- Custom icon override -->
<details class="mt-2">
<summary class="text-secondary small" style="cursor:pointer">
<i class="bi bi-palette me-1"></i> Custom icon override
</summary>
<div class="mt-2">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text" class="form-control icon-text-input"
name="input_{{ n }}_icon"
value="{{ board.entities.get('input_' ~ n, {}).get('icon', '') }}"
placeholder="e.g. bi-door-open-fill"
data-target="input_{{ n }}" />
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
data-target="input_{{ n }}">
<i class="bi bi-x"></i>
</button>
</div>
<div class="icon-palette d-flex flex-wrap gap-1">
{% for icon_cls, icon_name in icon_palette %}
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="input_{{ n }}">
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
</button>
{% endfor %}
</div>
</div>
</details>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- ── Actions ────────────────────────────────────────────────────────────── -->
<div class="d-flex gap-2 mt-2 mb-5">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i> Save Configuration
</button>
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<style>
/* ── Entity type pills ───────────────────────────────────────────── */
.entity-type-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
border: 1px solid var(--bs-border-color);
cursor: pointer;
font-size: .8rem;
transition: background .15s, border-color .15s, color .15s;
user-select: none;
}
.entity-type-pill:hover {
border-color: var(--bs-primary);
color: var(--bs-primary);
}
.entity-type-pill:has(input:checked) {
border-color: var(--bs-primary);
background: var(--bs-primary-bg-subtle, rgba(13,110,253,.15));
color: var(--bs-primary);
font-weight: 600;
}
/* ── Icon palette grid ───────────────────────────────────────────── */
.icon-palette {
max-height: 160px;
overflow-y: auto;
padding: 4px;
background: var(--bs-secondary-bg);
border-radius: .5rem;
}
.icon-pick-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-pick-btn.active {
background: var(--bs-primary);
border-color: var(--bs-primary);
color: #fff;
}
</style>
<script>
/* ── Live icon preview ───────────────────────────────────────────────────── */
function resolvePreview(target) {
const iconInput = document.querySelector(`.icon-text-input[data-target="${target}"]`);
const customIcon = iconInput ? iconInput.value.trim() : "";
const preview = document.getElementById(`icon-preview-${target.replace("_", "-")}`);
if (!preview) return;
if (customIcon) {
// Remove all bi-* classes, add the custom one
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
const cls = customIcon.startsWith("bi-") ? customIcon : `bi-${customIcon}`;
preview.classList.add(cls);
} else {
// Use the checked type's icon
const checkedType = document.querySelector(`input[name="${target}_type"]:checked`);
if (checkedType) {
const pill = checkedType.closest(".entity-type-pill");
const typeIcon = pill ? pill.getAttribute("data-icon") : null;
if (typeIcon) {
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
preview.classList.add(typeIcon);
}
}
}
}
/* ── Type pill selection ─────────────────────────────────────────────────── */
document.querySelectorAll(".entity-type-pill").forEach(pill => {
pill.addEventListener("click", () => {
const target = pill.getAttribute("data-target");
// Small delay so the radio value has flipped
requestAnimationFrame(() => resolvePreview(target));
});
});
/* ── Custom icon text input ──────────────────────────────────────────────── */
document.querySelectorAll(".icon-text-input").forEach(inp => {
inp.addEventListener("input", () => resolvePreview(inp.getAttribute("data-target")));
});
/* ── Icon palette pick ───────────────────────────────────────────────────── */
document.querySelectorAll(".icon-pick-btn").forEach(btn => {
btn.addEventListener("click", () => {
const icon = btn.getAttribute("data-icon");
const target = btn.getAttribute("data-target");
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
if (inp) inp.value = icon;
// Highlight selected palette button
btn.closest(".icon-palette").querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
resolvePreview(target);
});
});
/* ── Clear custom icon ───────────────────────────────────────────────────── */
document.querySelectorAll(".icon-clear-btn").forEach(btn => {
btn.addEventListener("click", () => {
const target = btn.getAttribute("data-target");
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
if (inp) inp.value = "";
// Deselect all palette buttons for this target
const card = btn.closest(".card-body");
if (card) card.querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
resolvePreview(target);
});
});
/* ── Init: highlight pre-selected palette icons, run initial previews ────── */
document.querySelectorAll(".icon-text-input").forEach(inp => {
const val = inp.value.trim();
const target = inp.getAttribute("data-target");
if (val) {
const match = inp.closest(".card-body").querySelector(`.icon-pick-btn[data-icon="${val}"]`);
if (match) match.classList.add("active");
}
resolvePreview(target);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Edit Labels {{ board.name }}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
<li class="breadcrumb-item active">Edit Labels</li>
</ol>
</nav>
<h2 class="fw-bold mb-1">Edit Labels</h2>
<p class="text-secondary mb-4">Assign custom names to each relay and input (max 20 characters). Leave a field blank to use the default name.</p>
<form method="POST" action="{{ url_for('boards.edit_labels', board_id=board.id) }}">
<div class="row g-4">
<!-- ── Relay labels ───────────────────────────────────────────────────── -->
<div class="col-md-6">
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-lightning-charge me-1 text-warning"></i> Relay Names
</div>
<div class="card-body">
{% for n in range(1, board.num_relays + 1) %}
<div class="mb-3">
<label class="form-label text-secondary small mb-1">Relay {{ n }}</label>
<input type="text"
class="form-control"
name="relay_{{ n }}_label"
value="{{ board.labels.get('relay_' ~ n, '') }}"
maxlength="20"
placeholder="Relay {{ n }}" />
</div>
{% endfor %}
</div>
</div>
</div>
<!-- ── Input labels ───────────────────────────────────────────────────── -->
<div class="col-md-6">
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-activity me-1 text-info"></i> Input Names
</div>
<div class="card-body">
{% for n in range(1, board.num_inputs + 1) %}
<div class="mb-3">
<label class="form-label text-secondary small mb-1">Input {{ n }}</label>
<input type="text"
class="form-control"
name="input_{{ n }}_label"
value="{{ board.labels.get('input_' ~ n, '') }}"
maxlength="20"
placeholder="Input {{ n }}" />
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- ── Actions ────────────────────────────────────────────────────────── -->
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i> Save Labels
</button>
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Boards 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-motherboard me-2 text-primary"></i>Boards</h2>
{% 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 Board
</a>
{% endif %}
</div>
{% if boards %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Name</th><th>Type</th><th>Host</th><th>Relays</th><th>Inputs</th><th>Status</th><th>Last Seen</th><th></th>
</tr>
</thead>
<tbody>
{% for b in boards %}
<tr>
<td><a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a></td>
<td><span class="badge text-bg-secondary">{{ b.board_type }}</span></td>
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
<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 %}">
{% if b.is_online %}Online{% else %}Offline{% endif %}
</span>
</td>
<td class="small text-secondary">
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" 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>
<form method="POST" action="{{ url_for('boards.delete_board', board_id=b.id) }}" class="d-inline"
onsubmit="return confirm('Delete {{ b.name }}?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-motherboard display-2"></i>
<p class="mt-3">No boards yet.</p>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">Add Board</a>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}Dashboard 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-grid-1x2-fill me-2 text-primary"></i>Dashboard</h2>
{% 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 Board
</a>
{% endif %}
</div>
<!-- ── Summary cards ─────────────────────────────────────────────────────── -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card stat-card border-0 rounded-4 bg-primary bg-opacity-10">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-motherboard display-5 text-primary"></i>
<div>
<div class="display-6 fw-bold">{{ boards | length }}</div>
<div class="text-secondary small">Total Boards</div>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card stat-card border-0 rounded-4 bg-success bg-opacity-10">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-wifi display-5 text-success"></i>
<div>
<div class="display-6 fw-bold">{{ boards | selectattr('is_online') | list | length }}</div>
<div class="text-secondary small">Online</div>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card stat-card border-0 rounded-4 bg-warning bg-opacity-10">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-diagram-3 display-5 text-warning"></i>
<div>
<div class="display-6 fw-bold">{{ active_workflows }}</div>
<div class="text-secondary small">Active Workflows</div>
</div>
</div>
</div>
</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,167 @@
{% extends "base.html" %}
{% block title %}{% if wf %}Edit{% else %}Add{% endif %} Workflow Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('workflows.list_workflows') }}">Workflows</a></li>
<li class="breadcrumb-item active">{% if wf %}Edit: {{ wf.name }}{% else %}New Workflow{% endif %}</li>
</ol>
</nav>
<div class="card border-0 rounded-4" style="max-width:700px">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-diagram-3 me-1 text-warning"></i>
{% if wf %}Edit Workflow{% else %}New Workflow{% endif %}
</div>
<div class="card-body">
<form method="POST">
<div class="mb-4">
<label class="form-label fw-semibold">Workflow Name</label>
<input type="text" name="name" class="form-control" value="{{ wf.name if wf else '' }}"
placeholder="e.g. Door bell → Living room light" required />
</div>
<!-- Trigger -->
<div class="card rounded-3 border-info mb-4">
<div class="card-header bg-info bg-opacity-10 fw-semibold">
<i class="bi bi-arrow-right-circle me-1 text-info"></i> Trigger (Input Event)
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Board</label>
<select name="trigger_board_id" class="form-select" id="trigger_board" onchange="updateInputs(this, 'trigger_input')">
{% for b in boards %}
<option value="{{ b.id }}"
data-inputs="{{ b.num_inputs }}"
{% if wf and wf.trigger_board_id == b.id %}selected{% endif %}>
{{ b.name }} ({{ b.host }})
</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label class="form-label">Input Number</label>
<select name="trigger_input" class="form-select" id="trigger_input">
{% if wf %}
{% for n in range(1, wf.trigger_board.num_inputs + 1) %}
<option value="{{ n }}" {% if wf.trigger_input == n %}selected{% endif %}>
Input {{ n }}{% if wf.trigger_board.get_input_label(n) != 'Input ' ~ n %} — {{ wf.trigger_board.get_input_label(n) }}{% endif %}
</option>
{% endfor %}
{% elif boards %}
{% for n in range(1, boards[0].num_inputs + 1) %}
<option value="{{ n }}">Input {{ n }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="col-6">
<label class="form-label">Event</label>
<select name="trigger_event" class="form-select">
{% for value, label in events %}
<option value="{{ value }}" {% if wf and wf.trigger_event == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<!-- Action -->
<div class="card rounded-3 border-warning mb-4">
<div class="card-header bg-warning bg-opacity-10 fw-semibold">
<i class="bi bi-lightning-charge me-1 text-warning"></i> Action (Relay Control)
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Board</label>
<select name="action_board_id" class="form-select" id="action_board" onchange="updateRelays(this, 'action_relay')">
{% for b in boards %}
<option value="{{ b.id }}"
data-relays="{{ b.num_relays }}"
{% if wf and wf.action_board_id == b.id %}selected{% endif %}>
{{ b.name }} ({{ b.host }})
</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label class="form-label">Relay Number</label>
<select name="action_relay" class="form-select" id="action_relay">
{% if wf %}
{% for n in range(1, wf.action_board.num_relays + 1) %}
<option value="{{ n }}" {% if wf.action_relay == n %}selected{% endif %}>
Relay {{ n }}{% if wf.action_board.get_relay_label(n) != 'Relay ' ~ n %} — {{ wf.action_board.get_relay_label(n) }}{% endif %}
</option>
{% endfor %}
{% elif boards %}
{% for n in range(1, boards[0].num_relays + 1) %}
<option value="{{ n }}">Relay {{ n }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="col-6">
<label class="form-label">Action</label>
<select name="action_type" class="form-select">
{% for value, label in actions %}
<option value="{{ value }}" {% if wf and wf.action_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
{% if wf %}
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" name="is_enabled" id="is_enabled"
{% if wf.is_enabled %}checked{% endif %} />
<label class="form-check-label" for="is_enabled">Enabled</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
<a href="{{ url_for('workflows.list_workflows') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const boardsData = {{ boards | tojson | safe }};
// Build lookup: id -> board object
const boardMap = {};
{{ "{% for b in boards %}" }}
boardMap[{{ "{{ b.id }}" }}] = {
relays: {{ "{{ b.num_relays }}" }},
inputs: {{ "{{ b.num_inputs }}" }},
};
{{ "{% endfor %}" }}
function updateInputs(selectEl, targetId) {
const opt = selectEl.options[selectEl.selectedIndex];
const n = parseInt(opt.getAttribute("data-inputs")) || 4;
const target = document.getElementById(targetId);
target.innerHTML = "";
for (let i = 1; i <= n; i++) {
target.innerHTML += `<option value="${i}">Input ${i}</option>`;
}
}
function updateRelays(selectEl, targetId) {
const opt = selectEl.options[selectEl.selectedIndex];
const n = parseInt(opt.getAttribute("data-relays")) || 4;
const target = document.getElementById(targetId);
target.innerHTML = "";
for (let i = 1; i <= n; i++) {
target.innerHTML += `<option value="${i}">Relay ${i}</option>`;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Workflows 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-diagram-3 me-2 text-warning"></i>Workflows</h2>
{% if current_user.is_admin() %}
<a href="{{ url_for('workflows.add_workflow') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add Workflow
</a>
{% endif %}
</div>
{% if workflows %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Action</th>
<th>Status</th>
<th>Last Triggered</th>
<th></th>
</tr>
</thead>
<tbody>
{% for wf in workflows %}
<tr class="{% if not wf.is_enabled %}text-secondary{% endif %}">
<td class="fw-semibold">{{ wf.name }}</td>
<td>
<span class="badge text-bg-secondary">{{ wf.trigger_board.name }}</span>
Input {{ wf.trigger_input }}
<span class="badge text-bg-info">{{ wf.trigger_event }}</span>
</td>
<td>
<span class="badge text-bg-secondary">{{ wf.action_board.name }}</span>
Relay {{ wf.action_relay }}
<span class="badge text-bg-warning">{{ wf.action_type }}</span>
</td>
<td>
<span class="badge {% if wf.is_enabled %}text-bg-success{% else %}text-bg-secondary{% endif %}">
{% if wf.is_enabled %}Enabled{% else %}Disabled{% endif %}
</span>
</td>
<td class="small text-secondary">
{% if wf.last_triggered %}{{ wf.last_triggered.strftime('%Y-%m-%d %H:%M') }}{% else %}Never{% endif %}
</td>
<td>
{% if current_user.is_admin() %}
<a href="{{ url_for('workflows.edit_workflow', wf_id=wf.id) }}" class="btn btn-sm btn-outline-secondary me-1">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('workflows.toggle_workflow', wf_id=wf.id) }}" class="d-inline">
<button class="btn btn-sm {% if wf.is_enabled %}btn-outline-warning{% else %}btn-outline-success{% endif %} me-1"
title="{% if wf.is_enabled %}Disable{% else %}Enable{% endif %}">
<i class="bi bi-{% if wf.is_enabled %}pause{% else %}play{% endif %}"></i>
</button>
</form>
<form method="POST" action="{{ url_for('workflows.delete_workflow', wf_id=wf.id) }}" class="d-inline"
onsubmit="return confirm('Delete workflow {{ wf.name }}?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-diagram-3 display-2"></i>
<p class="mt-3">No workflows yet.</p>
{% if current_user.is_admin() %}
<a href="{{ url_for('workflows.add_workflow') }}" class="btn btn-primary">Create Workflow</a>
{% endif %}
</div>
{% endif %}
{% endblock %}

35
config.py Normal file
View File

@@ -0,0 +1,35 @@
"""Application configuration."""
import os
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production")
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
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
BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 10))
# Base URL this server is reachable at (boards will POST webhooks here)
SERVER_BASE_URL = os.environ.get("SERVER_BASE_URL", "http://localhost:5000")
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
DEBUG = False
config_map = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Migrate==4.0.7
Flask-SocketIO==5.3.6
Werkzeug==3.0.3
requests==2.32.3
simple-websocket==1.1.0
python-dotenv==1.0.1
WTForms==3.1.2
SQLAlchemy==2.0.31
greenlet>=3.1.1

41
run.py Normal file
View File

@@ -0,0 +1,41 @@
"""Application entry point.
Run with:
python run.py
Or for production:
gunicorn -w 1 -k eventlet "run:create_socketio_app()"
"""
import os
import threading
import time
from app import create_app, socketio
from app.services.board_service import poll_all_boards
app = create_app(os.environ.get("FLASK_ENV", "development"))
def _background_poller():
"""Poll all boards in a loop every BOARD_POLL_INTERVAL seconds."""
interval = app.config.get("BOARD_POLL_INTERVAL", 10)
while True:
try:
poll_all_boards(app)
except Exception as exc:
app.logger.warning("Poller error: %s", exc)
time.sleep(interval)
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()
port = int(os.environ.get("PORT", 5000))
socketio.run(app, host="0.0.0.0", port=port, debug=app.debug, allow_unsafe_werkzeug=True)