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__/ __pycache__/
*.db *.db
.pytest_cache/ .pytest_cache/
.env

View File

@@ -1,9 +1,13 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import create_engine
DATABASE_URL = "sqlite+aiosqlite:///./ignis.db" DATABASE_URL = "sqlite+aiosqlite:///./ignis.db"
SYNC_DATABASE_URL = "sqlite:///./ignis.db"
engine = create_async_engine(DATABASE_URL, echo=False) engine = create_async_engine(DATABASE_URL, echo=False)
sync_engine = create_engine(SYNC_DATABASE_URL)
async_session = async_sessionmaker(engine, expire_on_commit=False) 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 import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -9,9 +10,11 @@ from sqlalchemy import select
from app.core.discovery import DiscoveryService from app.core.discovery import DiscoveryService
from app.core.state import state_manager 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.drivers.wiz import WizDriver
from app.core.database import init_db, async_session from app.core.database import init_db, async_session
from app.models.device import GroupModel, DeviceModel, GroupCreateSchema from app.models.device import GroupModel, DeviceModel, GroupCreateSchema
from app.models.schedule import ScheduleTask
logging.basicConfig( logging.basicConfig(
level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s"
@@ -29,6 +32,8 @@ async def lifespan(app: FastAPI):
await init_db() await init_db()
logger.info("🗄️ База данных инициализирована") logger.info("🗄️ База данных инициализирована")
await start_scheduler()
# 2. Загрузка групп из БД в память # 2. Загрузка групп из БД в память
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(GroupModel)) result = await session.execute(select(GroupModel))
@@ -223,6 +228,49 @@ async def blink_device(device_id: str):
return {"status": "blink_sent"} 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 МАРШРУТОВ) --- # --- МОНТИРОВАНИЕ СТАТИКИ (ДОЛЖНО БЫТЬ ПОСЛЕ ВСЕХ API МАРШРУТОВ) ---
app.mount("/", StaticFiles(directory="static", html=True), name="static") app.mount("/", StaticFiles(directory="static", html=True), name="static")

View File

@@ -4,3 +4,6 @@ pydantic==2.5.3
httpx==0.26.0 httpx==0.26.0
sqlalchemy==2.0.25 sqlalchemy==2.0.25
aiosqlite==0.19.0 aiosqlite==0.19.0
apscheduler==3.10.4
python-dotenv==1.0.0
pytz