Stabilize discovery lifecycle and rescan summary
This commit is contained in:
13
README.md
13
README.md
@@ -32,6 +32,8 @@ UI: `http://<host>:8000/`
|
|||||||
IGNIS_API_KEY=change-me
|
IGNIS_API_KEY=change-me
|
||||||
APP_TIMEZONE=Asia/Novosibirsk
|
APP_TIMEZONE=Asia/Novosibirsk
|
||||||
SCAN_NETWORK=
|
SCAN_NETWORK=
|
||||||
|
DISCOVERY_INTERVAL_SECONDS=600
|
||||||
|
DISCOVERY_BACKGROUND_MISSING_THRESHOLD=2
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
EVENT_LOG_RETENTION_DAYS=30
|
EVENT_LOG_RETENTION_DAYS=30
|
||||||
```
|
```
|
||||||
@@ -45,8 +47,12 @@ IGNIS_SYNC_DATABASE_URL=sqlite:///./ignis.db
|
|||||||
|
|
||||||
Замечание по discovery:
|
Замечание по discovery:
|
||||||
|
|
||||||
- если на хосте есть VPN или несколько интерфейсов, лучше явно задать `SCAN_NETWORK`
|
- если `SCAN_NETWORK` не задан, сервер сам выбирает private IPv4-подсети обычных интерфейсов и старается не сканировать VPN / docker / tunnel-интерфейсы
|
||||||
|
- если на хосте есть VPN или несколько интерфейсов, всё равно лучше явно задать `SCAN_NETWORK`
|
||||||
- формат: `192.168.0.0/24` или список через запятую
|
- формат: `192.168.0.0/24` или список через запятую
|
||||||
|
- startup scan выполняется до старта фонового цикла
|
||||||
|
- background refresh по умолчанию удаляет устройство только после двух подряд промахов discovery
|
||||||
|
- manual `POST /devices/rescan` удаляет оффлайн-устройства сразу и возвращает summary (`found`, `added`, `updated`, `removed_offline`, `pending_removal`, `online`)
|
||||||
|
|
||||||
## Авторизация
|
## Авторизация
|
||||||
|
|
||||||
@@ -162,7 +168,7 @@ curl -X POST 'http://localhost:8000/schedules/once' \
|
|||||||
timeout 120s .venv/bin/python -m unittest discover -s tests -v
|
timeout 120s .venv/bin/python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Сейчас есть 17 тестов. Покрыты:
|
Сейчас есть 25 тестов. Покрыты:
|
||||||
|
|
||||||
- auth и роли
|
- auth и роли
|
||||||
- lifecycle API-ключей
|
- lifecycle API-ключей
|
||||||
@@ -170,6 +176,9 @@ timeout 120s .venv/bin/python -m unittest discover -s tests -v
|
|||||||
- валидация scene
|
- валидация scene
|
||||||
- one-shot и cron расписания
|
- one-shot и cron расписания
|
||||||
- миграция legacy jobs
|
- миграция legacy jobs
|
||||||
|
- auto-subnet selection для discovery
|
||||||
|
- background offline cleanup threshold
|
||||||
|
- manual rescan summary и immediate cleanup
|
||||||
- агрегация stats без двойного счёта `*_requested`
|
- агрегация stats без двойного счёта `*_requested`
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.core.state import state_manager, discovery_service
|
|
||||||
from app.core.database import async_session
|
from app.api.schemas import RescanResponse
|
||||||
from app.models.device import GroupModel, GroupCreateSchema
|
|
||||||
from app.api.deps import verify_token, require_admin
|
from app.api.deps import verify_token, require_admin
|
||||||
|
from app.core.database import async_session
|
||||||
|
from app.core.state import state_manager, discovery_service
|
||||||
|
from app.models.device import GroupModel, GroupCreateSchema
|
||||||
from app.drivers.wiz import WizDriver
|
from app.drivers.wiz import WizDriver
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -61,25 +63,14 @@ async def delete_group(group_id: str):
|
|||||||
return {"status": "deleted", "id": group_id}
|
return {"status": "deleted", "id": group_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rescan", dependencies=[Depends(require_admin)])
|
@router.post(
|
||||||
|
"/rescan",
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
response_model=RescanResponse,
|
||||||
|
)
|
||||||
async def rescan_network():
|
async def rescan_network():
|
||||||
found_devices = await discovery_service.scan_network()
|
summary = await discovery_service.manual_refresh(state_manager)
|
||||||
|
|
||||||
# MAC-адреса найденных ламп
|
|
||||||
found_macs = {dev["mac"] for dev in found_devices}
|
|
||||||
|
|
||||||
# Удаляем устройства, которые не ответили (оффлайн)
|
|
||||||
offline_macs = [mac for mac in state_manager.devices if mac not in found_macs]
|
|
||||||
for mac in offline_macs:
|
|
||||||
del state_manager.devices[mac]
|
|
||||||
logger.info(f"Устройство {mac} не ответило -- убрано из списка")
|
|
||||||
|
|
||||||
# Обновляем/добавляем найденные
|
|
||||||
for dev_data in found_devices:
|
|
||||||
state_manager.update_device(dev_data)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"found": len(found_macs),
|
**summary.to_dict(),
|
||||||
"removed_offline": len(offline_macs),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,3 +168,13 @@ class ScheduleTasksResponse(BaseModel):
|
|||||||
|
|
||||||
class DeleteStatusResponse(BaseModel):
|
class DeleteStatusResponse(BaseModel):
|
||||||
status: Literal["deleted"]
|
status: Literal["deleted"]
|
||||||
|
|
||||||
|
|
||||||
|
class RescanResponse(BaseModel):
|
||||||
|
status: Literal["ok"]
|
||||||
|
found: int
|
||||||
|
added: int
|
||||||
|
updated: int
|
||||||
|
removed_offline: int
|
||||||
|
pending_removal: int
|
||||||
|
online: int
|
||||||
|
|||||||
@@ -1,64 +1,224 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import ipaddress
|
import socket
|
||||||
from typing import List, Dict
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
except ImportError: # pragma: no cover - не на Linux
|
||||||
|
fcntl = None
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Минимальный допустимый prefixlen (больше число = меньше сеть)
|
ENV_MIN_PREFIX_LEN = 16
|
||||||
# /16 = 65534 хоста, /8 = 16M хостов -- слишком много
|
AUTO_MIN_PREFIX_LEN = 24
|
||||||
MIN_PREFIX_LEN = 16
|
DEFAULT_DISCOVERY_INTERVAL_SECONDS = 600
|
||||||
|
DEFAULT_BACKGROUND_MISSING_THRESHOLD = 2
|
||||||
|
|
||||||
|
EXCLUDED_INTERFACE_PREFIXES = (
|
||||||
|
"lo",
|
||||||
|
"docker",
|
||||||
|
"br-",
|
||||||
|
"veth",
|
||||||
|
"virbr",
|
||||||
|
"tun",
|
||||||
|
"tap",
|
||||||
|
"wg",
|
||||||
|
"tailscale",
|
||||||
|
"zt",
|
||||||
|
"utun",
|
||||||
|
"ppp",
|
||||||
|
)
|
||||||
|
|
||||||
|
SIOCGIFADDR = 0x8915
|
||||||
|
SIOCGIFNETMASK = 0x891B
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InterfaceSubnet:
|
||||||
|
name: str
|
||||||
|
address: ipaddress.IPv4Address
|
||||||
|
network: ipaddress.IPv4Network
|
||||||
|
|
||||||
|
|
||||||
class DiscoveryService:
|
class DiscoveryService:
|
||||||
def __init__(self, port: int = 38899):
|
def __init__(self, port: int = 38899):
|
||||||
self.port = port
|
self.port = port
|
||||||
self.discover_msg = {"method": "getPilot", "params": {}}
|
self.discover_msg = {"method": "getPilot", "params": {}}
|
||||||
|
self._scan_lock = asyncio.Lock()
|
||||||
|
|
||||||
def _get_target_subnets(self) -> List[str]:
|
def _env_min_prefix_len(self) -> int:
|
||||||
"""
|
return int(os.getenv("DISCOVERY_ENV_MIN_PREFIX_LEN", ENV_MIN_PREFIX_LEN))
|
||||||
Определяет список подсетей для сканирования.
|
|
||||||
Приоритет:
|
def _auto_min_prefix_len(self) -> int:
|
||||||
1. Переменная окружения SCAN_NETWORK (можно через запятую: "192.168.0.0/24,192.168.1.0/24")
|
return int(os.getenv("DISCOVERY_AUTO_MIN_PREFIX_LEN", AUTO_MIN_PREFIX_LEN))
|
||||||
2. Автоопределение по дефолтному шлюзу
|
|
||||||
"""
|
def _background_interval_seconds(self) -> int:
|
||||||
env_network = os.getenv("SCAN_NETWORK")
|
return int(
|
||||||
if env_network:
|
os.getenv(
|
||||||
subnets = []
|
"DISCOVERY_INTERVAL_SECONDS", DEFAULT_DISCOVERY_INTERVAL_SECONDS
|
||||||
for s in env_network.split(","):
|
)
|
||||||
s = s.strip()
|
)
|
||||||
|
|
||||||
|
def _background_missing_threshold(self) -> int:
|
||||||
|
return int(
|
||||||
|
os.getenv(
|
||||||
|
"DISCOVERY_BACKGROUND_MISSING_THRESHOLD",
|
||||||
|
DEFAULT_BACKGROUND_MISSING_THRESHOLD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_env_subnets(self, value: str) -> List[str]:
|
||||||
|
subnets: list[str] = []
|
||||||
|
min_prefix_len = self._env_min_prefix_len()
|
||||||
|
|
||||||
|
for raw_subnet in value.split(","):
|
||||||
|
subnet = raw_subnet.strip()
|
||||||
|
if not subnet:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
network = ipaddress.IPv4Network(subnet, strict=False)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error("Неверный формат подсети %s: %s", subnet, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if network.prefixlen < min_prefix_len:
|
||||||
|
logger.warning(
|
||||||
|
"Подсеть %s слишком большая (/%s), ограничиваю до /%s",
|
||||||
|
subnet,
|
||||||
|
network.prefixlen,
|
||||||
|
min_prefix_len,
|
||||||
|
)
|
||||||
|
network = ipaddress.IPv4Network(
|
||||||
|
f"{network.network_address}/{min_prefix_len}", strict=False
|
||||||
|
)
|
||||||
|
subnets.append(str(network))
|
||||||
|
|
||||||
|
return subnets
|
||||||
|
|
||||||
|
def _interface_subnets(self) -> list[InterfaceSubnet]:
|
||||||
|
if fcntl is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates: list[InterfaceSubnet] = []
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
|
for _, interface_name in socket.if_nameindex():
|
||||||
|
ifreq = struct.pack("256s", interface_name.encode("utf-8")[:15])
|
||||||
try:
|
try:
|
||||||
net = ipaddress.IPv4Network(s, strict=False)
|
address = socket.inet_ntoa(
|
||||||
if net.prefixlen < MIN_PREFIX_LEN:
|
fcntl.ioctl(sock.fileno(), SIOCGIFADDR, ifreq)[20:24]
|
||||||
logger.warning(
|
)
|
||||||
f"Подсеть {s} слишком большая (/{net.prefixlen}), "
|
netmask = socket.inet_ntoa(
|
||||||
f"ограничиваю до /{MIN_PREFIX_LEN}"
|
fcntl.ioctl(sock.fileno(), SIOCGIFNETMASK, ifreq)[20:24]
|
||||||
)
|
)
|
||||||
net = ipaddress.IPv4Network(
|
except OSError:
|
||||||
f"{net.network_address}/{MIN_PREFIX_LEN}", strict=False
|
continue
|
||||||
)
|
|
||||||
subnets.append(str(net))
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Неверный формат подсети {s}: {e}")
|
|
||||||
return subnets if subnets else ["192.168.1.0/24"]
|
|
||||||
|
|
||||||
# Автоопределение
|
ipv4 = ipaddress.IPv4Address(address)
|
||||||
|
if ipv4.is_loopback or ipv4.is_link_local:
|
||||||
|
continue
|
||||||
|
|
||||||
|
network = ipaddress.IPv4Network(f"{address}/{netmask}", strict=False)
|
||||||
|
candidates.append(
|
||||||
|
InterfaceSubnet(
|
||||||
|
name=interface_name,
|
||||||
|
address=ipv4,
|
||||||
|
network=network,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _is_excluded_interface(self, interface_name: str) -> bool:
|
||||||
|
lowered = interface_name.lower()
|
||||||
|
return lowered.startswith(EXCLUDED_INTERFACE_PREFIXES)
|
||||||
|
|
||||||
|
def _normalize_auto_network(
|
||||||
|
self, candidate: InterfaceSubnet
|
||||||
|
) -> ipaddress.IPv4Network:
|
||||||
|
min_prefix_len = self._auto_min_prefix_len()
|
||||||
|
target_prefix_len = max(candidate.network.prefixlen, min_prefix_len)
|
||||||
|
if target_prefix_len != candidate.network.prefixlen:
|
||||||
|
logger.info(
|
||||||
|
"Авто-discovery: подсеть %s (%s) шире /%s, сканирую локальный сегмент /%s",
|
||||||
|
candidate.network,
|
||||||
|
candidate.name,
|
||||||
|
min_prefix_len,
|
||||||
|
target_prefix_len,
|
||||||
|
)
|
||||||
|
return ipaddress.IPv4Network(
|
||||||
|
f"{candidate.address}/{target_prefix_len}", strict=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _collect_auto_subnets(self) -> list[str]:
|
||||||
|
candidates = self._interface_subnets()
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
private_candidates = [candidate for candidate in candidates if candidate.address.is_private]
|
||||||
|
usable_candidates = private_candidates or candidates
|
||||||
|
preferred_candidates = [
|
||||||
|
candidate
|
||||||
|
for candidate in usable_candidates
|
||||||
|
if not self._is_excluded_interface(candidate.name)
|
||||||
|
]
|
||||||
|
selected_candidates = preferred_candidates or usable_candidates
|
||||||
|
|
||||||
|
subnets: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for candidate in selected_candidates:
|
||||||
|
normalized = str(self._normalize_auto_network(candidate))
|
||||||
|
if normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
subnets.append(normalized)
|
||||||
|
|
||||||
|
if subnets:
|
||||||
|
logger.info(
|
||||||
|
"Авто-discovery: выбраны подсети %s",
|
||||||
|
", ".join(subnets),
|
||||||
|
)
|
||||||
|
return subnets
|
||||||
|
|
||||||
|
def _fallback_subnet(self) -> list[str]:
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
# Коннект не создает трафика, но заставляет ОС выбрать нужный интерфейс
|
sock.connect(("8.8.8.8", 80))
|
||||||
s.connect(("8.8.8.8", 80))
|
local_ip = sock.getsockname()[0]
|
||||||
local_ip = s.getsockname()[0]
|
except Exception as exc:
|
||||||
network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False)
|
|
||||||
return [str(network)]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Discovery Error: Не удалось определить подсеть автоматически: {e}"
|
"Discovery Error: Не удалось определить подсеть автоматически: %s",
|
||||||
|
exc,
|
||||||
)
|
)
|
||||||
return ["192.168.1.0/24"]
|
return ["192.168.1.0/24"]
|
||||||
|
|
||||||
|
network = ipaddress.IPv4Network(
|
||||||
|
f"{local_ip}/{self._auto_min_prefix_len()}",
|
||||||
|
strict=False,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Авто-discovery fallback: использую локальный сегмент %s", network
|
||||||
|
)
|
||||||
|
return [str(network)]
|
||||||
|
|
||||||
|
def _get_target_subnets(self) -> List[str]:
|
||||||
|
env_network = os.getenv("SCAN_NETWORK", "").strip()
|
||||||
|
if env_network:
|
||||||
|
subnets = self._parse_env_subnets(env_network)
|
||||||
|
return subnets if subnets else ["192.168.1.0/24"]
|
||||||
|
|
||||||
|
auto_subnets = self._collect_auto_subnets()
|
||||||
|
if auto_subnets:
|
||||||
|
return auto_subnets
|
||||||
|
|
||||||
|
return self._fallback_subnet()
|
||||||
|
|
||||||
async def scan_network(self, timeout: float = 2.0) -> List[Dict]:
|
async def scan_network(self, timeout: float = 2.0) -> List[Dict]:
|
||||||
subnets = self._get_target_subnets()
|
subnets = self._get_target_subnets()
|
||||||
found_devices = []
|
found_devices = []
|
||||||
@@ -69,65 +229,119 @@ class DiscoveryService:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
message = json.dumps(self.discover_msg).encode()
|
message = json.dumps(self.discover_msg).encode()
|
||||||
|
|
||||||
logger.debug(f"Начинаю сканирование сетей: {', '.join(subnets)}...")
|
logger.debug("Начинаю сканирование сетей: %s...", ", ".join(subnets))
|
||||||
|
|
||||||
# Рассылаем запросы по всем целевым сетям
|
try:
|
||||||
for subnet in subnets:
|
for subnet in subnets:
|
||||||
try:
|
try:
|
||||||
network = ipaddress.IPv4Network(subnet)
|
network = ipaddress.IPv4Network(subnet)
|
||||||
for ip in network.hosts():
|
for ip in network.hosts():
|
||||||
try:
|
try:
|
||||||
sock.sendto(message, (str(ip), self.port))
|
sock.sendto(message, (str(ip), self.port))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
continue
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error("Неверный формат подсети %s: %s", subnet, exc)
|
||||||
|
|
||||||
|
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.2
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = json.loads(data.decode())
|
||||||
|
if "result" not in resp:
|
||||||
continue
|
continue
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Неверный формат подсети {subnet}: {e}")
|
|
||||||
|
|
||||||
# Собираем ответы
|
result = resp["result"]
|
||||||
start_time = loop.time()
|
mac = result.get("mac")
|
||||||
while (loop.time() - start_time) < timeout:
|
if not mac:
|
||||||
try:
|
continue
|
||||||
# Используем небольшой таймаут на чтение, чтобы успевать выходить из цикла
|
|
||||||
data, addr = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(None, sock.recvfrom, 1024), timeout=0.2
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = json.loads(data.decode())
|
found_devices.append(
|
||||||
if "result" in resp:
|
{
|
||||||
res = resp["result"]
|
"mac": mac,
|
||||||
mac = res.get("mac")
|
"ip": addr[0],
|
||||||
if mac:
|
"state": {
|
||||||
found_devices.append(
|
"on": result.get("state"),
|
||||||
{
|
"dimming": result.get("dimming"),
|
||||||
"mac": mac,
|
"temp": result.get("temp"),
|
||||||
"ip": addr[0],
|
},
|
||||||
"state": {
|
}
|
||||||
"on": res.get("state"),
|
)
|
||||||
"dimming": res.get("dimming"),
|
logger.info(" [+] Найдена лампа: %s | MAC: %s", addr[0], mac)
|
||||||
"temp": res.get("temp"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.info(f" [+] Найдена лампа: {addr[0]} | MAC: {mac}")
|
|
||||||
|
|
||||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
continue
|
continue
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
sock.close()
|
return list({device["mac"]: device for device in found_devices}.values())
|
||||||
# Фильтруем дубликаты
|
|
||||||
return list({d["mac"]: d for d in found_devices}.values())
|
|
||||||
|
|
||||||
async def start_background_discovery(self, state_manager, interval=600):
|
async def _refresh_devices(
|
||||||
"""Запускает бесконечный цикл сканирования."""
|
self,
|
||||||
|
state_manager,
|
||||||
|
*,
|
||||||
|
mode: str,
|
||||||
|
remove_missing: bool,
|
||||||
|
missing_threshold: int,
|
||||||
|
timeout: float = 2.0,
|
||||||
|
):
|
||||||
|
async with self._scan_lock:
|
||||||
|
found_devices = await self.scan_network(timeout=timeout)
|
||||||
|
result = state_manager.apply_discovery_snapshot(
|
||||||
|
found_devices,
|
||||||
|
remove_missing=remove_missing,
|
||||||
|
missing_threshold=missing_threshold,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Discovery (%s): found=%s added=%s updated=%s removed=%s pending_removal=%s online=%s",
|
||||||
|
mode,
|
||||||
|
result.found,
|
||||||
|
result.added,
|
||||||
|
result.updated,
|
||||||
|
result.removed_offline,
|
||||||
|
result.pending_removal,
|
||||||
|
result.online,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def startup_refresh(self, state_manager, timeout: float = 2.0):
|
||||||
|
return await self._refresh_devices(
|
||||||
|
state_manager,
|
||||||
|
mode="startup",
|
||||||
|
remove_missing=True,
|
||||||
|
missing_threshold=1,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def manual_refresh(self, state_manager, timeout: float = 2.0):
|
||||||
|
return await self._refresh_devices(
|
||||||
|
state_manager,
|
||||||
|
mode="manual",
|
||||||
|
remove_missing=True,
|
||||||
|
missing_threshold=1,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def background_refresh(self, state_manager, timeout: float = 2.0):
|
||||||
|
return await self._refresh_devices(
|
||||||
|
state_manager,
|
||||||
|
mode="background",
|
||||||
|
remove_missing=True,
|
||||||
|
missing_threshold=self._background_missing_threshold(),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start_background_discovery(self, state_manager, interval: int | None = None):
|
||||||
|
interval_seconds = interval or self._background_interval_seconds()
|
||||||
while True:
|
while True:
|
||||||
|
await asyncio.sleep(interval_seconds)
|
||||||
try:
|
try:
|
||||||
found_devices = await self.scan_network()
|
await self.background_refresh(state_manager)
|
||||||
for dev_data in found_devices:
|
except Exception as exc:
|
||||||
state_manager.update_device(dev_data)
|
logger.error("Discovery background error: %s", exc)
|
||||||
logger.info(f"Discovery: онлайн {len(state_manager.devices)} устройств")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Discovery background error: {e}")
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
|
|||||||
@@ -1,28 +1,96 @@
|
|||||||
|
from dataclasses import asdict, dataclass
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List
|
||||||
|
|
||||||
from app.models.device import DeviceSchema, GroupModel
|
from app.models.device import DeviceSchema, GroupModel
|
||||||
from app.core.discovery import DiscoveryService
|
from app.core.discovery import DiscoveryService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DiscoveryApplyResult:
|
||||||
|
found: int
|
||||||
|
added: int
|
||||||
|
updated: int
|
||||||
|
removed_offline: int
|
||||||
|
pending_removal: int
|
||||||
|
online: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
class StateManager:
|
class StateManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Храним устройства как Pydantic объекты
|
# Храним устройства как Pydantic объекты
|
||||||
self.devices: Dict[str, DeviceSchema] = {}
|
self.devices: Dict[str, DeviceSchema] = {}
|
||||||
# Группы как модели SQLAlchemy
|
# Группы как модели SQLAlchemy
|
||||||
self.groups: Dict[str, GroupModel] = {}
|
self.groups: Dict[str, GroupModel] = {}
|
||||||
|
# Сколько подряд циклов discovery устройство не видно
|
||||||
|
self._missing_scan_counts: Dict[str, int] = {}
|
||||||
|
|
||||||
def update_device(self, device_data: dict):
|
def update_device(self, device_data: dict):
|
||||||
"""Обновляет или добавляет устройство в состояние."""
|
"""Обновляет или добавляет устройство в состояние."""
|
||||||
|
|
||||||
mac = device_data["mac"]
|
mac = device_data["mac"]
|
||||||
|
current = self.devices.get(mac)
|
||||||
# Используем DeviceSchema вместо Device
|
|
||||||
device = DeviceSchema(
|
device = DeviceSchema(
|
||||||
id=mac, ip=device_data["ip"], name=f"WiZ {mac[-4:]}", room="Default"
|
id=mac,
|
||||||
|
ip=device_data["ip"],
|
||||||
|
name=current.name if current else f"WiZ {mac[-4:]}",
|
||||||
|
room=current.room if current else "Default",
|
||||||
)
|
)
|
||||||
self.devices[mac] = device
|
self.devices[mac] = device
|
||||||
|
self._missing_scan_counts.pop(mac, None)
|
||||||
|
|
||||||
|
def apply_discovery_snapshot(
|
||||||
|
self,
|
||||||
|
found_devices: list[dict],
|
||||||
|
*,
|
||||||
|
remove_missing: bool,
|
||||||
|
missing_threshold: int = 1,
|
||||||
|
) -> DiscoveryApplyResult:
|
||||||
|
found_by_mac = {device["mac"]: device for device in found_devices}
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
updated = 0
|
||||||
|
for mac, device_data in found_by_mac.items():
|
||||||
|
if mac in self.devices:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
added += 1
|
||||||
|
self.update_device(device_data)
|
||||||
|
|
||||||
|
removed_offline = 0
|
||||||
|
if remove_missing:
|
||||||
|
for mac in list(self.devices):
|
||||||
|
if mac in found_by_mac:
|
||||||
|
continue
|
||||||
|
|
||||||
|
missed_scans = self._missing_scan_counts.get(mac, 0) + 1
|
||||||
|
self._missing_scan_counts[mac] = missed_scans
|
||||||
|
if missed_scans < missing_threshold:
|
||||||
|
logger.info(
|
||||||
|
"Устройство %s не ответило (%s/%s), оставляю до следующего цикла",
|
||||||
|
mac,
|
||||||
|
missed_scans,
|
||||||
|
missing_threshold,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.devices.pop(mac, None)
|
||||||
|
self._missing_scan_counts.pop(mac, None)
|
||||||
|
removed_offline += 1
|
||||||
|
logger.info("Устройство %s не ответило -- убрано из списка", mac)
|
||||||
|
|
||||||
|
return DiscoveryApplyResult(
|
||||||
|
found=len(found_by_mac),
|
||||||
|
added=added,
|
||||||
|
updated=updated,
|
||||||
|
removed_offline=removed_offline,
|
||||||
|
pending_removal=len(self._missing_scan_counts),
|
||||||
|
online=len(self.devices),
|
||||||
|
)
|
||||||
|
|
||||||
def get_group_ips(self, group_id: str) -> List[str]:
|
def get_group_ips(self, group_id: str) -> List[str]:
|
||||||
"""Возвращает список IP всех ламп, входящих в группу."""
|
"""Возвращает список IP всех ламп, входящих в группу."""
|
||||||
|
|||||||
7
main.py
7
main.py
@@ -31,10 +31,13 @@ async def lifespan(app: FastAPI):
|
|||||||
state_manager.groups[g.id] = g
|
state_manager.groups[g.id] = g
|
||||||
logger.info(f"📂 Загружена группа: {g.name}")
|
logger.info(f"📂 Загружена группа: {g.name}")
|
||||||
|
|
||||||
# 3. Планировщик после загрузки метаданных групп
|
# 3. Startup discovery до старта фонового цикла
|
||||||
|
await discovery_service.startup_refresh(state_manager)
|
||||||
|
|
||||||
|
# 4. Планировщик после загрузки метаданных групп
|
||||||
await start_scheduler()
|
await start_scheduler()
|
||||||
|
|
||||||
# 4. Фоновый Discovery
|
# 5. Фоновый Discovery
|
||||||
discovery_task = asyncio.create_task(
|
discovery_task = asyncio.create_task(
|
||||||
discovery_service.start_background_discovery(state_manager)
|
discovery_service.start_background_discovery(state_manager)
|
||||||
)
|
)
|
||||||
|
|||||||
47
openapi.json
47
openapi.json
@@ -176,7 +176,9 @@
|
|||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {}
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RescanResponse"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1309,49 @@
|
|||||||
"title": "KeyActionRequest",
|
"title": "KeyActionRequest",
|
||||||
"description": "Тело запроса для операций с ключом (чтобы токен не летел в URL)."
|
"description": "Тело запроса для операций с ключом (чтобы токен не летел в URL)."
|
||||||
},
|
},
|
||||||
|
"RescanResponse": {
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"const": "ok",
|
||||||
|
"title": "Status"
|
||||||
|
},
|
||||||
|
"found": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Found"
|
||||||
|
},
|
||||||
|
"added": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Added"
|
||||||
|
},
|
||||||
|
"updated": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Updated"
|
||||||
|
},
|
||||||
|
"removed_offline": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Removed Offline"
|
||||||
|
},
|
||||||
|
"pending_removal": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Pending Removal"
|
||||||
|
},
|
||||||
|
"online": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Online"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"found",
|
||||||
|
"added",
|
||||||
|
"updated",
|
||||||
|
"removed_offline",
|
||||||
|
"pending_removal",
|
||||||
|
"online"
|
||||||
|
],
|
||||||
|
"title": "RescanResponse"
|
||||||
|
},
|
||||||
"ScheduleCreateResponse": {
|
"ScheduleCreateResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
140
tests/test_p1_discovery.py
Normal file
140
tests/test_p1_discovery.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
TEST_DB_PATH = Path(__file__).with_name("test_ignis_discovery.db")
|
||||||
|
if TEST_DB_PATH.exists():
|
||||||
|
TEST_DB_PATH.unlink()
|
||||||
|
|
||||||
|
MASTER_KEY = "master-secret-for-discovery-tests"
|
||||||
|
os.environ["IGNIS_API_KEY"] = MASTER_KEY
|
||||||
|
os.environ["IGNIS_DATABASE_URL"] = f"sqlite+aiosqlite:///{TEST_DB_PATH}"
|
||||||
|
os.environ["IGNIS_SYNC_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}"
|
||||||
|
|
||||||
|
import main # noqa: E402
|
||||||
|
from app.core.database import async_session, init_db # noqa: E402
|
||||||
|
from app.core.discovery import DiscoveryService, InterfaceSubnet # noqa: E402
|
||||||
|
from app.core.state import state_manager # noqa: E402
|
||||||
|
from app.models.device import GroupModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryBehaviorTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
os.environ["IGNIS_API_KEY"] = MASTER_KEY
|
||||||
|
await init_db()
|
||||||
|
await self._reset_database()
|
||||||
|
state_manager.devices.clear()
|
||||||
|
state_manager.groups.clear()
|
||||||
|
state_manager._missing_scan_counts.clear()
|
||||||
|
self.client = AsyncClient(
|
||||||
|
transport=ASGITransport(app=main.app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.client.aclose()
|
||||||
|
state_manager.devices.clear()
|
||||||
|
state_manager.groups.clear()
|
||||||
|
state_manager._missing_scan_counts.clear()
|
||||||
|
|
||||||
|
async def _reset_database(self):
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(delete(GroupModel))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {"X-API-Key": MASTER_KEY}
|
||||||
|
|
||||||
|
def test_auto_subnets_prefer_non_vpn_private_interfaces(self):
|
||||||
|
service = DiscoveryService()
|
||||||
|
candidates = [
|
||||||
|
InterfaceSubnet(
|
||||||
|
name="wg0",
|
||||||
|
address=ipaddress.IPv4Address("10.8.0.2"),
|
||||||
|
network=ipaddress.IPv4Network("10.8.0.0/24"),
|
||||||
|
),
|
||||||
|
InterfaceSubnet(
|
||||||
|
name="wlan0",
|
||||||
|
address=ipaddress.IPv4Address("192.168.0.25"),
|
||||||
|
network=ipaddress.IPv4Network("192.168.0.0/24"),
|
||||||
|
),
|
||||||
|
InterfaceSubnet(
|
||||||
|
name="docker0",
|
||||||
|
address=ipaddress.IPv4Address("172.17.0.1"),
|
||||||
|
network=ipaddress.IPv4Network("172.17.0.0/16"),
|
||||||
|
),
|
||||||
|
InterfaceSubnet(
|
||||||
|
name="enp3s0",
|
||||||
|
address=ipaddress.IPv4Address("192.168.1.20"),
|
||||||
|
network=ipaddress.IPv4Network("192.168.0.0/23"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
with patch.object(service, "_interface_subnets", return_value=candidates):
|
||||||
|
subnets = service._get_target_subnets()
|
||||||
|
|
||||||
|
self.assertEqual(subnets, ["192.168.0.0/24", "192.168.1.0/24"])
|
||||||
|
|
||||||
|
async def test_manual_rescan_updates_and_removes_devices_immediately(self):
|
||||||
|
state_manager.devices["stale-device"] = SimpleNamespace(
|
||||||
|
id="stale-device",
|
||||||
|
ip="192.168.0.10",
|
||||||
|
name="Old Lamp",
|
||||||
|
room="Office",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
main.discovery_service,
|
||||||
|
"scan_network",
|
||||||
|
AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"mac": "fresh-device",
|
||||||
|
"ip": "192.168.0.20",
|
||||||
|
"state": {"on": True, "dimming": 100, "temp": 4100},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = await self.client.post(
|
||||||
|
"/devices/rescan",
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["status"], "ok")
|
||||||
|
self.assertEqual(payload["found"], 1)
|
||||||
|
self.assertEqual(payload["added"], 1)
|
||||||
|
self.assertEqual(payload["updated"], 0)
|
||||||
|
self.assertEqual(payload["removed_offline"], 1)
|
||||||
|
self.assertEqual(payload["online"], 1)
|
||||||
|
self.assertEqual(list(state_manager.devices.keys()), ["fresh-device"])
|
||||||
|
|
||||||
|
def test_background_cleanup_requires_multiple_misses(self):
|
||||||
|
state_manager.update_device({"mac": "dev-1", "ip": "192.168.0.10"})
|
||||||
|
|
||||||
|
first = state_manager.apply_discovery_snapshot(
|
||||||
|
[],
|
||||||
|
remove_missing=True,
|
||||||
|
missing_threshold=2,
|
||||||
|
)
|
||||||
|
self.assertEqual(first.removed_offline, 0)
|
||||||
|
self.assertEqual(first.pending_removal, 1)
|
||||||
|
self.assertIn("dev-1", state_manager.devices)
|
||||||
|
|
||||||
|
second = state_manager.apply_discovery_snapshot(
|
||||||
|
[],
|
||||||
|
remove_missing=True,
|
||||||
|
missing_threshold=2,
|
||||||
|
)
|
||||||
|
self.assertEqual(second.removed_offline, 1)
|
||||||
|
self.assertEqual(second.pending_removal, 0)
|
||||||
|
self.assertNotIn("dev-1", state_manager.devices)
|
||||||
Reference in New Issue
Block a user