71803418e5
Shelly device management app with mDNS/subnet discovery, inventory, configuration, and mass operations for Gen1/Gen2+ devices. Includes .gitignore excluding runtime data (device DB, user config), AI conversation history, build artifacts, and common Python/OS patterns.
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
"""
|
|
Real-network integration tests: mDNS, HTTP /shelly, optional subnet scan, read snapshot.
|
|
|
|
Enable with:
|
|
SHELLY_INTEGRATION=1 uv run pytest tests/integration -v -s
|
|
|
|
Optional:
|
|
SHELLY_MDNS_SECONDS=8 # browse time (default 6)
|
|
SHELLY_SUBNET=192.168.1.0/24 # also run subnet scan (slow on large nets)
|
|
SHELLY_SCAN_CONCURRENCY=48
|
|
SHELLY_TEST_IP=192.168.1.42 # optional :8080, or [fe80::1]:80 for IPv6
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import aiohttp
|
|
import pytest
|
|
|
|
from shelly_manager.api.client import ShellyRuntime, fetch_device_snapshot, probe_ip
|
|
from shelly_manager.core.discovery import browse_mdns, fetch_shelly_json, scan_subnet_http
|
|
from shelly_manager.core.models import DiscoveredEndpoint
|
|
from tests.integration.conftest import mdns_timeout_sec, requires_integration, subnet_cidr
|
|
|
|
|
|
def _parse_host_port(raw: str) -> tuple[str, int]:
|
|
raw = raw.strip()
|
|
if raw.startswith("["):
|
|
end = raw.index("]")
|
|
host = raw[1:end]
|
|
rest = raw[end + 1 :]
|
|
if rest.startswith(":") and rest[1:].isdigit():
|
|
return host, int(rest[1:])
|
|
return host, 80
|
|
if raw.count(":") == 1:
|
|
h, p = raw.rsplit(":", 1)
|
|
if p.isdigit():
|
|
return h, int(p)
|
|
return raw, 80
|
|
|
|
|
|
@requires_integration
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_real_mdns_browse_returns_without_error() -> None:
|
|
"""Browse local mDNS; must complete (list may be empty)."""
|
|
timeout = mdns_timeout_sec()
|
|
endpoints = await browse_mdns(timeout_sec=timeout)
|
|
assert isinstance(endpoints, list)
|
|
print(f"\n[integration] mDNS endpoints after {timeout}s: {len(endpoints)}")
|
|
for ep in endpoints[:20]:
|
|
print(f" - {ep.ip}:{ep.port} ({ep.source}) host={ep.hostname}")
|
|
|
|
|
|
@requires_integration
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_real_probe_mdns_endpoints() -> None:
|
|
"""For each discovered service, GET /shelly (non-Shelly on _http._tcp may fail probe)."""
|
|
timeout = mdns_timeout_sec()
|
|
endpoints = await browse_mdns(timeout_sec=timeout)
|
|
if not endpoints:
|
|
pytest.skip("No mDNS services (no devices or multicast blocked).")
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
ok = 0
|
|
for ep in endpoints:
|
|
r = await probe_ip(session, ep.ip, port=ep.port)
|
|
if r:
|
|
gen, data = r
|
|
assert isinstance(gen, int)
|
|
assert isinstance(data, dict)
|
|
ok += 1
|
|
print(f"\n[integration] probe OK {ep.ip}:{ep.port} gen={gen} mac={data.get('mac')}")
|
|
print(f"\n[integration] successful /shelly probes: {ok}/{len(endpoints)}")
|
|
|
|
|
|
@requires_integration
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_real_read_snapshot_first_unauthenticated_device() -> None:
|
|
"""Full aioshelly read for first device that answers /shelly without auth."""
|
|
timeout = mdns_timeout_sec()
|
|
endpoints = await browse_mdns(timeout_sec=timeout)
|
|
if not endpoints:
|
|
pytest.skip("No mDNS endpoints.")
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
chosen: tuple[DiscoveredEndpoint, int] | None = None
|
|
for ep in endpoints:
|
|
r = await probe_ip(session, ep.ip, port=ep.port)
|
|
if not r:
|
|
continue
|
|
gen, data = r
|
|
if data.get("auth") or data.get("auth_en"):
|
|
print(f"\n[integration] skip auth device {ep.ip}")
|
|
continue
|
|
chosen = (ep, gen)
|
|
break
|
|
if chosen is None:
|
|
for ep in endpoints:
|
|
r = await probe_ip(session, ep.ip, port=ep.port)
|
|
if r:
|
|
chosen = (ep, r[0])
|
|
break
|
|
if chosen is None:
|
|
pytest.skip("No /shelly response on mDNS endpoints.")
|
|
|
|
ep, gen = chosen
|
|
try:
|
|
async with ShellyRuntime() as runtime:
|
|
snap = await fetch_device_snapshot(runtime, ep.ip, gen)
|
|
except Exception as exc: # noqa: BLE001
|
|
pytest.skip(f"Snapshot read failed (network/gen/COAP): {exc}")
|
|
|
|
assert snap.mac
|
|
assert snap.ip == ep.ip
|
|
print(f"\n[integration] snapshot OK id={snap.id} model={snap.model} gen={snap.generation}")
|
|
|
|
|
|
@requires_integration
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_real_subnet_scan_when_env_set() -> None:
|
|
"""Optional subnet scan — set SHELLY_SUBNET."""
|
|
cidr = subnet_cidr()
|
|
if not cidr:
|
|
pytest.skip("Set SHELLY_SUBNET e.g. 192.168.1.0/24 to run subnet scan.")
|
|
|
|
conc = int(os.environ.get("SHELLY_SCAN_CONCURRENCY", "48"))
|
|
async with aiohttp.ClientSession() as session:
|
|
results = await scan_subnet_http(
|
|
session,
|
|
cidr,
|
|
concurrency=min(conc, 128),
|
|
)
|
|
print(f"\n[integration] subnet {cidr}: {len(results)} Shelly /shelly responses")
|
|
for ip, data in results[:10]:
|
|
print(f" - {ip} mac={data.get('mac')} gen={data.get('gen', '1')}")
|
|
assert isinstance(results, list)
|
|
|
|
|
|
@requires_integration
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_real_fetch_shelly_json_direct_ip() -> None:
|
|
"""If SHELLY_TEST_IP is set, GET /shelly on that host:port."""
|
|
raw = os.environ.get("SHELLY_TEST_IP", "").strip()
|
|
if not raw:
|
|
pytest.skip("Set SHELLY_TEST_IP=192.168.1.42 or [fe80::1]:80 for direct probe.")
|
|
|
|
host, port = _parse_host_port(raw)
|
|
async with aiohttp.ClientSession() as session:
|
|
data = await fetch_shelly_json(session, host, port=port, timeout_sec=3.0)
|
|
assert data is not None
|
|
assert isinstance(data, dict)
|
|
print(f"\n[integration] SHELLY_TEST_IP {host}:{port} -> keys {list(data.keys())[:8]}")
|