Initial commit: Location Management Flask app
This commit is contained in:
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}]>"
|
||||
)
|
||||
Reference in New Issue
Block a user