Scheduler (resolves #1)

This commit is contained in:
Артём Кокос
2026-02-18 22:47:57 +07:00
parent 298dcbc277
commit 7d30afe9a3
6 changed files with 100 additions and 0 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
__pycache__/
*.db
.pytest_cache/
.env

View File

@@ -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)

31
app/core/scheduler.py Normal file
View File

@@ -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}")

13
app/models/schedule.py Normal file
View File

@@ -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

48
main.py
View File

@@ -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")

View File

@@ -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