Initial commit: Location Management Flask app

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

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

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

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

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

View File

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

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

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

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

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