Scheduler (resolves #1)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
__pycache__/
|
||||
*.db
|
||||
.pytest_cache/
|
||||
.env
|
||||
|
||||
@@ -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
31
app/core/scheduler.py
Normal 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
13
app/models/schedule.py
Normal 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
48
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user