Initial commit: Shelly Manager with Textual CLI, Streamlit UI, and comprehensive .gitignore
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.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
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]}")
|
||||
Reference in New Issue
Block a user