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