diff --git a/.gitignore b/.gitignore index d6f9c50..357c7c0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.db .pytest_cache/ +.env diff --git a/app/core/database.py b/app/core/database.py index a206353..414a287 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,9 +1,13 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import create_engine DATABASE_URL = "sqlite+aiosqlite:///./ignis.db" +SYNC_DATABASE_URL = "sqlite:///./ignis.db" engine = create_async_engine(DATABASE_URL, echo=False) +sync_engine = create_engine(SYNC_DATABASE_URL) + async_session = async_sessionmaker(engine, expire_on_commit=False) diff --git a/app/core/scheduler.py b/app/core/scheduler.py new file mode 100644 index 0000000..1846f9b --- /dev/null +++ b/app/core/scheduler.py @@ -0,0 +1,31 @@ +import os +import logging +import pytz +from datetime import datetime +from dotenv import load_dotenv +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from app.core.database import sync_engine +from app.drivers.wiz import WizDriver + +load_dotenv() +logger = logging.getLogger(__name__) + +TZ_NAME = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk") +app_tz = pytz.timezone(TZ_NAME) + +jobstores = {"default": SQLAlchemyJobStore(engine=sync_engine)} +scheduler = AsyncIOScheduler(jobstores=jobstores, timezone=app_tz) + + +async def execute_lamp_command(ip: str, params: dict): + """Выполнение команды по расписанию""" + driver = WizDriver() + await driver.set_pilot(ip, params) + logger.info(f"⏰ Сработало расписание для {ip}: {params}") + + +async def start_scheduler(): + if not scheduler.running: + scheduler.start() + logger.info(f"🚀 Планировщик запущен. Таймзона: {TZ_NAME}") diff --git a/app/models/schedule.py b/app/models/schedule.py new file mode 100644 index 0000000..e5730b6 --- /dev/null +++ b/app/models/schedule.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, JSON +from app.core.database import Base + + +class ScheduleTask(Base): + __tablename__ = "schedules" + + id = Column(Integer, primary_key=True, index=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False) + task_type = Column(String) # 'once', 'daily', 'cron' + action_params = Column(JSON) # {'state': True, 'dimming': 50} + is_active = Column(Boolean, default=True) + job_id = Column(String, unique=True) # ID задачи в APScheduler diff --git a/main.py b/main.py index 747a6e1..89b5eaa 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import asyncio import logging from contextlib import asynccontextmanager from typing import Optional, List +from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles @@ -9,9 +10,11 @@ from sqlalchemy import select from app.core.discovery import DiscoveryService from app.core.state import state_manager +from app.core.scheduler import start_scheduler, scheduler, execute_lamp_command from app.drivers.wiz import WizDriver from app.core.database import init_db, async_session from app.models.device import GroupModel, DeviceModel, GroupCreateSchema +from app.models.schedule import ScheduleTask logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" @@ -29,6 +32,8 @@ async def lifespan(app: FastAPI): await init_db() logger.info("🗄️ База данных инициализирована") + await start_scheduler() + # 2. Загрузка групп из БД в память async with async_session() as session: result = await session.execute(select(GroupModel)) @@ -223,6 +228,49 @@ async def blink_device(device_id: str): return {"status": "blink_sent"} +@app.post("/schedules/once") +async def add_once_task(device_id: str, minutes: int, state: bool): + device = state_manager.devices.get(device_id) + if not device: + raise HTTPException(status_code=404, detail="Лампа не найдена") + + # Получаем TZ из самого шедулера, чтобы они были синхронны + run_time = datetime.now(scheduler.timezone) + timedelta(minutes=minutes) + + job = scheduler.add_job( + execute_lamp_command, + "date", + run_date=run_time, + args=[device.ip, {"state": state}], + ) + + return {"status": "scheduled", "job_id": job.id, "run_at": run_time.isoformat()} + + +@app.get("/schedules/active") +async def get_active_jobs(): + jobs = [] + for job in scheduler.get_jobs(): + jobs.append( + { + "id": job.id, + "next_run": job.next_run_time, + "func": job.func_ref, + "args": str(job.args), + } + ) + return {"active_jobs": jobs} + + +@app.delete("/schedules/{job_id}") +async def cancel_task(job_id: str): + try: + scheduler.remove_job(job_id) + return {"status": "deleted"} + except: + raise HTTPException(status_code=404, detail="Задача не найдена") + + # --- МОНТИРОВАНИЕ СТАТИКИ (ДОЛЖНО БЫТЬ ПОСЛЕ ВСЕХ API МАРШРУТОВ) --- app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/requirements.txt b/requirements.txt index d860b7c..fffb605 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ pydantic==2.5.3 httpx==0.26.0 sqlalchemy==2.0.25 aiosqlite==0.19.0 +apscheduler==3.10.4 +python-dotenv==1.0.0 +pytz