diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..a206353 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = "sqlite+aiosqlite:///./ignis.db" + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def init_db(): + async with engine.begin() as conn: + # Создает таблицы, если их еще нет + await conn.run_sync(Base.metadata.create_all) diff --git a/app/core/state.py b/app/core/state.py index eb9f9f5..9840d3e 100644 --- a/app/core/state.py +++ b/app/core/state.py @@ -1,25 +1,25 @@ import logging from typing import Dict, List, Optional -from app.models.device import Device, Group +from app.models.device import DeviceSchema, GroupModel logger = logging.getLogger(__name__) class StateManager: def __init__(self): - # Храним устройства по MAC (уникальный ID) - self.devices: Dict[str, Device] = {} - # Группы (люстры) по их ID - self.groups: Dict[str, Group] = {} + # Храним устройства как Pydantic объекты + self.devices: Dict[str, DeviceSchema] = {} + # Группы как модели SQLAlchemy + self.groups: Dict[str, GroupModel] = {} def update_device(self, device_data: dict): """Обновляет или добавляет устройство в состояние.""" + mac = device_data["mac"] - device = Device( - id=mac, - ip=device_data["ip"], - name=f"WiZ {mac[-4:]}", # Временное имя - room="Default", + + # Используем DeviceSchema вместо Device + device = DeviceSchema( + id=mac, ip=device_data["ip"], name=f"WiZ {mac[-4:]}", room="Default" ) self.devices[mac] = device @@ -29,10 +29,10 @@ class StateManager: if not group: return [] + # Извлекаем IP по MAC-адресам, которые хранятся в группе return [ self.devices[d_id].ip for d_id in group.device_ids if d_id in self.devices ] -# Создаем синглтон для использования во всем приложении state_manager = StateManager() diff --git a/app/models/device.py b/app/models/device.py index 39b79c2..fa272b8 100644 --- a/app/models/device.py +++ b/app/models/device.py @@ -1,15 +1,44 @@ +from sqlalchemy import Column, String, JSON, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base from pydantic import BaseModel from typing import List, Optional -class Device(BaseModel): - id: str # MAC-адрес или UUID +# --- Таблицы в БД --- + + +class DeviceModel(Base): + __tablename__ = "devices" + + id: Mapped[str] = mapped_column(String, primary_key=True) # MAC + ip: Mapped[str] = mapped_column(String) + name: Mapped[str] = mapped_column(String) + room: Mapped[str] = mapped_column(String, default="Default") + + +class GroupModel(Base): + __tablename__ = "groups" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String) + device_ids: Mapped[list] = mapped_column(JSON) # Храним список MAC-адресов как JSON + + +# --- Pydantic модели для API (оставляем для валидации) --- + + +class DeviceSchema(BaseModel): + id: str ip: str name: str room: str + class Config: + from_attributes = True -class Group(BaseModel): + +class GroupCreateSchema(BaseModel): id: str name: str - device_ids: List[str] # Список ID устройств, входящих в люстру + macs: List[str] diff --git a/main.py b/main.py index 36523a0..ff7829e 100644 --- a/main.py +++ b/main.py @@ -4,10 +4,13 @@ from contextlib import asynccontextmanager from typing import Optional, List from fastapi import FastAPI, HTTPException +from sqlalchemy import select from app.core.discovery import DiscoveryService from app.core.state import state_manager from app.drivers.wiz import WizDriver +from app.core.database import init_db, async_session +from app.models.device import GroupModel, DeviceModel, GroupCreateSchema logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" @@ -20,11 +23,21 @@ wiz = WizDriver() @asynccontextmanager async def lifespan(app: FastAPI): - """ - Жизненный цикл приложения: выполняется при старте и остановке. - """ + """Жизненный цикл приложения.""" + # 1. Инициализация БД (создание таблиц) + await init_db() + logger.info("🗄️ База данных инициализирована") - # Задача для периодического сканирования сети + # 2. Загрузка групп из БД в память + async with async_session() as session: + result = await session.execute(select(GroupModel)) + groups = result.scalars().all() + for g in groups: + # Превращаем модель БД в формат стейта (если нужно) + state_manager.groups[g.id] = g + logger.info(f"📂 Загружена группа: {g.name} (MACs: {g.device_ids})") + + # 3. Задача для периодического сканирования сети async def periodic_discovery(): logger.info("🚀 Запущена фоновая служба Discovery") @@ -36,43 +49,72 @@ async def lifespan(app: FastAPI): state_manager.update_device(dev_data) logger.info( - f"📡 Сеть просканирована. Устройств в памяти: {len(state_manager.devices)}" + f"📡 Сеть просканирована. Устройств онлайн: {len(state_manager.devices)}" ) except Exception as e: logger.error(f"❌ Ошибка в цикле Discovery: {e}") - - # Интервал сканирования (например, раз в 10 минут) await asyncio.sleep(600) discovery_task = asyncio.create_task(periodic_discovery()) yield - # Логика при завершении работы сервера discovery_task.cancel() logger.info("🛑 Фоновая служба Discovery остановлена") -app = FastAPI( - title="Ignis Core API", - description="Ядро системы умного дома для управления лампами WiZ/Gauss", - version="1.0.0", - lifespan=lifespan, -) +app = FastAPI(title="Ignis Core API", lifespan=lifespan) - -@app.get("/health") -async def health(): - """Проверка работоспособности сервиса.""" - return {"status": "online", "devices_discovered": len(state_manager.devices)} +# --- Эндпоинты Устройств --- @app.get("/devices") async def get_all_devices(): - """Возвращает список всех обнаруженных устройств из стейта.""" + """Список всех обнаруженных ламп.""" return state_manager.devices +@app.post("/control/device/{device_id}") +async def control_device(device_id: str, state: bool, brightness: Optional[int] = None): + """Прямое управление лампой по MAC.""" + device = state_manager.devices.get(device_id) + if not device: + raise HTTPException(status_code=404, detail="Лампа не в сети") + + params = {"state": state} + if brightness: + params["dimming"] = brightness + + result = await wiz.set_pilot(device.ip, params) + return {"device_id": device_id, "result": result} + + +# --- Эндпоинты Групп --- + + +@app.get("/groups") +async def get_groups(): + """Список всех созданных люстр/групп.""" + return state_manager.groups + + +@app.post("/groups") +async def create_group(data: GroupCreateSchema): + async with async_session() as session: + existing = await session.get(GroupModel, data.id) + if existing: + raise HTTPException(status_code=400, detail="Группа с таким ID уже есть") + + # Берем данные из схемы + new_group = GroupModel(id=data.id, name=data.name, device_ids=data.macs) + session.add(new_group) + await session.commit() + + state_manager.groups[data.id] = new_group + + return {"status": "created", "group": data.name} + + @app.post("/control/group/{group_id}") async def control_group( group_id: str, @@ -80,19 +122,14 @@ async def control_group( brightness: Optional[int] = None, scene: Optional[str] = None, ): - """ - Управление группой ламп (люстрой). - Параметры передаются в JSON или как query-params. - """ - # 1. Получаем IP-адреса всех ламп в группе из стейт-менеджера - ips = state_manager.get_group_ips(group_id) + """Управление всей люстрой сразу.""" + ips = state_manager.get_group_ips(group_id) if not ips: raise HTTPException( - status_code=404, detail=f"Группа '{group_id}' не найдена или пуста" + status_code=404, detail="Группа не найдена или лампы оффлайн" ) - # 2. Формируем параметры команды для WiZ params = {} if state is not None: params["state"] = state @@ -102,35 +139,13 @@ async def control_group( params["sceneId"] = wiz.SCENES[scene] if not params: - raise HTTPException( - status_code=400, - detail="Не указаны параметры управления (state, brightness или scene)", - ) + raise HTTPException(status_code=400, detail="Никаких команд не передано") - # 3. Отправляем команды всем лампам группы параллельно + # Параллельная отправка всем лампам группы tasks = [wiz.set_pilot(ip, params) for ip in ips] - results = await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*tasks) - return { - "group_id": group_id, - "affected_ips": ips, - "status": "commands_sent", - "params": params, - } - - -@app.post("/control/device/{device_id}") -async def control_device(device_id: str, state: bool): - """Прямое управление устройством по его ID (MAC-адресу).""" - device = state_manager.devices.get(device_id) - - if not device: - raise HTTPException( - status_code=404, detail="Устройство не найдено в текущем стейте" - ) - - result = await wiz.set_pilot(device.ip, {"state": state}) - return {"device_id": device_id, "ip": device.ip, "result": result} + return {"status": "ok", "group": group_id, "sent_to": ips} if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index f253f0d..d860b7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ fastapi==0.109.0 uvicorn[standard]==0.27.0 pydantic==2.5.3 httpx==0.26.0 +sqlalchemy==2.0.25 +aiosqlite==0.19.0