Initial commit: Location Management Flask app
This commit is contained in:
+40
@@ -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
@@ -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)")
|
||||
@@ -0,0 +1 @@
|
||||
"""Drivers package — auto-discovered plugins."""
|
||||
@@ -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}>"
|
||||
@@ -0,0 +1 @@
|
||||
"""Generic ESP32 driver package."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Generic ESP8266 driver package."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Olimex ESP32-C6-EVB driver package."""
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Models package."""
|
||||
from .user import User
|
||||
from .board import Board
|
||||
from .workflow import Workflow
|
||||
|
||||
__all__ = ["User", "Board", "Workflow"]
|
||||
@@ -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}>"
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})>"
|
||||
@@ -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}]>"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Routes package."""
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
])
|
||||
@@ -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"))
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -0,0 +1 @@
|
||||
"""Services package."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user