commit c7adc24b07ad016d3867482e869721397df5ac9c Author: Артём Кокос Date: Thu Feb 12 21:52:24 2026 +0700 Initial commit diff --git a/app/core/discovery.py b/app/core/discovery.py new file mode 100644 index 0000000..9519aee --- /dev/null +++ b/app/core/discovery.py @@ -0,0 +1,88 @@ +import asyncio +import json +import socket +import logging +import subprocess +import ipaddress +from typing import List, Dict + +logger = logging.getLogger(__name__) + + +class DiscoveryService: + def __init__(self, port: int = 38899): + self.port = port + self.discover_msg = {"method": "getPilot", "params": {}} + + def _get_local_subnet(self) -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + + network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False) + return str(network) + except Exception as e: + logger.error(f"Linux Discovery Error: Не удалось определить подсеть: {e}") + return "192.168.1.0/24" + + async def scan_network(self, timeout: float = 1.5) -> List[Dict]: + subnet = self._get_local_subnet() + network = ipaddress.IPv4Network(subnet) + found_devices = [] + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + + loop = asyncio.get_running_loop() + message = json.dumps(self.discover_msg).encode() + + logger.info(f"🚀 Начинаю сканирование сети {subnet}...") + + # Рассылаем запросы + for ip in network.hosts(): + try: + sock.sendto(message, (str(ip), self.port)) + except Exception: + continue + + start_time = loop.time() + while (loop.time() - start_time) < timeout: + try: + data, addr = await asyncio.wait_for( + loop.run_in_executor(None, sock.recvfrom, 1024), timeout=0.1 + ) + + resp = json.loads(data.decode()) + if "result" in resp: + res = resp["result"] + mac = res.get("mac") + ip_from_socket = addr[0] + + if mac: + device_info = { + "mac": mac, + "ip": ip_from_socket, + "state": { + "on": res.get("state"), + "dimming": res.get("dimming"), + "temp": res.get("temp"), + }, + } + found_devices.append(device_info) + # Красивый лог с IP и MAC + logger.info( + f" [+] Найдена лампа: {ip_from_socket} | MAC: {mac}" + ) + + except (asyncio.TimeoutError, json.JSONDecodeError): + continue + except Exception as e: + # На Linux Errno 11 (EAGAIN) тут может выскочить, если буфер пуст + await asyncio.sleep(0.01) + continue + + sock.close() + # Фильтруем дубликаты (если лампа ответила несколько раз) + unique_devices = list({d["mac"]: d for d in found_devices}.values()) + return unique_devices diff --git a/app/core/state.py b/app/core/state.py new file mode 100644 index 0000000..eb9f9f5 --- /dev/null +++ b/app/core/state.py @@ -0,0 +1,38 @@ +import logging +from typing import Dict, List, Optional +from app.models.device import Device, Group + +logger = logging.getLogger(__name__) + + +class StateManager: + def __init__(self): + # Храним устройства по MAC (уникальный ID) + self.devices: Dict[str, Device] = {} + # Группы (люстры) по их ID + self.groups: Dict[str, Group] = {} + + 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", + ) + self.devices[mac] = device + + def get_group_ips(self, group_id: str) -> List[str]: + """Возвращает список IP всех ламп, входящих в группу.""" + group = self.groups.get(group_id) + if not group: + return [] + + 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/drivers/wiz.py b/app/drivers/wiz.py new file mode 100644 index 0000000..6835735 --- /dev/null +++ b/app/drivers/wiz.py @@ -0,0 +1,42 @@ +import json +import asyncio +import socket + + +class WizDriver: + PORT = 38899 + + # Стандартные ID сцен WiZ + SCENES = { + "ocean": 1, + "romance": 2, + "party": 3, + "fireplace": 5, + "cozy": 6, + "forest": 10, + "bedtime": 13, + "warm_white": 33, + "daylight": 34, + } + + async def send_udp(self, ip: str, payload: dict): + loop = asyncio.get_event_loop() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(2.0) + data = json.dumps(payload).encode() + + await loop.run_in_executor(None, sock.sendto, data, (ip, self.PORT)) + + try: + resp, _ = sock.recvfrom(1024) + return json.loads(resp.decode()) + except socket.timeout: + return None + + async def set_pilot(self, ip: str, params: dict): + payload = {"method": "setPilot", "params": params} + return await self.send_udp(ip, payload) + + async def get_pilot(self, ip: str): + payload = {"method": "getPilot", "params": {}} + return await self.send_udp(ip, payload) diff --git a/app/models/device.py b/app/models/device.py new file mode 100644 index 0000000..39b79c2 --- /dev/null +++ b/app/models/device.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class Device(BaseModel): + id: str # MAC-адрес или UUID + ip: str + name: str + room: str + + +class Group(BaseModel): + id: str + name: str + device_ids: List[str] # Список ID устройств, входящих в люстру diff --git a/main.py b/main.py new file mode 100644 index 0000000..36523a0 --- /dev/null +++ b/main.py @@ -0,0 +1,139 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import Optional, List + +from fastapi import FastAPI, HTTPException + +from app.core.discovery import DiscoveryService +from app.core.state import state_manager +from app.drivers.wiz import WizDriver + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" +) +logger = logging.getLogger(__name__) + +discovery = DiscoveryService() +wiz = WizDriver() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Жизненный цикл приложения: выполняется при старте и остановке. + """ + + # Задача для периодического сканирования сети + async def periodic_discovery(): + logger.info("🚀 Запущена фоновая служба Discovery") + + while True: + try: + found_devices = await discovery.scan_network() + + for dev_data in found_devices: + state_manager.update_device(dev_data) + + logger.info( + 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.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/group/{group_id}") +async def control_group( + group_id: str, + state: Optional[bool] = None, + brightness: Optional[int] = None, + scene: Optional[str] = None, +): + """ + Управление группой ламп (люстрой). + Параметры передаются в JSON или как query-params. + """ + # 1. Получаем IP-адреса всех ламп в группе из стейт-менеджера + ips = state_manager.get_group_ips(group_id) + + if not ips: + raise HTTPException( + status_code=404, detail=f"Группа '{group_id}' не найдена или пуста" + ) + + # 2. Формируем параметры команды для WiZ + params = {} + if state is not None: + params["state"] = state + if brightness is not None: + params["dimming"] = brightness + if scene and scene in wiz.SCENES: + params["sceneId"] = wiz.SCENES[scene] + + if not params: + raise HTTPException( + status_code=400, + detail="Не указаны параметры управления (state, brightness или scene)", + ) + + # 3. Отправляем команды всем лампам группы параллельно + tasks = [wiz.set_pilot(ip, params) for ip in ips] + results = await asyncio.gather(*tasks, return_exceptions=True) + + 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} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f253f0d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +pydantic==2.5.3 +httpx==0.26.0