"""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 from app.routes.sonoff import sonoff_bp from app.routes.tuya import tuya_bp from app.routes.layouts import layouts_bp from app.routes.devices import devices_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") app.register_blueprint(sonoff_bp) app.register_blueprint(tuya_bp) app.register_blueprint(layouts_bp, url_prefix="/layouts") app.register_blueprint(devices_bp, url_prefix="/devices") # ── 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()]) # ── Jinja filters ───────────────────────────────────────────────────────── from app.drivers.tuya_cloud.driver import format_dp_value as _tuya_fmt @app.template_filter("tuya_dp") def _tuya_dp_filter(value, dp_code: str, status=None): """Format a raw Tuya DP value for display (temperature scaling, units…).""" return _tuya_fmt(dp_code, value, status) # ── create tables & seed admin on first run ─────────────────────────────── with app.app_context(): # Import all models so their tables are registered before create_all from app.models import board, user, workflow # noqa: F401 from app.models import sonoff_device # noqa: F401 from app.models import tuya_device # noqa: F401 from app.models import layout # noqa: F401 from app.models import device # noqa: F401 db.create_all() _seed_admin(app) _add_entities_column(app) _add_config_json_column(app) _migrate_board_types(app) _add_device_hardware_id_column(app) _add_device_app_id_column(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 _add_config_json_column(app: Flask) -> None: """Add config_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 config_json TEXT DEFAULT '{}'")) db.session.commit() app.logger.info("Added config_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)") def _add_device_app_id_column(app: Flask) -> None: """Add app_id column to devices table and backfill existing rows.""" from sqlalchemy import text with app.app_context(): try: db.session.execute(text("ALTER TABLE devices ADD COLUMN app_id TEXT")) db.session.commit() app.logger.info("Added app_id column to devices table") except Exception: db.session.rollback() # Column already exists — safe to ignore # Backfill any rows that have no app_id yet from app.models.device import Device unset = Device.query.filter(Device.app_id.is_(None)).all() for d in unset: d.app_id = Device.generate_app_id(d.name, exclude_id=d.id) if unset: db.session.commit() app.logger.info("Backfilled app_id for %d device(s)", len(unset)) def _add_device_hardware_id_column(app: Flask) -> None: """Add hardware_device_id column and backfill from existing entity_num bindings.""" from sqlalchemy import text with app.app_context(): try: db.session.execute(text("ALTER TABLE devices ADD COLUMN hardware_device_id TEXT")) db.session.commit() app.logger.info("Added hardware_device_id column to devices table") except Exception: db.session.rollback() # Column already exists — safe to ignore # Backfill: resolve entity_num → actual hardware device_id string from app.models.device import Device from app.models.sonoff_device import SonoffDevice from app.models.tuya_device import TuyaDevice unset = Device.query.filter( Device.hardware_device_id.is_(None), Device.entity_num.isnot(None), Device.entity_type.in_(["sonoff", "tuya"]), ).all() for d in unset: hw_id = None sd_or_td_id = d.entity_num // 100 if d.entity_type == "sonoff": obj = db.session.get(SonoffDevice, sd_or_td_id) if obj: hw_id = obj.device_id elif d.entity_type == "tuya": obj = db.session.get(TuyaDevice, sd_or_td_id) if obj: hw_id = obj.device_id if hw_id: d.hardware_device_id = hw_id if unset: db.session.commit() app.logger.info("Backfilled hardware_device_id for %d device(s)", len(unset))