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