Initial commit: Location Management Flask app
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Flask / SQLite
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.log.*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# pytest
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
111
app/__init__.py
Normal file
111
app/__init__.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Flask application factory."""
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
from config import config_map
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
socketio = SocketIO()
|
||||
|
||||
|
||||
def create_app(config_name: str = "default") -> Flask:
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
app.config.from_object(config_map[config_name])
|
||||
|
||||
# ── extensions ────────────────────────────────────────────────────────────
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
socketio.init_app(app, cors_allowed_origins="*", async_mode="threading")
|
||||
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "auth.login"
|
||||
login_manager.login_message = "Please log in to access this page."
|
||||
login_manager.login_message_category = "warning"
|
||||
|
||||
# ── blueprints ────────────────────────────────────────────────────────────
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
from app.routes.boards import boards_bp
|
||||
from app.routes.workflows import workflows_bp
|
||||
from app.routes.api import api_bp
|
||||
from app.routes.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(boards_bp, url_prefix="/boards")
|
||||
app.register_blueprint(workflows_bp, url_prefix="/workflows")
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||
|
||||
# ── user loader ───────────────────────────────────────────────────────────
|
||||
from app.models.user import User
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
# ── discover board drivers ─────────────────────────────────────────────────
|
||||
from app.drivers.registry import registry
|
||||
registry.discover()
|
||||
app.logger.info("Board drivers loaded: %s", [d.DRIVER_ID for d in registry.all()])
|
||||
|
||||
# ── create tables & seed admin on first run ───────────────────────────────
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
_seed_admin(app)
|
||||
_add_entities_column(app)
|
||||
_migrate_board_types(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _add_entities_column(app: Flask) -> None:
|
||||
"""Add entities_json column to boards table if it doesn't exist yet."""
|
||||
from sqlalchemy import text
|
||||
with app.app_context():
|
||||
try:
|
||||
db.session.execute(text("ALTER TABLE boards ADD COLUMN entities_json TEXT DEFAULT '{}'"))
|
||||
db.session.commit()
|
||||
app.logger.info("Added entities_json column to boards table")
|
||||
except Exception:
|
||||
db.session.rollback() # Column already exists — safe to ignore
|
||||
|
||||
|
||||
def _migrate_board_types(app: Flask) -> None:
|
||||
"""Rename legacy board_type values to current driver IDs."""
|
||||
from app.models.board import Board
|
||||
renames = {
|
||||
"olimex_esp32_c6": "olimex_esp32_c6_evb",
|
||||
}
|
||||
total = 0
|
||||
for old, new in renames.items():
|
||||
rows = Board.query.filter_by(board_type=old).all()
|
||||
for b in rows:
|
||||
b.board_type = new
|
||||
total += 1
|
||||
if total:
|
||||
db.session.commit()
|
||||
app.logger.info("Migrated %d board(s) to updated driver IDs", total)
|
||||
|
||||
|
||||
def _seed_admin(app: Flask) -> None:
|
||||
"""Create default admin account if no users exist."""
|
||||
from app.models.user import User
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
if User.query.count() == 0:
|
||||
admin = User(
|
||||
username="admin",
|
||||
password_hash=generate_password_hash("admin"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
app.logger.info("Default admin user created (username=admin password=admin)")
|
||||
1
app/drivers/__init__.py
Normal file
1
app/drivers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Drivers package — auto-discovered plugins."""
|
||||
76
app/drivers/base.py
Normal file
76
app/drivers/base.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Base class every board driver must implement.
|
||||
|
||||
To create a new board driver:
|
||||
1. Make a subfolder inside ``app/drivers/`` (e.g. ``my_custom_board/``)
|
||||
2. Add a ``driver.py`` that subclasses ``BoardDriver``
|
||||
3. Set the class-level metadata attributes
|
||||
4. The registry will auto-discover and register it at startup — no other
|
||||
changes needed anywhere in the application.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.board import Board
|
||||
|
||||
|
||||
class BoardDriver(ABC):
|
||||
"""Abstract interface all board drivers must satisfy."""
|
||||
|
||||
# ── Metadata (override in subclass) ──────────────────────────────────────
|
||||
#: Unique identifier stored in Board.board_type column
|
||||
DRIVER_ID: str = ""
|
||||
#: Human-readable name shown in the UI
|
||||
DISPLAY_NAME: str = ""
|
||||
#: Short description shown on the Add-board form
|
||||
DESCRIPTION: str = ""
|
||||
#: Default I/O counts pre-filled when this type is selected
|
||||
DEFAULT_NUM_RELAYS: int = 4
|
||||
DEFAULT_NUM_INPUTS: int = 4
|
||||
#: Firmware download link shown in the UI (optional)
|
||||
FIRMWARE_URL: str = ""
|
||||
|
||||
# ── Core API ──────────────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
|
||||
"""Return current relay state or None on comm error."""
|
||||
|
||||
@abstractmethod
|
||||
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
|
||||
"""Set relay ON/OFF. Return True on success."""
|
||||
|
||||
@abstractmethod
|
||||
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
|
||||
"""Toggle relay. Return new state or None on error."""
|
||||
|
||||
@abstractmethod
|
||||
def poll(self, board: "Board") -> dict:
|
||||
"""Fetch all I/O states.
|
||||
|
||||
Must return::
|
||||
|
||||
{
|
||||
"relay_states": {"relay_1": True, ...},
|
||||
"input_states": {"input_1": False, ...},
|
||||
"is_online": True,
|
||||
"firmware_version": "1.0.0", # optional
|
||||
}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def register_webhook(self, board: "Board", callback_url: str) -> bool:
|
||||
"""Tell the board where to POST input events. Return True on success."""
|
||||
|
||||
# ── Optional hooks ────────────────────────────────────────────────────────
|
||||
|
||||
def on_board_added(self, board: "Board", server_base_url: str) -> None:
|
||||
"""Called once right after a board is created in the DB."""
|
||||
self.register_webhook(board, f"{server_base_url}/api/webhook/{board.id}")
|
||||
|
||||
def on_board_deleted(self, board: "Board") -> None:
|
||||
"""Called just before a board is deleted from the DB."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Driver {self.DRIVER_ID}>"
|
||||
1
app/drivers/generic_esp32/__init__.py
Normal file
1
app/drivers/generic_esp32/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Generic ESP32 driver package."""
|
||||
90
app/drivers/generic_esp32/driver.py
Normal file
90
app/drivers/generic_esp32/driver.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Generic ESP32 board driver (custom firmware).
|
||||
|
||||
This is a template driver for custom ESP32 firmware.
|
||||
Copy this folder, rename it, and implement the HTTP endpoints
|
||||
to match your own firmware's API.
|
||||
|
||||
Expected firmware endpoints (same shape as Olimex by default):
|
||||
POST /relay/on?relay=<n>
|
||||
POST /relay/off?relay=<n>
|
||||
POST /relay/toggle?relay=<n>
|
||||
GET /relay/status?relay=<n> → {"state": <bool>}
|
||||
GET /input/status?input=<n> → {"state": <bool>}
|
||||
POST /register?callback_url=<url>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.drivers.base import BoardDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.board import Board
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TIMEOUT = 3
|
||||
|
||||
|
||||
def _get(url):
|
||||
try:
|
||||
r = requests.get(url, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
logger.debug("GET %s → %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _post(url):
|
||||
try:
|
||||
r = requests.post(url, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
logger.debug("POST %s → %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
class GenericESP32Driver(BoardDriver):
|
||||
"""Generic ESP32 driver — uses the same REST conventions as the Olimex board.
|
||||
Customise the endpoint paths below to match your firmware."""
|
||||
|
||||
DRIVER_ID = "generic_esp32"
|
||||
DISPLAY_NAME = "Generic ESP32"
|
||||
DESCRIPTION = "Custom ESP32 firmware · same REST API shape as Olimex"
|
||||
DEFAULT_NUM_RELAYS = 4
|
||||
DEFAULT_NUM_INPUTS = 4
|
||||
|
||||
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
|
||||
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
|
||||
return bool(data["state"]) if data is not None else None
|
||||
|
||||
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
|
||||
action = "on" if state else "off"
|
||||
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
|
||||
|
||||
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
|
||||
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
|
||||
return bool(data["state"]) if data is not None else None
|
||||
|
||||
def poll(self, board: "Board") -> dict:
|
||||
relay_states, input_states, online = {}, {}, False
|
||||
|
||||
for n in range(1, board.num_relays + 1):
|
||||
data = _get(f"{board.base_url}/relay/status?relay={n}")
|
||||
if data is not None:
|
||||
relay_states[f"relay_{n}"] = bool(data.get("state", False))
|
||||
online = True
|
||||
|
||||
for n in range(1, board.num_inputs + 1):
|
||||
data = _get(f"{board.base_url}/input/status?input={n}")
|
||||
if data is not None:
|
||||
input_states[f"input_{n}"] = bool(data.get("state", False))
|
||||
online = True
|
||||
|
||||
return {"relay_states": relay_states, "input_states": input_states, "is_online": online}
|
||||
|
||||
def register_webhook(self, board: "Board", callback_url: str) -> bool:
|
||||
return _post(f"{board.base_url}/register?callback_url={callback_url}") is not None
|
||||
1
app/drivers/generic_esp8266/__init__.py
Normal file
1
app/drivers/generic_esp8266/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Generic ESP8266 driver package."""
|
||||
80
app/drivers/generic_esp8266/driver.py
Normal file
80
app/drivers/generic_esp8266/driver.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Generic ESP8266 board driver (custom firmware).
|
||||
|
||||
Lighter board — defaults to 2 relays and 2 inputs.
|
||||
Adjust DEFAULT_NUM_RELAYS / DEFAULT_NUM_INPUTS to match your hardware.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.drivers.base import BoardDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.board import Board
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TIMEOUT = 3
|
||||
|
||||
|
||||
def _get(url):
|
||||
try:
|
||||
r = requests.get(url, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
logger.debug("GET %s → %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _post(url):
|
||||
try:
|
||||
r = requests.post(url, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
logger.debug("POST %s → %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
class GenericESP8266Driver(BoardDriver):
|
||||
"""Generic ESP8266 driver — lighter board variant."""
|
||||
|
||||
DRIVER_ID = "generic_esp8266"
|
||||
DISPLAY_NAME = "Generic ESP8266"
|
||||
DESCRIPTION = "Custom ESP8266 firmware · 2 relays · 2 inputs (configurable)"
|
||||
DEFAULT_NUM_RELAYS = 2
|
||||
DEFAULT_NUM_INPUTS = 2
|
||||
|
||||
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
|
||||
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
|
||||
return bool(data["state"]) if data is not None else None
|
||||
|
||||
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
|
||||
action = "on" if state else "off"
|
||||
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
|
||||
|
||||
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
|
||||
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
|
||||
return bool(data["state"]) if data is not None else None
|
||||
|
||||
def poll(self, board: "Board") -> dict:
|
||||
relay_states, input_states, online = {}, {}, False
|
||||
|
||||
for n in range(1, board.num_relays + 1):
|
||||
data = _get(f"{board.base_url}/relay/status?relay={n}")
|
||||
if data is not None:
|
||||
relay_states[f"relay_{n}"] = bool(data.get("state", False))
|
||||
online = True
|
||||
|
||||
for n in range(1, board.num_inputs + 1):
|
||||
data = _get(f"{board.base_url}/input/status?input={n}")
|
||||
if data is not None:
|
||||
input_states[f"input_{n}"] = bool(data.get("state", False))
|
||||
online = True
|
||||
|
||||
return {"relay_states": relay_states, "input_states": input_states, "is_online": online}
|
||||
|
||||
def register_webhook(self, board: "Board", callback_url: str) -> bool:
|
||||
return _post(f"{board.base_url}/register?callback_url={callback_url}") is not None
|
||||
1
app/drivers/olimex_esp32_c6_evb/__init__.py
Normal file
1
app/drivers/olimex_esp32_c6_evb/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Olimex ESP32-C6-EVB driver package."""
|
||||
117
app/drivers/olimex_esp32_c6_evb/driver.py
Normal file
117
app/drivers/olimex_esp32_c6_evb/driver.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Olimex ESP32-C6-EVB board driver.
|
||||
|
||||
Hardware
|
||||
--------
|
||||
- 4 relays (outputs)
|
||||
- 4 digital inputs
|
||||
- HTTP REST API served directly by the board on port 80
|
||||
|
||||
API endpoints
|
||||
-------------
|
||||
POST /relay/on?relay=<n> → {"state": true}
|
||||
POST /relay/off?relay=<n> → {"state": false}
|
||||
POST /relay/toggle?relay=<n> → {"state": <new>}
|
||||
GET /relay/status?relay=<n> → {"state": <bool>}
|
||||
GET /input/status?input=<n> → {"state": <bool>}
|
||||
POST /register?callback_url=<url> → {"status": "ok"}
|
||||
|
||||
Webhook (board → server)
|
||||
------------------------
|
||||
The board POSTs to the registered callback_url whenever an input changes:
|
||||
POST <callback_url>
|
||||
{"input": <n>, "state": <bool>}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.drivers.base import BoardDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.board import Board
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TIMEOUT = 3
|
||||
|
||||
|
||||
def _get(url: str) -> dict | None:
|
||||
try:
|
||||
r = requests.get(url, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
logger.debug("GET %s → %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _post(url: str) -> dict | None:
|
||||
try:
|
||||
r = requests.post(url, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
logger.debug("POST %s → %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
class OlimexESP32C6EVBDriver(BoardDriver):
|
||||
"""Driver for the Olimex ESP32-C6-EVB board."""
|
||||
|
||||
DRIVER_ID = "olimex_esp32_c6_evb"
|
||||
DISPLAY_NAME = "Olimex ESP32-C6-EVB"
|
||||
DESCRIPTION = "4 relays · 4 digital inputs · HTTP REST + webhook callbacks"
|
||||
DEFAULT_NUM_RELAYS = 4
|
||||
DEFAULT_NUM_INPUTS = 4
|
||||
FIRMWARE_URL = "https://github.com/OLIMEX/ESP32-C6-EVB"
|
||||
|
||||
# ── relay control ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
|
||||
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
|
||||
return bool(data["state"]) if data is not None else None
|
||||
|
||||
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
|
||||
action = "on" if state else "off"
|
||||
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
|
||||
|
||||
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
|
||||
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
|
||||
return bool(data["state"]) if data is not None else None
|
||||
|
||||
# ── poll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def poll(self, board: "Board") -> dict:
|
||||
relay_states: dict = {}
|
||||
input_states: dict = {}
|
||||
online = False
|
||||
|
||||
for n in range(1, board.num_relays + 1):
|
||||
data = _get(f"{board.base_url}/relay/status?relay={n}")
|
||||
if data is not None:
|
||||
relay_states[f"relay_{n}"] = bool(data.get("state", False))
|
||||
online = True
|
||||
|
||||
for n in range(1, board.num_inputs + 1):
|
||||
data = _get(f"{board.base_url}/input/status?input={n}")
|
||||
if data is not None:
|
||||
input_states[f"input_{n}"] = bool(data.get("state", True))
|
||||
online = True
|
||||
|
||||
return {
|
||||
"relay_states": relay_states,
|
||||
"input_states": input_states,
|
||||
"is_online": online,
|
||||
}
|
||||
|
||||
# ── webhook registration ──────────────────────────────────────────────────
|
||||
|
||||
def register_webhook(self, board: "Board", callback_url: str) -> bool:
|
||||
url = f"{board.base_url}/register?callback_url={callback_url}"
|
||||
ok = _post(url) is not None
|
||||
if ok:
|
||||
logger.info("Webhook registered on board '%s' → %s", board.name, callback_url)
|
||||
else:
|
||||
logger.warning("Webhook registration failed for board '%s'", board.name)
|
||||
return ok
|
||||
18
app/drivers/olimex_esp32_c6_evb/manifest.json
Normal file
18
app/drivers/olimex_esp32_c6_evb/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"driver_id": "olimex_esp32_c6_evb",
|
||||
"display_name": "Olimex ESP32-C6-EVB",
|
||||
"description": "Olimex ESP32-C6-EVB board with 4 relays and 4 digital inputs. Communicates via HTTP REST API with webhook callbacks.",
|
||||
"manufacturer": "Olimex",
|
||||
"chip": "ESP32-C6 WROOM-1",
|
||||
"default_num_relays": 4,
|
||||
"default_num_inputs": 4,
|
||||
"firmware_url": "https://github.com/OLIMEX/ESP32-C6-EVB",
|
||||
"api": {
|
||||
"relay_on": "POST /relay/on?relay={n}",
|
||||
"relay_off": "POST /relay/off?relay={n}",
|
||||
"relay_toggle": "POST /relay/toggle?relay={n}",
|
||||
"relay_status": "GET /relay/status?relay={n}",
|
||||
"input_status": "GET /input/status?input={n}",
|
||||
"register": "POST /register?callback_url={url}"
|
||||
}
|
||||
}
|
||||
104
app/drivers/registry.py
Normal file
104
app/drivers/registry.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Driver registry — auto-discovers board drivers from sub-folders.
|
||||
|
||||
At application startup call ``registry.discover()``. Every sub-folder of
|
||||
``app/drivers/`` that contains a ``driver.py`` with a ``BoardDriver`` subclass
|
||||
is registered automatically. No manual imports needed.
|
||||
|
||||
Usage
|
||||
-----
|
||||
::
|
||||
from app.drivers.registry import registry
|
||||
|
||||
driver = registry.get("olimex_esp32_c6_evb")
|
||||
driver.set_relay(board, 1, True)
|
||||
|
||||
# All registered drivers (for UI drop-downs):
|
||||
all_drivers = registry.all()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.drivers.base import BoardDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DRIVERS_DIR = os.path.dirname(__file__)
|
||||
|
||||
|
||||
class DriverRegistry:
|
||||
def __init__(self):
|
||||
self._drivers: dict[str, BoardDriver] = {}
|
||||
|
||||
# ── discovery ─────────────────────────────────────────────────────────────
|
||||
|
||||
def discover(self) -> None:
|
||||
"""Scan ``app/drivers/`` sub-folders and register found drivers."""
|
||||
for entry in sorted(os.scandir(_DRIVERS_DIR), key=lambda e: e.name):
|
||||
if not entry.is_dir() or entry.name.startswith(("_", ".")):
|
||||
continue
|
||||
driver_module_path = os.path.join(entry.path, "driver.py")
|
||||
if not os.path.isfile(driver_module_path):
|
||||
logger.debug("Skipping %s — no driver.py found", entry.name)
|
||||
continue
|
||||
module_name = f"app.drivers.{entry.name}.driver"
|
||||
try:
|
||||
mod = importlib.import_module(module_name)
|
||||
self._register_from_module(mod, entry.name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load driver '%s': %s", entry.name, exc, exc_info=True)
|
||||
|
||||
def _register_from_module(self, mod, folder_name: str) -> None:
|
||||
for _name, obj in inspect.getmembers(mod, inspect.isclass):
|
||||
if (
|
||||
issubclass(obj, BoardDriver)
|
||||
and obj is not BoardDriver
|
||||
and obj.DRIVER_ID
|
||||
):
|
||||
instance = obj()
|
||||
self._drivers[obj.DRIVER_ID] = instance
|
||||
logger.info(
|
||||
"Board driver registered: '%s' (%s) from folder '%s'",
|
||||
obj.DRIVER_ID, obj.DISPLAY_NAME, folder_name,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Folder '%s' has driver.py but no BoardDriver subclass with DRIVER_ID found",
|
||||
folder_name,
|
||||
)
|
||||
|
||||
# ── lookup ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get(self, driver_id: str) -> BoardDriver | None:
|
||||
return self._drivers.get(driver_id)
|
||||
|
||||
def get_or_default(self, driver_id: str) -> BoardDriver:
|
||||
"""Return driver or fall back to the generic_esp32 stub."""
|
||||
return self._drivers.get(driver_id) or self._drivers.get("generic_esp32") or next(iter(self._drivers.values()))
|
||||
|
||||
def all(self) -> list[BoardDriver]:
|
||||
return list(self._drivers.values())
|
||||
|
||||
def choices(self) -> list[tuple[str, str]]:
|
||||
"""Return list of (DRIVER_ID, DISPLAY_NAME) for form select fields."""
|
||||
return [(d.DRIVER_ID, d.DISPLAY_NAME) for d in self._drivers.values()]
|
||||
|
||||
def is_registered(self, driver_id: str) -> bool:
|
||||
return driver_id in self._drivers
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._drivers)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DriverRegistry drivers={list(self._drivers.keys())}>"
|
||||
|
||||
|
||||
# Singleton — import this everywhere
|
||||
registry = DriverRegistry()
|
||||
6
app/models/__init__.py
Normal file
6
app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Models package."""
|
||||
from .user import User
|
||||
from .board import Board
|
||||
from .workflow import Workflow
|
||||
|
||||
__all__ = ["User", "Board", "Workflow"]
|
||||
144
app/models/board.py
Normal file
144
app/models/board.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Board model.
|
||||
|
||||
Supported board types
|
||||
---------------------
|
||||
olimex_esp32_c6 – Olimex ESP32-C6-EVB (4 relays, 4 digital inputs)
|
||||
Communicates via HTTP REST + webhook callbacks
|
||||
generic_esp32 – Generic ESP32 with custom firmware (configurable I/O)
|
||||
generic_esp8266 – Generic ESP8266 with custom firmware (configurable I/O)
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class Board(db.Model):
|
||||
__tablename__ = "boards"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
# Human-readable local name
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
board_type = db.Column(db.String(32), nullable=False, default="olimex_esp32_c6_evb")
|
||||
host = db.Column(db.String(128), nullable=False)
|
||||
port = db.Column(db.Integer, nullable=False, default=80)
|
||||
# Number of relays / outputs exposed by this board
|
||||
num_relays = db.Column(db.Integer, nullable=False, default=4)
|
||||
# Number of digital inputs
|
||||
num_inputs = db.Column(db.Integer, nullable=False, default=4)
|
||||
# JSON blob: {"relay_1": true, "relay_2": false, ...}
|
||||
relay_states_json = db.Column(db.Text, default="{}")
|
||||
# JSON blob: {"input_1": false, "input_2": true, ...}
|
||||
input_states_json = db.Column(db.Text, default="{}")
|
||||
# Relay and input labels (legacy, kept for backward compat)
|
||||
labels_json = db.Column(db.Text, default="{}")
|
||||
# Entity config: {"relay_1": {"type": "light", "name": "...", "icon": ""}, ...}
|
||||
entities_json = db.Column(db.Text, default="{}")
|
||||
# Whether this board is currently reachable
|
||||
is_online = db.Column(db.Boolean, default=False)
|
||||
last_seen = db.Column(db.DateTime, nullable=True)
|
||||
# Firmware version string reported by the board
|
||||
firmware_version = db.Column(db.String(64), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# ── relationships ────────────────────────────────────────────────
|
||||
workflows_trigger = db.relationship(
|
||||
"Workflow", foreign_keys="Workflow.trigger_board_id",
|
||||
back_populates="trigger_board", cascade="all, delete-orphan"
|
||||
)
|
||||
workflows_target = db.relationship(
|
||||
"Workflow", foreign_keys="Workflow.action_board_id",
|
||||
back_populates="action_board"
|
||||
)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
@property
|
||||
def relay_states(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.relay_states_json or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
@relay_states.setter
|
||||
def relay_states(self, value: dict):
|
||||
self.relay_states_json = json.dumps(value)
|
||||
|
||||
@property
|
||||
def input_states(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.input_states_json or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
@input_states.setter
|
||||
def input_states(self, value: dict):
|
||||
self.input_states_json = json.dumps(value)
|
||||
|
||||
@property
|
||||
def labels(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.labels_json or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
@labels.setter
|
||||
def labels(self, value: dict):
|
||||
self.labels_json = json.dumps(value)
|
||||
|
||||
@property
|
||||
def entities(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.entities_json or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
@entities.setter
|
||||
def entities(self, value: dict):
|
||||
self.entities_json = json.dumps(value)
|
||||
|
||||
def get_relay_entity(self, n: int) -> dict:
|
||||
from app.models.entity_types import RELAY_ENTITY_TYPES
|
||||
cfg = self.entities.get(f"relay_{n}", {})
|
||||
etype = cfg.get("type", "switch")
|
||||
tdef = RELAY_ENTITY_TYPES.get(etype, RELAY_ENTITY_TYPES["switch"])
|
||||
# name: entities > legacy labels > type default
|
||||
name = cfg.get("name", "").strip() or self.labels.get(f"relay_{n}", "") or f"{tdef['label']} {n}"
|
||||
icon = cfg.get("icon", "").strip() or tdef["icon"]
|
||||
return {
|
||||
"name": name,
|
||||
"icon": icon,
|
||||
"type": etype,
|
||||
"on_color": tdef["on"][0],
|
||||
"on_label": tdef["on"][1],
|
||||
"off_color": tdef["off"][0],
|
||||
"off_label": tdef["off"][1],
|
||||
}
|
||||
|
||||
def get_input_entity(self, n: int) -> dict:
|
||||
from app.models.entity_types import INPUT_ENTITY_TYPES
|
||||
cfg = self.entities.get(f"input_{n}", {})
|
||||
etype = cfg.get("type", "generic")
|
||||
tdef = INPUT_ENTITY_TYPES.get(etype, INPUT_ENTITY_TYPES["generic"])
|
||||
name = cfg.get("name", "").strip() or self.labels.get(f"input_{n}", "") or f"{tdef['label']} {n}"
|
||||
icon = cfg.get("icon", "").strip() or tdef["icon"]
|
||||
return {
|
||||
"name": name,
|
||||
"icon": icon,
|
||||
"type": etype,
|
||||
"active_color": tdef["active"][0],
|
||||
"active_label": tdef["active"][1],
|
||||
"idle_color": tdef["idle"][0],
|
||||
"idle_label": tdef["idle"][1],
|
||||
}
|
||||
|
||||
def get_relay_label(self, relay_num: int) -> str:
|
||||
return self.get_relay_entity(relay_num)["name"]
|
||||
|
||||
def get_input_label(self, input_num: int) -> str:
|
||||
return self.get_input_entity(input_num)["name"]
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Board {self.name} ({self.board_type}) @ {self.host}:{self.port}>"
|
||||
95
app/models/entity_types.py
Normal file
95
app/models/entity_types.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Entity type definitions for relay outputs and digital inputs.
|
||||
|
||||
Each type declares its default icon (Bootstrap Icons class), display label,
|
||||
and the color/text to show for each state. These drive the UI automatically
|
||||
so adding a new type here is the only step required.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
|
||||
# ── Relay / output entity types ───────────────────────────────────────────────
|
||||
# key → {icon, label, on: (color, text), off: (color, text)}
|
||||
RELAY_ENTITY_TYPES: dict = OrderedDict([
|
||||
("switch", {"icon": "bi-toggles", "label": "Switch", "on": ("success", "ON"), "off": ("secondary", "OFF")}),
|
||||
("light", {"icon": "bi-lightbulb-fill", "label": "Light", "on": ("warning", "ON"), "off": ("secondary", "OFF")}),
|
||||
("outlet", {"icon": "bi-plug-fill", "label": "Outlet", "on": ("success", "ON"), "off": ("secondary", "OFF")}),
|
||||
("fan", {"icon": "bi-fan", "label": "Fan", "on": ("info", "RUN"), "off": ("secondary", "STOP")}),
|
||||
("pump", {"icon": "bi-water", "label": "Pump", "on": ("primary", "RUN"), "off": ("secondary", "STOP")}),
|
||||
("heater", {"icon": "bi-thermometer-high", "label": "Heater", "on": ("danger", "ON"), "off": ("secondary", "OFF")}),
|
||||
("lock", {"icon": "bi-lock-fill", "label": "Lock", "on": ("danger", "LOCKED"), "off": ("success", "OPEN")}),
|
||||
("gate", {"icon": "bi-door-open-fill", "label": "Gate", "on": ("warning", "OPEN"), "off": ("secondary", "CLOSED")}),
|
||||
("valve", {"icon": "bi-moisture", "label": "Valve", "on": ("info", "OPEN"), "off": ("secondary", "CLOSED")}),
|
||||
("siren", {"icon": "bi-megaphone-fill", "label": "Siren", "on": ("danger", "ALERT"), "off": ("secondary", "SILENT")}),
|
||||
])
|
||||
|
||||
# ── Input / sensor entity types ───────────────────────────────────────────────
|
||||
# NC contacts: raw True = resting → is_active = False (idle)
|
||||
# raw False = triggered → is_active = True (active)
|
||||
# key → {icon, label, active: (color, text), idle: (color, text)}
|
||||
INPUT_ENTITY_TYPES: dict = OrderedDict([
|
||||
("generic", {"icon": "bi-circle-fill", "label": "Input", "active": ("info", "ACTIVE"), "idle": ("secondary", "IDLE")}),
|
||||
("button", {"icon": "bi-hand-index-thumb-fill", "label": "Button", "active": ("warning", "PRESSED"), "idle": ("secondary", "IDLE")}),
|
||||
("door", {"icon": "bi-door-open-fill", "label": "Door", "active": ("warning", "OPEN"), "idle": ("success", "CLOSED")}),
|
||||
("window", {"icon": "bi-window-dash", "label": "Window", "active": ("warning", "OPEN"), "idle": ("success", "CLOSED")}),
|
||||
("motion", {"icon": "bi-person-walking", "label": "Motion", "active": ("danger", "MOTION"), "idle": ("secondary", "CLEAR")}),
|
||||
("contact", {"icon": "bi-magnet-fill", "label": "Contact", "active": ("warning", "OPEN"), "idle": ("success", "CLOSED")}),
|
||||
("smoke", {"icon": "bi-cloud-haze2", "label": "Smoke", "active": ("danger", "ALARM"), "idle": ("success", "CLEAR")}),
|
||||
("flood", {"icon": "bi-droplet-fill", "label": "Flood", "active": ("danger", "FLOOD"), "idle": ("success", "DRY")}),
|
||||
("vibration", {"icon": "bi-activity", "label": "Vibration", "active": ("warning", "VIBRATION"), "idle": ("secondary", "IDLE")}),
|
||||
])
|
||||
|
||||
# ── Icon picker palette (Bootstrap Icons, for custom override) ────────────────
|
||||
ICON_PALETTE = [
|
||||
# Lighting / power
|
||||
("bi-lightbulb-fill", "Bulb"),
|
||||
("bi-lamp-fill", "Lamp"),
|
||||
("bi-sun-fill", "Sun"),
|
||||
("bi-plug-fill", "Plug"),
|
||||
("bi-toggles", "Toggle"),
|
||||
("bi-power", "Power"),
|
||||
# Climate
|
||||
("bi-thermometer-high", "Heater"),
|
||||
("bi-thermometer-low", "Cool"),
|
||||
("bi-fan", "Fan"),
|
||||
("bi-wind", "Wind"),
|
||||
("bi-snow2", "AC"),
|
||||
("bi-moisture", "Moisture"),
|
||||
# Water / pumps
|
||||
("bi-water", "Water"),
|
||||
("bi-droplet-fill", "Drop"),
|
||||
("bi-droplet-half", "Drip"),
|
||||
("bi-bucket-fill", "Bucket"),
|
||||
# Doors / security
|
||||
("bi-door-open-fill", "Door"),
|
||||
("bi-door-closed-fill", "Door (c)"),
|
||||
("bi-lock-fill", "Lock"),
|
||||
("bi-unlock-fill", "Unlock"),
|
||||
("bi-shield-fill", "Shield"),
|
||||
("bi-key-fill", "Key"),
|
||||
("bi-megaphone-fill", "Siren"),
|
||||
("bi-bell-fill", "Bell"),
|
||||
# Cameras / access
|
||||
("bi-camera-video-fill", "Camera"),
|
||||
("bi-eye-fill", "Eye"),
|
||||
# Motion / sensors
|
||||
("bi-person-walking", "Motion"),
|
||||
("bi-activity", "Sensor"),
|
||||
("bi-magnet-fill", "Magnet"),
|
||||
("bi-cloud-haze2", "Smoke"),
|
||||
("bi-hand-index-thumb-fill", "Button"),
|
||||
("bi-window-dash", "Window"),
|
||||
# Outdoors / location
|
||||
("bi-house-fill", "House"),
|
||||
("bi-building", "Building"),
|
||||
("bi-car-front-fill", "Car"),
|
||||
("bi-tree-fill", "Tree"),
|
||||
("bi-sign-yield-fill", "Gate"),
|
||||
# Misc
|
||||
("bi-gear-fill", "Gear"),
|
||||
("bi-cpu-fill", "CPU"),
|
||||
("bi-wifi", "WiFi"),
|
||||
("bi-tv-fill", "TV"),
|
||||
("bi-speaker-fill", "Speaker"),
|
||||
("bi-music-note-beamed", "Music"),
|
||||
("bi-controller", "Control"),
|
||||
("bi-circle-fill", "Circle"),
|
||||
]
|
||||
19
app/models/user.py
Normal file
19
app/models/user.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""User model."""
|
||||
from flask_login import UserMixin
|
||||
from app import db
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default="user") # "admin" | "user"
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == "admin"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} ({self.role})>"
|
||||
51
app/models/workflow.py
Normal file
51
app/models/workflow.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Workflow model.
|
||||
|
||||
A workflow fires an action (set relay ON/OFF/TOGGLE) on a *target* board
|
||||
whenever a specific input on a *trigger* board changes state.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class Workflow(db.Model):
|
||||
__tablename__ = "workflows"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
is_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
# ── trigger ──────────────────────────────────────────────────────
|
||||
trigger_board_id = db.Column(
|
||||
db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
trigger_input = db.Column(db.Integer, nullable=False) # 1-based
|
||||
# "press" (rising edge), "release" (falling), "both"
|
||||
trigger_event = db.Column(db.String(16), nullable=False, default="press")
|
||||
|
||||
# ── action ───────────────────────────────────────────────────────
|
||||
action_board_id = db.Column(
|
||||
db.Integer, db.ForeignKey("boards.id"), nullable=False
|
||||
)
|
||||
action_relay = db.Column(db.Integer, nullable=False) # 1-based
|
||||
# "on", "off", "toggle"
|
||||
action_type = db.Column(db.String(16), nullable=False, default="toggle")
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_triggered = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# ── relationships ────────────────────────────────────────────────
|
||||
trigger_board = db.relationship(
|
||||
"Board", foreign_keys=[trigger_board_id],
|
||||
back_populates="workflows_trigger"
|
||||
)
|
||||
action_board = db.relationship(
|
||||
"Board", foreign_keys=[action_board_id],
|
||||
back_populates="workflows_target"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Workflow '{self.name}' "
|
||||
f"Board#{self.trigger_board_id}.in{self.trigger_input} "
|
||||
f"→ Board#{self.action_board_id}.relay{self.action_relay} [{self.action_type}]>"
|
||||
)
|
||||
1
app/routes/__init__.py
Normal file
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Routes package."""
|
||||
87
app/routes/admin.py
Normal file
87
app/routes/admin.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Admin routes – user management (admin only)."""
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
def _require_admin():
|
||||
if not current_user.is_authenticated or not current_user.is_admin():
|
||||
abort(403)
|
||||
|
||||
|
||||
@admin_bp.route("/users")
|
||||
@login_required
|
||||
def list_users():
|
||||
_require_admin()
|
||||
users = User.query.order_by(User.username).all()
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@admin_bp.route("/users/add", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_user():
|
||||
_require_admin()
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
role = request.form.get("role", "user")
|
||||
|
||||
if not username or not password:
|
||||
flash("Username and password are required.", "danger")
|
||||
return render_template("admin/user_form.html", user=None)
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash("Username already exists.", "danger")
|
||||
return render_template("admin/user_form.html", user=None)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=generate_password_hash(password),
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash(f"User '{username}' created.", "success")
|
||||
return redirect(url_for("admin.list_users"))
|
||||
|
||||
return render_template("admin/user_form.html", user=None)
|
||||
|
||||
|
||||
@admin_bp.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_user(user_id: int):
|
||||
_require_admin()
|
||||
user = db.get_or_404(User, user_id)
|
||||
|
||||
if request.method == "POST":
|
||||
user.username = request.form.get("username", user.username).strip()
|
||||
user.role = request.form.get("role", user.role)
|
||||
user.is_active = "is_active" in request.form
|
||||
new_password = request.form.get("password", "").strip()
|
||||
if new_password:
|
||||
user.password_hash = generate_password_hash(new_password)
|
||||
db.session.commit()
|
||||
flash("User updated.", "success")
|
||||
return redirect(url_for("admin.list_users"))
|
||||
|
||||
return render_template("admin/user_form.html", user=user)
|
||||
|
||||
|
||||
@admin_bp.route("/users/<int:user_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user(user_id: int):
|
||||
_require_admin()
|
||||
if user_id == current_user.id:
|
||||
flash("You cannot delete your own account.", "danger")
|
||||
return redirect(url_for("admin.list_users"))
|
||||
user = db.get_or_404(User, user_id)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f"User '{user.username}' deleted.", "warning")
|
||||
return redirect(url_for("admin.list_users"))
|
||||
81
app/routes/api.py
Normal file
81
app/routes/api.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""REST API – board webhook receiver and JSON relay control."""
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, abort
|
||||
|
||||
from app import db, socketio
|
||||
from app.models.board import Board
|
||||
from app.services import workflow_engine
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
# ── webhook endpoint (boards POST input events here) ──────────────────────────
|
||||
|
||||
@api_bp.route("/webhook/<int:board_id>", methods=["POST"])
|
||||
def webhook(board_id: int):
|
||||
board = db.session.get(Board, board_id)
|
||||
if board is None:
|
||||
abort(404)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
input_num = data.get("input")
|
||||
state = data.get("state")
|
||||
|
||||
if input_num is None or state is None:
|
||||
return jsonify({"error": "missing input or state"}), 400
|
||||
|
||||
# Update cached input state
|
||||
states = board.input_states
|
||||
states[f"input_{input_num}"] = bool(state)
|
||||
board.input_states = states
|
||||
board.is_online = True
|
||||
board.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Let the workflow engine decide what to do
|
||||
workflow_engine.process_input_event(board_id, int(input_num), bool(state))
|
||||
|
||||
# Push live update to all connected clients immediately
|
||||
socketio.emit("board_update", {
|
||||
"board_id": board_id,
|
||||
"is_online": True,
|
||||
"input_states": board.input_states,
|
||||
"relay_states": board.relay_states,
|
||||
})
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# ── JSON relay status ─────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route("/boards/<int:board_id>/relays")
|
||||
def relay_states(board_id: int):
|
||||
board = db.get_or_404(Board, board_id)
|
||||
return jsonify({
|
||||
"board_id": board.id,
|
||||
"name": board.name,
|
||||
"is_online": board.is_online,
|
||||
"relay_states": board.relay_states,
|
||||
"input_states": board.input_states,
|
||||
})
|
||||
|
||||
|
||||
# ── JSON board list ───────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route("/boards")
|
||||
def board_list():
|
||||
boards = Board.query.order_by(Board.name).all()
|
||||
return jsonify([
|
||||
{
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"board_type": b.board_type,
|
||||
"host": b.host,
|
||||
"port": b.port,
|
||||
"is_online": b.is_online,
|
||||
"last_seen": b.last_seen.isoformat() if b.last_seen else None,
|
||||
"relay_states": b.relay_states,
|
||||
"input_states": b.input_states,
|
||||
}
|
||||
for b in boards
|
||||
])
|
||||
38
app/routes/auth.py
Normal file
38
app/routes/auth.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Authentication routes."""
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
remember = bool(request.form.get("remember"))
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and user.is_active and check_password_hash(user.password_hash, password):
|
||||
login_user(user, remember=remember)
|
||||
next_page = request.args.get("next")
|
||||
flash(f"Welcome back, {user.username}!", "success")
|
||||
return redirect(next_page or url_for("dashboard.index"))
|
||||
|
||||
flash("Invalid username or password.", "danger")
|
||||
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash("You have been logged out.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
258
app/routes/boards.py
Normal file
258
app/routes/boards.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Board management routes."""
|
||||
import json
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.board import Board
|
||||
from app.services.board_service import poll_board, register_webhook, set_relay
|
||||
from app.drivers.registry import registry
|
||||
from flask import current_app
|
||||
|
||||
boards_bp = Blueprint("boards", __name__)
|
||||
|
||||
|
||||
def _board_types():
|
||||
"""Dynamic list read from the driver registry — auto-updates when drivers are added."""
|
||||
return registry.choices()
|
||||
|
||||
|
||||
# ── list ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/")
|
||||
@login_required
|
||||
def list_boards():
|
||||
boards = Board.query.order_by(Board.name).all()
|
||||
return render_template("boards/list.html", boards=boards)
|
||||
|
||||
|
||||
# ── detail / quick controls ───────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>")
|
||||
@login_required
|
||||
def board_detail(board_id: int):
|
||||
board = db.get_or_404(Board, board_id)
|
||||
# Refresh states from device
|
||||
poll_board(current_app._get_current_object(), board_id)
|
||||
board = db.session.get(Board, board_id)
|
||||
return render_template("boards/detail.html", board=board)
|
||||
|
||||
|
||||
# ── add ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/add", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_board():
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
board_type = request.form.get("board_type", "olimex_esp32_c6_evb")
|
||||
host = request.form.get("host", "").strip()
|
||||
port = int(request.form.get("port", 80))
|
||||
num_relays = int(request.form.get("num_relays", 4))
|
||||
num_inputs = int(request.form.get("num_inputs", 4))
|
||||
|
||||
if not name or not host:
|
||||
flash("Name and host are required.", "danger")
|
||||
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
|
||||
|
||||
board = Board(
|
||||
name=name,
|
||||
board_type=board_type,
|
||||
host=host,
|
||||
port=port,
|
||||
num_relays=num_relays,
|
||||
num_inputs=num_inputs,
|
||||
)
|
||||
db.session.add(board)
|
||||
db.session.commit()
|
||||
|
||||
# Try to register webhook immediately
|
||||
server_url = current_app.config.get("SERVER_BASE_URL", "http://localhost:5000")
|
||||
register_webhook(board, server_url)
|
||||
|
||||
flash(f"Board '{name}' added successfully.", "success")
|
||||
return redirect(url_for("boards.board_detail", board_id=board.id))
|
||||
|
||||
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
|
||||
|
||||
|
||||
# ── edit ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_board(board_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = db.get_or_404(Board, board_id)
|
||||
|
||||
if request.method == "POST":
|
||||
board.name = request.form.get("name", board.name).strip()
|
||||
board.host = request.form.get("host", board.host).strip()
|
||||
board.port = int(request.form.get("port", board.port))
|
||||
board.board_type = request.form.get("board_type", board.board_type)
|
||||
board.num_relays = int(request.form.get("num_relays", board.num_relays))
|
||||
board.num_inputs = int(request.form.get("num_inputs", board.num_inputs))
|
||||
|
||||
# Update labels
|
||||
labels = {}
|
||||
for n in range(1, board.num_relays + 1):
|
||||
lbl = request.form.get(f"relay_{n}_label", "").strip()
|
||||
if lbl:
|
||||
labels[f"relay_{n}"] = lbl
|
||||
for n in range(1, board.num_inputs + 1):
|
||||
lbl = request.form.get(f"input_{n}_label", "").strip()
|
||||
if lbl:
|
||||
labels[f"input_{n}"] = lbl
|
||||
board.labels = labels
|
||||
|
||||
db.session.commit()
|
||||
flash("Board updated.", "success")
|
||||
return redirect(url_for("boards.board_detail", board_id=board.id))
|
||||
|
||||
return render_template("boards/edit.html", board=board, board_types=_board_types())
|
||||
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_board(board_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = db.get_or_404(Board, board_id)
|
||||
db.session.delete(board)
|
||||
db.session.commit()
|
||||
flash(f"Board '{board.name}' deleted.", "warning")
|
||||
return redirect(url_for("boards.list_boards"))
|
||||
|
||||
|
||||
# ── quick relay toggle ───────────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>/relay/<int:relay_num>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_relay_view(board_id: int, relay_num: int):
|
||||
from app import socketio
|
||||
board = db.get_or_404(Board, board_id)
|
||||
|
||||
# Always flip the local cached state first (optimistic update)
|
||||
states = board.relay_states
|
||||
current = states.get(f"relay_{relay_num}", False)
|
||||
new_state = not current
|
||||
states[f"relay_{relay_num}"] = new_state
|
||||
board.relay_states = states
|
||||
db.session.commit()
|
||||
|
||||
# Best-effort: send the command to the physical board using set_relay
|
||||
# (uses /relay/on or /relay/off — same endpoints as the detail page ON/OFF buttons)
|
||||
hw_ok = set_relay(board, relay_num, new_state)
|
||||
|
||||
# Push live update to all clients
|
||||
socketio.emit("board_update", {
|
||||
"board_id": board_id,
|
||||
"is_online": board.is_online,
|
||||
"relay_states": board.relay_states,
|
||||
"input_states": board.input_states,
|
||||
})
|
||||
|
||||
label = board.get_relay_label(relay_num)
|
||||
status_text = "ON" if new_state else "OFF"
|
||||
hw_warning = not hw_ok # True when board was unreachable
|
||||
|
||||
# JSON response for AJAX callers (dashboard)
|
||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||
return jsonify({
|
||||
"relay": relay_num,
|
||||
"state": new_state,
|
||||
"label": label,
|
||||
"hw_ok": not hw_warning,
|
||||
})
|
||||
|
||||
# HTML response for form-submit callers (detail page)
|
||||
if hw_warning:
|
||||
flash(f"{label}: {status_text} (board unreachable — local state updated)", "warning")
|
||||
else:
|
||||
flash(f"{label}: {status_text}", "info")
|
||||
return redirect(url_for("boards.board_detail", board_id=board_id))
|
||||
|
||||
|
||||
# ── quick relay set ───────────────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>/relay/<int:relay_num>/set", methods=["POST"])
|
||||
@login_required
|
||||
def set_relay_view(board_id: int, relay_num: int):
|
||||
from app import socketio
|
||||
board = db.get_or_404(Board, board_id)
|
||||
state = request.form.get("state", "off") == "on"
|
||||
|
||||
# Always update local state
|
||||
states = board.relay_states
|
||||
states[f"relay_{relay_num}"] = state
|
||||
board.relay_states = states
|
||||
db.session.commit()
|
||||
|
||||
# Best-effort send to hardware
|
||||
hw_ok = set_relay(board, relay_num, state)
|
||||
|
||||
socketio.emit("board_update", {
|
||||
"board_id": board_id,
|
||||
"is_online": board.is_online,
|
||||
"relay_states": board.relay_states,
|
||||
"input_states": board.input_states,
|
||||
})
|
||||
|
||||
label = board.get_relay_label(relay_num)
|
||||
status_text = "ON" if state else "OFF"
|
||||
if not hw_ok:
|
||||
flash(f"{label}: {status_text} (board unreachable — local state updated)", "warning")
|
||||
else:
|
||||
flash(f"{label}: {status_text}", "info")
|
||||
return redirect(url_for("boards.board_detail", board_id=board_id))
|
||||
|
||||
|
||||
# ── edit entity configuration ─────────────────────────────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>/entities", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_entities(board_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = db.get_or_404(Board, board_id)
|
||||
|
||||
if request.method == "POST":
|
||||
entities = {}
|
||||
for n in range(1, board.num_relays + 1):
|
||||
entities[f"relay_{n}"] = {
|
||||
"type": request.form.get(f"relay_{n}_type", "switch"),
|
||||
"name": request.form.get(f"relay_{n}_name", "").strip()[:20],
|
||||
"icon": request.form.get(f"relay_{n}_icon", "").strip(),
|
||||
}
|
||||
for n in range(1, board.num_inputs + 1):
|
||||
entities[f"input_{n}"] = {
|
||||
"type": request.form.get(f"input_{n}_type", "generic"),
|
||||
"name": request.form.get(f"input_{n}_name", "").strip()[:20],
|
||||
"icon": request.form.get(f"input_{n}_icon", "").strip(),
|
||||
}
|
||||
board.entities = entities
|
||||
db.session.commit()
|
||||
flash("Entity configuration saved.", "success")
|
||||
return redirect(url_for("boards.board_detail", board_id=board_id))
|
||||
|
||||
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES, ICON_PALETTE
|
||||
return render_template(
|
||||
"boards/edit_entities.html",
|
||||
board=board,
|
||||
relay_types=RELAY_ENTITY_TYPES,
|
||||
input_types=INPUT_ENTITY_TYPES,
|
||||
icon_palette=ICON_PALETTE,
|
||||
)
|
||||
|
||||
|
||||
# ── edit labels (legacy — redirects to edit_entities) ─────────────────────────
|
||||
|
||||
@boards_bp.route("/<int:board_id>/labels", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_labels(board_id: int):
|
||||
return redirect(url_for("boards.edit_entities", board_id=board_id))
|
||||
20
app/routes/dashboard.py
Normal file
20
app/routes/dashboard.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Dashboard route."""
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required
|
||||
|
||||
from app.models.board import Board
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
@dashboard_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
boards = Board.query.order_by(Board.name).all()
|
||||
workflows = Workflow.query.filter_by(is_enabled=True).count()
|
||||
return render_template(
|
||||
"dashboard/index.html",
|
||||
boards=boards,
|
||||
active_workflows=workflows,
|
||||
)
|
||||
98
app/routes/workflows.py
Normal file
98
app/routes/workflows.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Workflow management routes."""
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.board import Board
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
workflows_bp = Blueprint("workflows", __name__)
|
||||
|
||||
EVENTS = [("press", "Press (rising edge)"), ("release", "Release (falling)"), ("both", "Both")]
|
||||
ACTIONS = [("on", "Turn ON"), ("off", "Turn OFF"), ("toggle", "Toggle")]
|
||||
|
||||
|
||||
@workflows_bp.route("/")
|
||||
@login_required
|
||||
def list_workflows():
|
||||
workflows = Workflow.query.order_by(Workflow.name).all()
|
||||
return render_template("workflows/list.html", workflows=workflows)
|
||||
|
||||
|
||||
@workflows_bp.route("/add", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_workflow():
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
boards = Board.query.order_by(Board.name).all()
|
||||
|
||||
if request.method == "POST":
|
||||
wf = Workflow(
|
||||
name=request.form.get("name", "").strip(),
|
||||
trigger_board_id=int(request.form["trigger_board_id"]),
|
||||
trigger_input=int(request.form["trigger_input"]),
|
||||
trigger_event=request.form.get("trigger_event", "press"),
|
||||
action_board_id=int(request.form["action_board_id"]),
|
||||
action_relay=int(request.form["action_relay"]),
|
||||
action_type=request.form.get("action_type", "toggle"),
|
||||
is_enabled=True,
|
||||
)
|
||||
if not wf.name:
|
||||
flash("Name is required.", "danger")
|
||||
return render_template("workflows/edit.html", wf=None, boards=boards,
|
||||
events=EVENTS, actions=ACTIONS)
|
||||
db.session.add(wf)
|
||||
db.session.commit()
|
||||
flash(f"Workflow '{wf.name}' created.", "success")
|
||||
return redirect(url_for("workflows.list_workflows"))
|
||||
|
||||
return render_template("workflows/edit.html", wf=None, boards=boards,
|
||||
events=EVENTS, actions=ACTIONS)
|
||||
|
||||
|
||||
@workflows_bp.route("/<int:wf_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_workflow(wf_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
wf = db.get_or_404(Workflow, wf_id)
|
||||
boards = Board.query.order_by(Board.name).all()
|
||||
|
||||
if request.method == "POST":
|
||||
wf.name = request.form.get("name", wf.name).strip()
|
||||
wf.trigger_board_id = int(request.form.get("trigger_board_id", wf.trigger_board_id))
|
||||
wf.trigger_input = int(request.form.get("trigger_input", wf.trigger_input))
|
||||
wf.trigger_event = request.form.get("trigger_event", wf.trigger_event)
|
||||
wf.action_board_id = int(request.form.get("action_board_id", wf.action_board_id))
|
||||
wf.action_relay = int(request.form.get("action_relay", wf.action_relay))
|
||||
wf.action_type = request.form.get("action_type", wf.action_type)
|
||||
wf.is_enabled = "is_enabled" in request.form
|
||||
db.session.commit()
|
||||
flash("Workflow updated.", "success")
|
||||
return redirect(url_for("workflows.list_workflows"))
|
||||
|
||||
return render_template("workflows/edit.html", wf=wf, boards=boards,
|
||||
events=EVENTS, actions=ACTIONS)
|
||||
|
||||
|
||||
@workflows_bp.route("/<int:wf_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_workflow(wf_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
wf = db.get_or_404(Workflow, wf_id)
|
||||
db.session.delete(wf)
|
||||
db.session.commit()
|
||||
flash(f"Workflow '{wf.name}' deleted.", "warning")
|
||||
return redirect(url_for("workflows.list_workflows"))
|
||||
|
||||
|
||||
@workflows_bp.route("/<int:wf_id>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_workflow(wf_id: int):
|
||||
wf = db.get_or_404(Workflow, wf_id)
|
||||
wf.is_enabled = not wf.is_enabled
|
||||
db.session.commit()
|
||||
state = "enabled" if wf.is_enabled else "disabled"
|
||||
flash(f"Workflow '{wf.name}' {state}.", "info")
|
||||
return redirect(url_for("workflows.list_workflows"))
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services package."""
|
||||
112
app/services/board_service.py
Normal file
112
app/services/board_service.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Board service — thin dispatcher layer.
|
||||
|
||||
All board-type-specific logic lives in ``app/drivers/<board_type>/driver.py``.
|
||||
This module just resolves the right driver from the registry and calls it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from app import db, socketio
|
||||
from app.models.board import Board
|
||||
from app.drivers.registry import registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _driver(board: Board):
|
||||
"""Resolve the driver for a board, falling back gracefully."""
|
||||
drv = registry.get(board.board_type)
|
||||
if drv is None:
|
||||
logger.warning(
|
||||
"No driver found for board_type='%s' (board '%s'). "
|
||||
"Is the driver folder present in app/drivers/?",
|
||||
board.board_type, board.name,
|
||||
)
|
||||
return drv
|
||||
|
||||
|
||||
# ── relay control (public API used by routes) ─────────────────────────────────
|
||||
|
||||
def get_relay_status(board: Board, relay_num: int) -> bool | None:
|
||||
drv = _driver(board)
|
||||
return drv.get_relay_status(board, relay_num) if drv else None
|
||||
|
||||
|
||||
def set_relay(board: Board, relay_num: int, state: bool) -> bool:
|
||||
drv = _driver(board)
|
||||
return drv.set_relay(board, relay_num, state) if drv else False
|
||||
|
||||
|
||||
def toggle_relay(board: Board, relay_num: int) -> bool | None:
|
||||
drv = _driver(board)
|
||||
return drv.toggle_relay(board, relay_num) if drv else None
|
||||
|
||||
|
||||
# ── polling ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def poll_board(app, board_id: int) -> None:
|
||||
"""Fetch all I/O states for one board and persist to DB."""
|
||||
with app.app_context():
|
||||
board = db.session.get(Board, board_id)
|
||||
if board is None:
|
||||
return
|
||||
|
||||
drv = _driver(board)
|
||||
if drv is None:
|
||||
return
|
||||
|
||||
result = drv.poll(board)
|
||||
|
||||
# Inputs: hardware is source of truth — always overwrite from poll
|
||||
if result.get("input_states"):
|
||||
board.input_states = result["input_states"]
|
||||
|
||||
# Relays: server is source of truth — only sync from hardware when the
|
||||
# board was previously offline (re-sync after reconnect), not during
|
||||
# normal operation (to avoid overwriting optimistic state from commands)
|
||||
was_offline = not board.is_online
|
||||
if was_offline and result.get("relay_states"):
|
||||
board.relay_states = result["relay_states"]
|
||||
|
||||
board.is_online = result.get("is_online", False)
|
||||
if result.get("firmware_version"):
|
||||
board.firmware_version = result["firmware_version"]
|
||||
if board.is_online:
|
||||
board.last_seen = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
socketio.emit("board_update", {
|
||||
"board_id": board.id,
|
||||
"is_online": board.is_online,
|
||||
"relay_states": board.relay_states,
|
||||
"input_states": board.input_states,
|
||||
})
|
||||
|
||||
|
||||
def poll_all_boards(app) -> None:
|
||||
"""Poll every registered board in parallel."""
|
||||
with app.app_context():
|
||||
board_ids = [r[0] for r in db.session.query(Board.id).all()]
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=poll_board, args=(app, bid), daemon=True)
|
||||
for bid in board_ids
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=4)
|
||||
|
||||
|
||||
# ── webhook registration ──────────────────────────────────────────────────────
|
||||
|
||||
def register_webhook(board: Board, server_base_url: str) -> bool:
|
||||
drv = _driver(board)
|
||||
if drv is None:
|
||||
return False
|
||||
callback_url = f"{server_base_url}/api/webhook/{board.id}"
|
||||
return drv.register_webhook(board, callback_url)
|
||||
71
app/services/workflow_engine.py
Normal file
71
app/services/workflow_engine.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Workflow execution engine.
|
||||
|
||||
When a board input fires a webhook, this module evaluates all matching
|
||||
enabled workflows and triggers the configured relay action.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app import db, socketio
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.board import Board
|
||||
from app.services import board_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def process_input_event(board_id: int, input_num: int, new_state: bool) -> None:
|
||||
"""Called from the webhook route. Finds and fires matching workflows."""
|
||||
# Determine event type from state
|
||||
event = "press" if new_state else "release"
|
||||
|
||||
workflows = Workflow.query.filter_by(
|
||||
trigger_board_id=board_id,
|
||||
trigger_input=input_num,
|
||||
is_enabled=True,
|
||||
).all()
|
||||
|
||||
for wf in workflows:
|
||||
if wf.trigger_event not in (event, "both"):
|
||||
continue
|
||||
|
||||
target = db.session.get(Board, wf.action_board_id)
|
||||
if target is None:
|
||||
logger.warning("Workflow %d: target board %d not found", wf.id, wf.action_board_id)
|
||||
continue
|
||||
|
||||
success = False
|
||||
new_relay_state = None
|
||||
|
||||
if wf.action_type == "on":
|
||||
success = board_service.set_relay(target, wf.action_relay, True)
|
||||
new_relay_state = True
|
||||
elif wf.action_type == "off":
|
||||
success = board_service.set_relay(target, wf.action_relay, False)
|
||||
new_relay_state = False
|
||||
elif wf.action_type == "toggle":
|
||||
new_relay_state = board_service.toggle_relay(target, wf.action_relay)
|
||||
success = new_relay_state is not None
|
||||
|
||||
if success:
|
||||
wf.last_triggered = datetime.utcnow()
|
||||
# Update cached relay state in DB
|
||||
if new_relay_state is not None:
|
||||
states = target.relay_states
|
||||
states[f"relay_{wf.action_relay}"] = new_relay_state
|
||||
target.relay_states = states
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"Workflow '%s' fired: Board#%d.input%d=%s → Board#%d.relay%d [%s]",
|
||||
wf.name, board_id, input_num, new_state,
|
||||
target.id, wf.action_relay, wf.action_type,
|
||||
)
|
||||
# Push update so dashboard reflects new relay state instantly
|
||||
socketio.emit("board_update", {
|
||||
"board_id": target.id,
|
||||
"relay_states": target.relay_states,
|
||||
"is_online": target.is_online,
|
||||
})
|
||||
else:
|
||||
logger.warning("Workflow '%s' failed to reach board %s", wf.name, target.name)
|
||||
84
app/static/css/style.css
Normal file
84
app/static/css/style.css
Normal file
@@ -0,0 +1,84 @@
|
||||
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||||
#wrapper {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 240px;
|
||||
min-height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#page-content {
|
||||
min-height: 100vh;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
/* ── Sidebar nav pills ──────────────────────────────────────────────────── */
|
||||
#sidebar .nav-link {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#sidebar .nav-link:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
#sidebar .nav-link.active {
|
||||
background: var(--bs-primary);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bs-dark);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.board-card {
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.board-card:hover {
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Relay buttons ──────────────────────────────────────────────────────── */
|
||||
.relay-btn {
|
||||
min-width: 90px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── Login page ─────────────────────────────────────────────────────────── */
|
||||
body:not(:has(#wrapper)) {
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
/* ── Misc ───────────────────────────────────────────────────────────────── */
|
||||
.font-monospace {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.input-badge {
|
||||
font-size: 0.78rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
position: relative;
|
||||
}
|
||||
#wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
22
app/static/js/app.js
Normal file
22
app/static/js/app.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* app.js – Global Socket.IO connection & UI helpers
|
||||
*/
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// ── Bootstrap alert auto-dismiss after 4 s ──────────────────────────────
|
||||
document.querySelectorAll(".alert").forEach(function (el) {
|
||||
setTimeout(function () {
|
||||
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
|
||||
if (bsAlert) bsAlert.close();
|
||||
}, 4000);
|
||||
});
|
||||
|
||||
// ── Confirm-before-submit for data-confirm forms ─────────────────────────
|
||||
document.querySelectorAll("form[data-confirm]").forEach(function (form) {
|
||||
form.addEventListener("submit", function (e) {
|
||||
if (!confirm(form.getAttribute("data-confirm"))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
5
app/static/vendor/bootstrap-icons/font/bootstrap-icons.min.css
vendored
Normal file
5
app/static/vendor/bootstrap-icons/font/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/static/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff
vendored
Normal file
BIN
app/static/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff
vendored
Normal file
Binary file not shown.
BIN
app/static/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff2
vendored
Normal file
BIN
app/static/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff2
vendored
Normal file
Binary file not shown.
6
app/static/vendor/bootstrap/css/bootstrap.min.css
vendored
Normal file
6
app/static/vendor/bootstrap/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
app/static/vendor/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
7
app/static/vendor/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
app/static/vendor/socket.io/socket.io.min.js
generated
vendored
Normal file
7
app/static/vendor/socket.io/socket.io.min.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
49
app/templates/admin/user_form.html
Normal file
49
app/templates/admin/user_form.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if user %}Edit User{% else %}Add User{% endif %} – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.list_users') }}">Users</a></li>
|
||||
<li class="breadcrumb-item active">{% if user %}Edit: {{ user.username }}{% else %}Add User{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:500px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-person me-1"></i> {% if user %}Edit User{% else %}New User{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" value="{{ user.username if user else '' }}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password {% if user %}<span class="text-secondary small">(leave blank to keep)</span>{% endif %}</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
{% if not user %}required{% endif %}
|
||||
placeholder="{% if user %}New password{% else %}Password{% endif %}" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="user" {% if user and user.role == 'user' %}selected{% endif %}>User</option>
|
||||
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="is_active"
|
||||
{% if user.is_active %}checked{% endif %} />
|
||||
<label class="form-check-label" for="is_active">Active</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
|
||||
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
50
app/templates/admin/users.html
Normal file
50
app/templates/admin/users.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="fw-bold mb-0"><i class="bi bi-people me-2 text-primary"></i>Users</h2>
|
||||
<a href="{{ url_for('admin.add_user') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Username</th><th>Role</th><th>Active</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="fw-semibold">
|
||||
{{ u.username }}
|
||||
{% if u.id == current_user.id %}
|
||||
<span class="badge text-bg-info ms-1">you</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge {% if u.role == 'admin' %}text-bg-primary{% else %}text-bg-secondary{% endif %}">{{ u.role }}</span></td>
|
||||
<td>
|
||||
{% if u.is_active %}
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle-fill text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.edit_user', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary me-1">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if u.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_user', user_id=u.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Delete user {{ u.username }}?')">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
40
app/templates/auth/login.html
Normal file
40
app/templates/auth/login.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base_guest.html" %}
|
||||
{% block title %}Sign In – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow-lg border-0 rounded-4">
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-cpu-fill display-4 text-primary"></i>
|
||||
<h2 class="mt-2 fw-bold">Location Management</h2>
|
||||
<p class="text-secondary">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="admin" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
placeholder="••••••••" autocomplete="current-password" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" id="remember" name="remember" />
|
||||
<label class="form-check-label" for="remember">Remember me</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
87
app/templates/base.html
Normal file
87
app/templates/base.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Location Management{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.min.css') }}" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Sidebar navigation ───────────────────────────────────────────────── -->
|
||||
<div class="d-flex" id="wrapper">
|
||||
<nav id="sidebar" class="d-flex flex-column flex-shrink-0 p-3 bg-dark">
|
||||
<a href="{{ url_for('dashboard.index') }}" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
|
||||
<i class="bi bi-cpu-fill me-2 fs-4 text-primary"></i>
|
||||
<span class="fs-5 fw-semibold">Loc. Mgmt</span>
|
||||
</a>
|
||||
<hr class="text-secondary" />
|
||||
<ul class="nav nav-pills flex-column mb-auto">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="nav-link text-white {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
|
||||
<i class="bi bi-grid-1x2-fill me-2"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('boards.list_boards') }}"
|
||||
class="nav-link text-white {% if 'boards.' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-motherboard me-2"></i>Boards
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('workflows.list_workflows') }}"
|
||||
class="nav-link text-white {% if 'workflows.' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Workflows
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin() %}
|
||||
<li>
|
||||
<a href="{{ url_for('admin.list_users') }}"
|
||||
class="nav-link text-white {% if 'admin.' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-people me-2"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<hr class="text-secondary" />
|
||||
<div class="dropdown">
|
||||
<a href="#" class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
|
||||
data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-2 fs-5"></i>
|
||||
<strong>{{ current_user.username }}</strong>
|
||||
{% if current_user.is_admin() %}
|
||||
<span class="badge bg-primary ms-2">admin</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark text-small shadow">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i> Sign out
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Main content ─────────────────────────────────────────────────────── -->
|
||||
<div id="page-content" class="flex-grow-1 p-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
32
app/templates/base_guest.html
Normal file
32
app/templates/base_guest.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Location Management{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.min.css') }}" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
|
||||
</head>
|
||||
<body class="guest-layout">
|
||||
|
||||
<div class="d-flex justify-content-center align-items-center min-vh-100">
|
||||
<div style="width:100%;max-width:420px;padding:1rem">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
76
app/templates/boards/add.html
Normal file
76
app/templates/boards/add.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Add Board – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item active">Add Board</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:640px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-plus-circle me-1 text-primary"></i> Add New Board
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Local Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Server Room Board" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Board Type</label>
|
||||
<select name="board_type" class="form-select" id="board_type_select" onchange="updateDefaults(this.value)">
|
||||
{% for value, label in board_types %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-secondary" id="driver_desc"></div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label">IP Address / Hostname</label>
|
||||
<input type="text" name="host" class="form-control font-monospace" placeholder="192.168.1.100" required />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Relays</label>
|
||||
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="1" max="32" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Inputs</label>
|
||||
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
|
||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const driverDefaults = {
|
||||
{% for d in drivers %}
|
||||
"{{ d.DRIVER_ID }}": { relays: {{ d.DEFAULT_NUM_RELAYS }}, inputs: {{ d.DEFAULT_NUM_INPUTS }}, desc: "{{ d.DESCRIPTION }}" },
|
||||
{% endfor %}
|
||||
};
|
||||
function updateDefaults(type) {
|
||||
const d = driverDefaults[type] || { relays: 4, inputs: 4, desc: "" };
|
||||
document.getElementById("num_relays").value = d.relays;
|
||||
document.getElementById("num_inputs").value = d.inputs;
|
||||
document.getElementById("driver_desc").textContent = d.desc;
|
||||
}
|
||||
// init on page load
|
||||
updateDefaults(document.getElementById("board_type_select").value);
|
||||
</script>
|
||||
{% endblock %}
|
||||
205
app/templates/boards/detail.html
Normal file
205
app/templates/boards/detail.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ board.name }} – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item active">{{ board.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0">{{ board.name }}</h2>
|
||||
<span class="badge text-bg-secondary">{{ board.board_type }}</span>
|
||||
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} ms-1">
|
||||
{% if board.is_online %}Online{% else %}Offline{% endif %}
|
||||
</span>
|
||||
<span class="text-secondary small ms-2 font-monospace">{{ board.host }}:{{ board.port }}</span>
|
||||
</div>
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('boards.edit_entities', board_id=board.id) }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-palette me-1"></i> Configure Entities
|
||||
</a>
|
||||
<a href="{{ url_for('boards.edit_board', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- ── Relay controls ────────────────────────────────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 rounded-4 h-100">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-lightning-charge me-1 text-warning"></i> Relay Controls
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
{% set relay_key = "relay_" ~ n %}
|
||||
{% set is_on = board.relay_states.get(relay_key, false) %}
|
||||
{% set e = board.get_relay_entity(n) %}
|
||||
<div class="col-6">
|
||||
<div id="relay-card-{{ n }}" class="card rounded-3 border-{{ e.on_color if is_on else e.off_color }} h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
|
||||
<i id="relay-icon-{{ n }}" class="bi {{ e.icon }} mb-2 text-{{ e.on_color if is_on else e.off_color }}"
|
||||
style="font-size:2rem"></i>
|
||||
<div class="fs-6 fw-semibold mb-1">{{ e.name }}</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<span id="relay-badge-{{ n }}" class="badge text-bg-{{ e.on_color if is_on else e.off_color }}">
|
||||
{{ e.on_label if is_on else e.off_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<form method="POST" action="{{ url_for('boards.set_relay_view', board_id=board.id, relay_num=n) }}">
|
||||
<button id="relay-on-btn-{{ n }}" class="btn btn-sm btn-{{ e.on_color }}" {% if is_on %}disabled{% endif %}>{{ e.on_label }}</button>
|
||||
<input type="hidden" name="state" value="on" />
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}">
|
||||
<button class="btn btn-sm btn-outline-primary">Toggle</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('boards.set_relay_view', board_id=board.id, relay_num=n) }}">
|
||||
<button id="relay-off-btn-{{ n }}" class="btn btn-sm btn-{{ e.off_color }}" {% if not is_on %}disabled{% endif %}>{{ e.off_label }}</button>
|
||||
<input type="hidden" name="state" value="off" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Input states ──────────────────────────────────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 rounded-4 h-100">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-activity me-1 text-info"></i> Input States
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
{% set input_key = "input_" ~ n %}
|
||||
{% set raw_state = board.input_states.get(input_key, true) %}
|
||||
{% set is_active = not raw_state %}
|
||||
{% set e = board.get_input_entity(n) %}
|
||||
<div class="col-6">
|
||||
<div id="input-card-{{ n }}" class="card rounded-3 border-{{ e.active_color if is_active else e.idle_color }}">
|
||||
<div class="card-body text-center py-3">
|
||||
|
||||
<i id="input-icon-{{ n }}" class="bi {{ e.icon }} mb-2 text-{{ e.active_color if is_active else e.idle_color }}"
|
||||
style="font-size:2rem"></i>
|
||||
<div class="fs-6 fw-semibold mb-1">{{ e.name }}</div>
|
||||
|
||||
<span id="input-badge-{{ n }}" class="badge text-bg-{{ e.active_color if is_active else e.idle_color }}">
|
||||
{{ e.active_label if is_active else e.idle_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Board info ────────────────────────────────────────────────────────── -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 rounded-4">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-info-circle me-1"></i> Board Information
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Board ID</dt><dd class="col-sm-9 font-monospace">{{ board.id }}</dd>
|
||||
<dt class="col-sm-3">Type</dt><dd class="col-sm-9">{{ board.board_type }}</dd>
|
||||
<dt class="col-sm-3">Host</dt><dd class="col-sm-9 font-monospace">{{ board.host }}:{{ board.port }}</dd>
|
||||
<dt class="col-sm-3">Firmware</dt><dd class="col-sm-9">{{ board.firmware_version or '—' }}</dd>
|
||||
<dt class="col-sm-3">Added</dt><dd class="col-sm-9">{{ board.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
<dt class="col-sm-3">Last Seen</dt>
|
||||
<dd class="col-sm-9">{% if board.last_seen %}{{ board.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}Never{% endif %}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const socket = io();
|
||||
const boardId = {{ board.id }};
|
||||
|
||||
// ── Entity config embedded from server ──────────────────────────────────────
|
||||
const ENTITY_CONFIG = {
|
||||
relays: {
|
||||
{% for n in range(1, board.num_relays + 1) %}{% set e = board.get_relay_entity(n) %}
|
||||
{{ n }}: {icon:"{{ e.icon }}",onColor:"{{ e.on_color }}",offColor:"{{ e.off_color }}",onLabel:"{{ e.on_label }}",offLabel:"{{ e.off_label }}"},
|
||||
{% endfor %}
|
||||
},
|
||||
inputs: {
|
||||
{% for n in range(1, board.num_inputs + 1) %}{% set e = board.get_input_entity(n) %}
|
||||
{{ n }}: {icon:"{{ e.icon }}",activeColor:"{{ e.active_color }}",idleColor:"{{ e.idle_color }}",activeLabel:"{{ e.active_label }}",idleLabel:"{{ e.idle_label }}"},
|
||||
{% endfor %}
|
||||
}
|
||||
};
|
||||
|
||||
function swapBorderColor(el, color) {
|
||||
el.classList.forEach(c => {
|
||||
if (c.startsWith('border-') && c !== 'border-0' && c !== 'border-3') el.classList.remove(c);
|
||||
});
|
||||
el.classList.add('border-' + color);
|
||||
}
|
||||
|
||||
function applyRelayState(n, isOn) {
|
||||
const e = ENTITY_CONFIG.relays[n]; if (!e) return;
|
||||
const color = isOn ? e.onColor : e.offColor;
|
||||
const label = isOn ? e.onLabel : e.offLabel;
|
||||
const card = document.getElementById('relay-card-' + n);
|
||||
const icon = document.getElementById('relay-icon-' + n);
|
||||
const badge = document.getElementById('relay-badge-' + n);
|
||||
const onBtn = document.getElementById('relay-on-btn-' + n);
|
||||
const offBtn= document.getElementById('relay-off-btn-' + n);
|
||||
if (card) swapBorderColor(card, color);
|
||||
if (icon) { icon.className = `bi ${e.icon} mb-2 text-${color}`; icon.style.fontSize = '2rem'; }
|
||||
if (badge) { badge.className = 'badge text-bg-' + color; badge.textContent = label; }
|
||||
if (onBtn) onBtn.disabled = isOn;
|
||||
if (offBtn) offBtn.disabled = !isOn;
|
||||
}
|
||||
|
||||
function applyInputState(n, rawState) {
|
||||
const e = ENTITY_CONFIG.inputs[n]; if (!e) return;
|
||||
const isActive = !rawState; // NC contact: raw true = resting = idle
|
||||
const color = isActive ? e.activeColor : e.idleColor;
|
||||
const label = isActive ? e.activeLabel : e.idleLabel;
|
||||
const card = document.getElementById('input-card-' + n);
|
||||
const icon = document.getElementById('input-icon-' + n);
|
||||
const badge = document.getElementById('input-badge-' + n);
|
||||
if (card) swapBorderColor(card, color);
|
||||
if (icon) { icon.className = `bi ${e.icon} mb-2 text-${color}`; icon.style.fontSize = '2rem'; }
|
||||
if (badge) { badge.className = 'badge text-bg-' + color; badge.textContent = label; }
|
||||
}
|
||||
|
||||
socket.on("board_update", function(data) {
|
||||
if (data.board_id !== boardId) return;
|
||||
if (data.relay_states) {
|
||||
for (const [key, isOn] of Object.entries(data.relay_states)) {
|
||||
applyRelayState(parseInt(key.split('_')[1]), isOn);
|
||||
}
|
||||
}
|
||||
if (data.input_states) {
|
||||
for (const [key, rawState] of Object.entries(data.input_states)) {
|
||||
applyInputState(parseInt(key.split('_')[1]), rawState);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
86
app/templates/boards/edit.html
Normal file
86
app/templates/boards/edit.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit {{ board.name }} – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:700px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-pencil me-1"></i> Edit Board — {{ board.name }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<!-- Connection -->
|
||||
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Connection</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Local Name</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ board.name }}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Board Type</label>
|
||||
<select name="board_type" class="form-select">
|
||||
{% for value, label in board_types %}
|
||||
<option value="{{ value }}" {% if board.board_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-8">
|
||||
<label class="form-label">IP / Hostname</label>
|
||||
<input type="text" name="host" class="form-control font-monospace" value="{{ board.host }}" required />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="{{ board.port }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Relays</label>
|
||||
<input type="number" name="num_relays" class="form-control" value="{{ board.num_relays }}" min="1" max="32" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Inputs</label>
|
||||
<input type="number" name="num_inputs" class="form-control" value="{{ board.num_inputs }}" min="0" max="32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Relay Labels</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Relay {{ n }}</label>
|
||||
<input type="text" name="relay_{{ n }}_label" class="form-control form-control-sm"
|
||||
placeholder="Relay {{ n }}"
|
||||
value="{{ board.labels.get('relay_' ~ n, '') }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Input Labels</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Input {{ n }}</label>
|
||||
<input type="text" name="input_{{ n }}_label" class="form-control form-control-sm"
|
||||
placeholder="Input {{ n }}"
|
||||
value="{{ board.labels.get('input_' ~ n, '') }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
318
app/templates/boards/edit_entities.html
Normal file
318
app/templates/boards/edit_entities.html
Normal file
@@ -0,0 +1,318 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Configure Entities – {{ board.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Configure Entities</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 class="fw-bold mb-1">Configure Entities</h2>
|
||||
<p class="text-secondary mb-4">
|
||||
Set a type, custom name, and optional icon override for each relay and input.
|
||||
The type determines the icon, status colors, and state labels shown everywhere.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('boards.edit_entities', board_id=board.id) }}">
|
||||
|
||||
<!-- ── Relay entities ────────────────────────────────────────────────────── -->
|
||||
<h5 class="fw-semibold mb-3"><i class="bi bi-lightning-charge text-warning me-1"></i> Relay Outputs</h5>
|
||||
<div class="row g-3 mb-4">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
{% set e = board.get_relay_entity(n) %}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4 entity-card" data-entity="relay_{{ n }}" data-kind="relay">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Header: icon preview + number label -->
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
|
||||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||||
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-relay-{{ n }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-secondary small mb-1">Relay {{ n }}</div>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="relay_{{ n }}_name"
|
||||
value="{{ board.entities.get('relay_' ~ n, {}).get('name', '') }}"
|
||||
maxlength="20" placeholder="{{ e.name }}" />
|
||||
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div class="mb-3">
|
||||
<div class="small text-secondary mb-2">Entity type</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tkey, tdef in relay_types.items() %}
|
||||
<label class="entity-type-pill" title="{{ tdef.label }}"
|
||||
data-icon="{{ tdef.icon }}"
|
||||
data-target="relay_{{ n }}">
|
||||
<input type="radio" name="relay_{{ n }}_type" value="{{ tkey }}" hidden
|
||||
{% if e.type == tkey %}checked{% endif %} />
|
||||
<i class="bi {{ tdef.icon }}"></i>
|
||||
<span>{{ tdef.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom icon override -->
|
||||
<details class="mt-2">
|
||||
<summary class="text-secondary small" style="cursor:pointer">
|
||||
<i class="bi bi-palette me-1"></i> Custom icon override
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control icon-text-input"
|
||||
name="relay_{{ n }}_icon"
|
||||
value="{{ board.entities.get('relay_' ~ n, {}).get('icon', '') }}"
|
||||
placeholder="e.g. bi-lightbulb-fill"
|
||||
data-target="relay_{{ n }}" />
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
|
||||
data-target="relay_{{ n }}">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="icon-palette d-flex flex-wrap gap-1">
|
||||
{% for icon_cls, icon_name in icon_palette %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
|
||||
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="relay_{{ n }}">
|
||||
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Input entities ────────────────────────────────────────────────────── -->
|
||||
<h5 class="fw-semibold mb-3"><i class="bi bi-activity text-info me-1"></i> Digital Inputs</h5>
|
||||
<div class="row g-3 mb-4">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
{% set e = board.get_input_entity(n) %}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4 entity-card" data-entity="input_{{ n }}" data-kind="input">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Header: icon preview + number label -->
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
|
||||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||||
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-input-{{ n }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-secondary small mb-1">Input {{ n }}</div>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="input_{{ n }}_name"
|
||||
value="{{ board.entities.get('input_' ~ n, {}).get('name', '') }}"
|
||||
maxlength="20" placeholder="{{ e.name }}" />
|
||||
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div class="mb-3">
|
||||
<div class="small text-secondary mb-2">Entity type</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tkey, tdef in input_types.items() %}
|
||||
<label class="entity-type-pill" title="{{ tdef.label }}"
|
||||
data-icon="{{ tdef.icon }}"
|
||||
data-target="input_{{ n }}">
|
||||
<input type="radio" name="input_{{ n }}_type" value="{{ tkey }}" hidden
|
||||
{% if e.type == tkey %}checked{% endif %} />
|
||||
<i class="bi {{ tdef.icon }}"></i>
|
||||
<span>{{ tdef.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom icon override -->
|
||||
<details class="mt-2">
|
||||
<summary class="text-secondary small" style="cursor:pointer">
|
||||
<i class="bi bi-palette me-1"></i> Custom icon override
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control icon-text-input"
|
||||
name="input_{{ n }}_icon"
|
||||
value="{{ board.entities.get('input_' ~ n, {}).get('icon', '') }}"
|
||||
placeholder="e.g. bi-door-open-fill"
|
||||
data-target="input_{{ n }}" />
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
|
||||
data-target="input_{{ n }}">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="icon-palette d-flex flex-wrap gap-1">
|
||||
{% for icon_cls, icon_name in icon_palette %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
|
||||
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="input_{{ n }}">
|
||||
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ────────────────────────────────────────────────────────────── -->
|
||||
<div class="d-flex gap-2 mt-2 mb-5">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i> Save Configuration
|
||||
</button>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
/* ── Entity type pills ───────────────────────────────────────────── */
|
||||
.entity-type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
cursor: pointer;
|
||||
font-size: .8rem;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.entity-type-pill:hover {
|
||||
border-color: var(--bs-primary);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
.entity-type-pill:has(input:checked) {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-primary-bg-subtle, rgba(13,110,253,.15));
|
||||
color: var(--bs-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* ── Icon palette grid ───────────────────────────────────────────── */
|
||||
.icon-palette {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
background: var(--bs-secondary-bg);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
.icon-pick-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-pick-btn.active {
|
||||
background: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/* ── Live icon preview ───────────────────────────────────────────────────── */
|
||||
function resolvePreview(target) {
|
||||
const iconInput = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||||
const customIcon = iconInput ? iconInput.value.trim() : "";
|
||||
const preview = document.getElementById(`icon-preview-${target.replace("_", "-")}`);
|
||||
if (!preview) return;
|
||||
|
||||
if (customIcon) {
|
||||
// Remove all bi-* classes, add the custom one
|
||||
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
|
||||
const cls = customIcon.startsWith("bi-") ? customIcon : `bi-${customIcon}`;
|
||||
preview.classList.add(cls);
|
||||
} else {
|
||||
// Use the checked type's icon
|
||||
const checkedType = document.querySelector(`input[name="${target}_type"]:checked`);
|
||||
if (checkedType) {
|
||||
const pill = checkedType.closest(".entity-type-pill");
|
||||
const typeIcon = pill ? pill.getAttribute("data-icon") : null;
|
||||
if (typeIcon) {
|
||||
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
|
||||
preview.classList.add(typeIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Type pill selection ─────────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".entity-type-pill").forEach(pill => {
|
||||
pill.addEventListener("click", () => {
|
||||
const target = pill.getAttribute("data-target");
|
||||
// Small delay so the radio value has flipped
|
||||
requestAnimationFrame(() => resolvePreview(target));
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Custom icon text input ──────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".icon-text-input").forEach(inp => {
|
||||
inp.addEventListener("input", () => resolvePreview(inp.getAttribute("data-target")));
|
||||
});
|
||||
|
||||
/* ── Icon palette pick ───────────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".icon-pick-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const icon = btn.getAttribute("data-icon");
|
||||
const target = btn.getAttribute("data-target");
|
||||
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||||
if (inp) inp.value = icon;
|
||||
// Highlight selected palette button
|
||||
btn.closest(".icon-palette").querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
resolvePreview(target);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Clear custom icon ───────────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".icon-clear-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.getAttribute("data-target");
|
||||
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||||
if (inp) inp.value = "";
|
||||
// Deselect all palette buttons for this target
|
||||
const card = btn.closest(".card-body");
|
||||
if (card) card.querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
|
||||
resolvePreview(target);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Init: highlight pre-selected palette icons, run initial previews ────── */
|
||||
document.querySelectorAll(".icon-text-input").forEach(inp => {
|
||||
const val = inp.value.trim();
|
||||
const target = inp.getAttribute("data-target");
|
||||
if (val) {
|
||||
const match = inp.closest(".card-body").querySelector(`.icon-pick-btn[data-icon="${val}"]`);
|
||||
if (match) match.classList.add("active");
|
||||
}
|
||||
resolvePreview(target);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
77
app/templates/boards/edit_labels.html
Normal file
77
app/templates/boards/edit_labels.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit Labels – {{ board.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit Labels</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 class="fw-bold mb-1">Edit Labels</h2>
|
||||
<p class="text-secondary mb-4">Assign custom names to each relay and input (max 20 characters). Leave a field blank to use the default name.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('boards.edit_labels', board_id=board.id) }}">
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- ── Relay labels ───────────────────────────────────────────────────── -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-lightning-charge me-1 text-warning"></i> Relay Names
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-secondary small mb-1">Relay {{ n }}</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="relay_{{ n }}_label"
|
||||
value="{{ board.labels.get('relay_' ~ n, '') }}"
|
||||
maxlength="20"
|
||||
placeholder="Relay {{ n }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Input labels ───────────────────────────────────────────────────── -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-activity me-1 text-info"></i> Input Names
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-secondary small mb-1">Input {{ n }}</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="input_{{ n }}_label"
|
||||
value="{{ board.labels.get('input_' ~ n, '') }}"
|
||||
maxlength="20"
|
||||
placeholder="Input {{ n }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ────────────────────────────────────────────────────────── -->
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i> Save Labels
|
||||
</button>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
62
app/templates/boards/list.html
Normal file
62
app/templates/boards/list.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Boards – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="fw-bold mb-0"><i class="bi bi-motherboard me-2 text-primary"></i>Boards</h2>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add Board
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if boards %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Name</th><th>Type</th><th>Host</th><th>Relays</th><th>Inputs</th><th>Status</th><th>Last Seen</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in boards %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a></td>
|
||||
<td><span class="badge text-bg-secondary">{{ b.board_type }}</span></td>
|
||||
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
|
||||
<td>{{ b.num_relays }}</td>
|
||||
<td>{{ b.num_inputs }}</td>
|
||||
<td>
|
||||
<span class="badge {% if b.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %}">
|
||||
{% if b.is_online %}Online{% else %}Offline{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-secondary">
|
||||
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('boards.edit_board', board_id=b.id) }}" class="btn btn-sm btn-outline-secondary me-1"><i class="bi bi-pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('boards.delete_board', board_id=b.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Delete {{ b.name }}?')">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-secondary">
|
||||
<i class="bi bi-motherboard display-2"></i>
|
||||
<p class="mt-3">No boards yet.</p>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">Add Board</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
210
app/templates/dashboard/index.html
Normal file
210
app/templates/dashboard/index.html
Normal file
@@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="fw-bold mb-0"><i class="bi bi-grid-1x2-fill me-2 text-primary"></i>Dashboard</h2>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add Board
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── Summary cards ─────────────────────────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="card stat-card border-0 rounded-4 bg-primary bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-motherboard display-5 text-primary"></i>
|
||||
<div>
|
||||
<div class="display-6 fw-bold">{{ boards | length }}</div>
|
||||
<div class="text-secondary small">Total Boards</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card stat-card border-0 rounded-4 bg-success bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-wifi display-5 text-success"></i>
|
||||
<div>
|
||||
<div class="display-6 fw-bold">{{ boards | selectattr('is_online') | list | length }}</div>
|
||||
<div class="text-secondary small">Online</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card stat-card border-0 rounded-4 bg-warning bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-diagram-3 display-5 text-warning"></i>
|
||||
<div>
|
||||
<div class="display-6 fw-bold">{{ active_workflows }}</div>
|
||||
<div class="text-secondary small">Active Workflows</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Board grid ─────────────────────────────────────────────────────────── -->
|
||||
{% if boards %}
|
||||
<div class="row g-3" id="board-grid">
|
||||
{% for board in boards %}
|
||||
<div class="col-md-6 col-xl-4" id="board-card-{{ board.id }}">
|
||||
<div class="card board-card border-0 rounded-4 h-100 {% if board.is_online %}border-start border-3 border-success{% else %}border-start border-3 border-secondary{% endif %}">
|
||||
<div class="card-header bg-transparent d-flex justify-content-between align-items-center pt-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-semibold">{{ board.name }}</h5>
|
||||
<span class="badge text-bg-secondary small">{{ board.board_type }}</span>
|
||||
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} small ms-1" id="online-badge-{{ board.id }}">
|
||||
{% if board.is_online %}Online{% else %}Offline{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-secondary small mb-2"><i class="bi bi-hdd-network me-1"></i>{{ board.host }}:{{ board.port }}</p>
|
||||
|
||||
<!-- Quick relay controls -->
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
{% set relay_key = "relay_" ~ n %}
|
||||
{% set is_on = board.relay_states.get(relay_key, false) %}
|
||||
{% set e = board.get_relay_entity(n) %}
|
||||
<button type="button"
|
||||
class="btn btn-sm relay-btn {% if is_on %}btn-{{ e.on_color }}{% else %}btn-outline-secondary{% endif %}"
|
||||
data-relay="{{ n }}" data-board="{{ board.id }}"
|
||||
data-toggle-url="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}"
|
||||
title="{{ e.name }}"
|
||||
onclick="dashToggleRelay(this)">
|
||||
<i class="bi {{ e.icon }}"></i>
|
||||
{{ e.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Input states -->
|
||||
{% if board.num_inputs > 0 %}
|
||||
<div class="mt-2 d-flex flex-wrap gap-1">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
{% set input_key = "input_" ~ n %}
|
||||
{% set raw_state = board.input_states.get(input_key, true) %}
|
||||
{% set is_active = not raw_state %}
|
||||
{% set e = board.get_input_entity(n) %}
|
||||
<span class="badge {% if is_active %}text-bg-{{ e.active_color }}{% else %}text-bg-dark{% endif %} input-badge"
|
||||
data-input="{{ n }}" data-board="{{ board.id }}">
|
||||
<i class="bi {{ e.icon }}"></i> {{ e.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-transparent text-secondary small">
|
||||
{% if board.last_seen %}
|
||||
Last seen {{ board.last_seen.strftime('%H:%M:%S') }}
|
||||
{% else %}
|
||||
Never polled
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-secondary">
|
||||
<i class="bi bi-motherboard display-2"></i>
|
||||
<p class="mt-3 fs-5">No boards added yet.</p>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add your first board
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── Entity config for all boards (embedded from server) ─────────────────────
|
||||
const BOARD_ENTITIES = {
|
||||
{% for board in boards %}
|
||||
{{ board.id }}: {
|
||||
relays: {
|
||||
{% for n in range(1, board.num_relays + 1) %}{% set e = board.get_relay_entity(n) %}
|
||||
{{ n }}: {icon:"{{ e.icon }}",onColor:"{{ e.on_color }}",offColor:"{{ e.off_color }}"},
|
||||
{% endfor %}
|
||||
},
|
||||
inputs: {
|
||||
{% for n in range(1, board.num_inputs + 1) %}{% set e = board.get_input_entity(n) %}
|
||||
{{ n }}: {icon:"{{ e.icon }}",activeColor:"{{ e.active_color }}",idleColor:"{{ e.idle_color }}"},
|
||||
{% endfor %}
|
||||
}
|
||||
},
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
const socket = io();
|
||||
socket.on("board_update", function(data) {
|
||||
const bid = data.board_id;
|
||||
const ent = BOARD_ENTITIES[bid] || {relays:{}, inputs:{}};
|
||||
|
||||
// online badge
|
||||
const onlineBadge = document.getElementById("online-badge-" + bid);
|
||||
if (onlineBadge) {
|
||||
onlineBadge.textContent = data.is_online ? "Online" : "Offline";
|
||||
onlineBadge.className = "badge small ms-1 " + (data.is_online ? "text-bg-success" : "text-bg-secondary");
|
||||
}
|
||||
|
||||
// relay buttons
|
||||
if (data.relay_states) {
|
||||
for (const [key, isOn] of Object.entries(data.relay_states)) {
|
||||
const n = parseInt(key.split("_")[1]);
|
||||
const e = ent.relays[n] || {};
|
||||
document.querySelectorAll(`[data-relay="${n}"][data-board="${bid}"]`).forEach(btn => {
|
||||
btn.className = `btn btn-sm relay-btn ${isOn ? 'btn-' + (e.onColor||'success') : 'btn-outline-secondary'}`;
|
||||
const icon = btn.querySelector("i");
|
||||
if (icon && e.icon) icon.className = `bi ${e.icon}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// input badges — NC inversion: raw true = resting = idle
|
||||
if (data.input_states) {
|
||||
for (const [key, rawState] of Object.entries(data.input_states)) {
|
||||
const n = parseInt(key.split("_")[1]);
|
||||
const e = ent.inputs[n] || {};
|
||||
const isActive = !rawState;
|
||||
document.querySelectorAll(`[data-input="${n}"][data-board="${bid}"]`).forEach(span => {
|
||||
span.className = `badge input-badge text-bg-${isActive ? (e.activeColor||'info') : (e.idleColor||'dark')}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Dashboard relay toggle (AJAX — no page navigation) ───────────────────────
|
||||
function dashToggleRelay(btn) {
|
||||
const url = btn.getAttribute("data-toggle-url");
|
||||
btn.disabled = true;
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// SocketIO will update the button colour; just re-enable it
|
||||
btn.disabled = false;
|
||||
if (!data.hw_ok) {
|
||||
// Brief visual indicator that hardware was unreachable
|
||||
btn.title = "(board unreachable — local state updated)";
|
||||
setTimeout(() => btn.title = btn.getAttribute("data-label") || "", 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => { btn.disabled = false; });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
167
app/templates/workflows/edit.html
Normal file
167
app/templates/workflows/edit.html
Normal file
@@ -0,0 +1,167 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if wf %}Edit{% else %}Add{% endif %} Workflow – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('workflows.list_workflows') }}">Workflows</a></li>
|
||||
<li class="breadcrumb-item active">{% if wf %}Edit: {{ wf.name }}{% else %}New Workflow{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:700px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-diagram-3 me-1 text-warning"></i>
|
||||
{% if wf %}Edit Workflow{% else %}New Workflow{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Workflow Name</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ wf.name if wf else '' }}"
|
||||
placeholder="e.g. Door bell → Living room light" required />
|
||||
</div>
|
||||
|
||||
<!-- Trigger -->
|
||||
<div class="card rounded-3 border-info mb-4">
|
||||
<div class="card-header bg-info bg-opacity-10 fw-semibold">
|
||||
<i class="bi bi-arrow-right-circle me-1 text-info"></i> Trigger (Input Event)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Board</label>
|
||||
<select name="trigger_board_id" class="form-select" id="trigger_board" onchange="updateInputs(this, 'trigger_input')">
|
||||
{% for b in boards %}
|
||||
<option value="{{ b.id }}"
|
||||
data-inputs="{{ b.num_inputs }}"
|
||||
{% if wf and wf.trigger_board_id == b.id %}selected{% endif %}>
|
||||
{{ b.name }} ({{ b.host }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Input Number</label>
|
||||
<select name="trigger_input" class="form-select" id="trigger_input">
|
||||
{% if wf %}
|
||||
{% for n in range(1, wf.trigger_board.num_inputs + 1) %}
|
||||
<option value="{{ n }}" {% if wf.trigger_input == n %}selected{% endif %}>
|
||||
Input {{ n }}{% if wf.trigger_board.get_input_label(n) != 'Input ' ~ n %} — {{ wf.trigger_board.get_input_label(n) }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% elif boards %}
|
||||
{% for n in range(1, boards[0].num_inputs + 1) %}
|
||||
<option value="{{ n }}">Input {{ n }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Event</label>
|
||||
<select name="trigger_event" class="form-select">
|
||||
{% for value, label in events %}
|
||||
<option value="{{ value }}" {% if wf and wf.trigger_event == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="card rounded-3 border-warning mb-4">
|
||||
<div class="card-header bg-warning bg-opacity-10 fw-semibold">
|
||||
<i class="bi bi-lightning-charge me-1 text-warning"></i> Action (Relay Control)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Board</label>
|
||||
<select name="action_board_id" class="form-select" id="action_board" onchange="updateRelays(this, 'action_relay')">
|
||||
{% for b in boards %}
|
||||
<option value="{{ b.id }}"
|
||||
data-relays="{{ b.num_relays }}"
|
||||
{% if wf and wf.action_board_id == b.id %}selected{% endif %}>
|
||||
{{ b.name }} ({{ b.host }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Relay Number</label>
|
||||
<select name="action_relay" class="form-select" id="action_relay">
|
||||
{% if wf %}
|
||||
{% for n in range(1, wf.action_board.num_relays + 1) %}
|
||||
<option value="{{ n }}" {% if wf.action_relay == n %}selected{% endif %}>
|
||||
Relay {{ n }}{% if wf.action_board.get_relay_label(n) != 'Relay ' ~ n %} — {{ wf.action_board.get_relay_label(n) }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% elif boards %}
|
||||
{% for n in range(1, boards[0].num_relays + 1) %}
|
||||
<option value="{{ n }}">Relay {{ n }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Action</label>
|
||||
<select name="action_type" class="form-select">
|
||||
{% for value, label in actions %}
|
||||
<option value="{{ value }}" {% if wf and wf.action_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if wf %}
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" name="is_enabled" id="is_enabled"
|
||||
{% if wf.is_enabled %}checked{% endif %} />
|
||||
<label class="form-check-label" for="is_enabled">Enabled</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
|
||||
<a href="{{ url_for('workflows.list_workflows') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const boardsData = {{ boards | tojson | safe }};
|
||||
// Build lookup: id -> board object
|
||||
const boardMap = {};
|
||||
{{ "{% for b in boards %}" }}
|
||||
boardMap[{{ "{{ b.id }}" }}] = {
|
||||
relays: {{ "{{ b.num_relays }}" }},
|
||||
inputs: {{ "{{ b.num_inputs }}" }},
|
||||
};
|
||||
{{ "{% endfor %}" }}
|
||||
|
||||
function updateInputs(selectEl, targetId) {
|
||||
const opt = selectEl.options[selectEl.selectedIndex];
|
||||
const n = parseInt(opt.getAttribute("data-inputs")) || 4;
|
||||
const target = document.getElementById(targetId);
|
||||
target.innerHTML = "";
|
||||
for (let i = 1; i <= n; i++) {
|
||||
target.innerHTML += `<option value="${i}">Input ${i}</option>`;
|
||||
}
|
||||
}
|
||||
function updateRelays(selectEl, targetId) {
|
||||
const opt = selectEl.options[selectEl.selectedIndex];
|
||||
const n = parseInt(opt.getAttribute("data-relays")) || 4;
|
||||
const target = document.getElementById(targetId);
|
||||
target.innerHTML = "";
|
||||
for (let i = 1; i <= n; i++) {
|
||||
target.innerHTML += `<option value="${i}">Relay ${i}</option>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
80
app/templates/workflows/list.html
Normal file
80
app/templates/workflows/list.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Workflows – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="fw-bold mb-0"><i class="bi bi-diagram-3 me-2 text-warning"></i>Workflows</h2>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('workflows.add_workflow') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add Workflow
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if workflows %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Trigger</th>
|
||||
<th>Action</th>
|
||||
<th>Status</th>
|
||||
<th>Last Triggered</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for wf in workflows %}
|
||||
<tr class="{% if not wf.is_enabled %}text-secondary{% endif %}">
|
||||
<td class="fw-semibold">{{ wf.name }}</td>
|
||||
<td>
|
||||
<span class="badge text-bg-secondary">{{ wf.trigger_board.name }}</span>
|
||||
Input {{ wf.trigger_input }}
|
||||
<span class="badge text-bg-info">{{ wf.trigger_event }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge text-bg-secondary">{{ wf.action_board.name }}</span>
|
||||
Relay {{ wf.action_relay }}
|
||||
<span class="badge text-bg-warning">{{ wf.action_type }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if wf.is_enabled %}text-bg-success{% else %}text-bg-secondary{% endif %}">
|
||||
{% if wf.is_enabled %}Enabled{% else %}Disabled{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-secondary">
|
||||
{% if wf.last_triggered %}{{ wf.last_triggered.strftime('%Y-%m-%d %H:%M') }}{% else %}Never{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('workflows.edit_workflow', wf_id=wf.id) }}" class="btn btn-sm btn-outline-secondary me-1">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('workflows.toggle_workflow', wf_id=wf.id) }}" class="d-inline">
|
||||
<button class="btn btn-sm {% if wf.is_enabled %}btn-outline-warning{% else %}btn-outline-success{% endif %} me-1"
|
||||
title="{% if wf.is_enabled %}Disable{% else %}Enable{% endif %}">
|
||||
<i class="bi bi-{% if wf.is_enabled %}pause{% else %}play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('workflows.delete_workflow', wf_id=wf.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Delete workflow {{ wf.name }}?')">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-secondary">
|
||||
<i class="bi bi-diagram-3 display-2"></i>
|
||||
<p class="mt-3">No workflows yet.</p>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('workflows.add_workflow') }}" class="btn btn-primary">Create Workflow</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
35
config.py
Normal file
35
config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Application configuration."""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production")
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'location_mgmt.db')}"
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
# How often (seconds) the board poller updates relay states in the background
|
||||
BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 10))
|
||||
# Base URL this server is reachable at (boards will POST webhooks here)
|
||||
SERVER_BASE_URL = os.environ.get("SERVER_BASE_URL", "http://localhost:5000")
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
|
||||
|
||||
config_map = {
|
||||
"development": DevelopmentConfig,
|
||||
"production": ProductionConfig,
|
||||
"default": DevelopmentConfig,
|
||||
}
|
||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
Flask==3.0.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
Flask-Migrate==4.0.7
|
||||
Flask-SocketIO==5.3.6
|
||||
Werkzeug==3.0.3
|
||||
requests==2.32.3
|
||||
simple-websocket==1.1.0
|
||||
python-dotenv==1.0.1
|
||||
WTForms==3.1.2
|
||||
SQLAlchemy==2.0.31
|
||||
greenlet>=3.1.1
|
||||
41
run.py
Normal file
41
run.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Application entry point.
|
||||
|
||||
Run with:
|
||||
python run.py
|
||||
|
||||
Or for production:
|
||||
gunicorn -w 1 -k eventlet "run:create_socketio_app()"
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app import create_app, socketio
|
||||
from app.services.board_service import poll_all_boards
|
||||
|
||||
app = create_app(os.environ.get("FLASK_ENV", "development"))
|
||||
|
||||
|
||||
def _background_poller():
|
||||
"""Poll all boards in a loop every BOARD_POLL_INTERVAL seconds."""
|
||||
interval = app.config.get("BOARD_POLL_INTERVAL", 10)
|
||||
while True:
|
||||
try:
|
||||
poll_all_boards(app)
|
||||
except Exception as exc:
|
||||
app.logger.warning("Poller error: %s", exc)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def create_socketio_app():
|
||||
"""WSGI callable for gunicorn / production."""
|
||||
return socketio.middleware(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Start background board poller
|
||||
t = threading.Thread(target=_background_poller, daemon=True)
|
||||
t.start()
|
||||
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
socketio.run(app, host="0.0.0.0", port=port, debug=app.debug, allow_unsafe_werkzeug=True)
|
||||
Reference in New Issue
Block a user