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 @@
|
||||
"""Test package (enables imports from tests.*)."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Pytest fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shelly_manager.core.config import AppConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_data_dir() -> Path:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
yield Path(d)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_config_sqlite(tmp_data_dir: Path) -> AppConfig:
|
||||
return AppConfig(
|
||||
storage_backend="sqlite",
|
||||
sqlite_path=tmp_data_dir / "test.db",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_config_markdown(tmp_data_dir: Path) -> AppConfig:
|
||||
return AppConfig(
|
||||
storage_backend="markdown",
|
||||
markdown_dir=tmp_data_dir / "md",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""LAN integration tests (opt-in via SHELLY_INTEGRATION=1)."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Integration test configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def integration_enabled() -> bool:
|
||||
return os.environ.get("SHELLY_INTEGRATION", "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def mdns_timeout_sec() -> float:
|
||||
raw = os.environ.get("SHELLY_MDNS_SECONDS", "6")
|
||||
try:
|
||||
return max(2.0, float(raw))
|
||||
except ValueError:
|
||||
return 6.0
|
||||
|
||||
|
||||
def subnet_cidr() -> str | None:
|
||||
c = os.environ.get("SHELLY_SUBNET", "").strip()
|
||||
return c or None
|
||||
|
||||
|
||||
requires_integration = pytest.mark.skipif(
|
||||
not integration_enabled(),
|
||||
reason="Set SHELLY_INTEGRATION=1 to run LAN tests (see README).",
|
||||
)
|
||||
@@ -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]}")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""config_patch deep merge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shelly_manager.core.config_patch import deep_merge_dict
|
||||
|
||||
|
||||
def test_deep_merge_nested() -> None:
|
||||
base = {"mqtt": {"enable": True, "server": "old"}, "sys": {"debug": 0}}
|
||||
patch = {"mqtt": {"server": "new", "enable": False}}
|
||||
out = deep_merge_dict(base, patch)
|
||||
assert out["mqtt"]["server"] == "new"
|
||||
assert out["mqtt"]["enable"] is False
|
||||
assert out["sys"]["debug"] == 0
|
||||
|
||||
|
||||
def test_deep_merge_adds_key() -> None:
|
||||
out = deep_merge_dict({"a": 1}, {"b": {"c": 2}})
|
||||
assert out == {"a": 1, "b": {"c": 2}}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Config snapshot unified diff (GetConfig history)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from shelly_manager.core.config_snapshot_diff import (
|
||||
pick_older_newer,
|
||||
settings_unified_diff_text,
|
||||
sort_snapshots_chronologically,
|
||||
)
|
||||
from shelly_manager.core.models import ConfigSnapshot
|
||||
|
||||
|
||||
def _snap(
|
||||
*,
|
||||
sid: str,
|
||||
created: datetime,
|
||||
label: str,
|
||||
settings: dict,
|
||||
) -> ConfigSnapshot:
|
||||
return ConfigSnapshot(
|
||||
id=sid,
|
||||
device_id="DEV",
|
||||
created_at=created,
|
||||
label=label,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
def test_sort_snapshots_chronologically_oldest_first() -> None:
|
||||
t0 = datetime(2026, 3, 20, 0, 37, tzinfo=UTC)
|
||||
t1 = datetime(2026, 3, 20, 0, 42, tzinfo=UTC)
|
||||
a = _snap(sid="a", created=t1, label="newer", settings={})
|
||||
b = _snap(sid="b", created=t0, label="older", settings={})
|
||||
out = sort_snapshots_chronologically([a, b])
|
||||
assert [x.label for x in out] == ["older", "newer"]
|
||||
|
||||
|
||||
def test_pick_older_newer_swaps_when_user_picks_newer_as_a() -> None:
|
||||
"""Oldest-first list: index 0 older, index 1 newer — if user picks A=1 B=0, still get older→newer."""
|
||||
t0 = datetime(2026, 3, 20, 0, 37, tzinfo=UTC)
|
||||
t1 = datetime(2026, 3, 20, 0, 42, tzinfo=UTC)
|
||||
snaps = [
|
||||
_snap(sid="b", created=t0, label="older", settings={"x": 1}),
|
||||
_snap(sid="a", created=t1, label="newer", settings={"x": 2}),
|
||||
]
|
||||
older, newer = pick_older_newer(snaps, 1, 0)
|
||||
assert older.label == "older"
|
||||
assert newer.label == "newer"
|
||||
|
||||
|
||||
def test_settings_unified_diff_text_has_line_separated_structure() -> None:
|
||||
"""Regression: diff must contain newlines so each +/- line is separate (not one wrapped blob)."""
|
||||
old = {"ble": {"enable": True}}
|
||||
new = {"ble": {"enable": False}}
|
||||
text = settings_unified_diff_text(old, new, fromfile="older/settings.json", tofile="newer/settings.json")
|
||||
assert text.count("\n") >= 4
|
||||
lines = text.splitlines()
|
||||
assert lines[0].startswith("--- ")
|
||||
assert lines[1].startswith("+++ ")
|
||||
assert any(line.startswith("@@") for line in lines)
|
||||
# Every content/hunk line must be its own line (no embedded @@ mid-line)
|
||||
for line in lines:
|
||||
assert "\n" not in line
|
||||
minus_lines = [ln for ln in lines if ln.startswith("-") and not ln.startswith("---")]
|
||||
plus_lines = [ln for ln in lines if ln.startswith("+") and not ln.startswith("+++")]
|
||||
assert minus_lines
|
||||
assert plus_lines
|
||||
|
||||
|
||||
def test_settings_unified_diff_identical_empty() -> None:
|
||||
cfg = {"a": 1}
|
||||
assert settings_unified_diff_text(cfg, cfg) == ""
|
||||
|
||||
|
||||
def test_pick_older_newer_same_timestamp_tie_break() -> None:
|
||||
t = datetime(2026, 3, 20, 12, 0, tzinfo=UTC)
|
||||
s1 = _snap(sid="aaa", created=t, label="first", settings={})
|
||||
s2 = _snap(sid="zzz", created=t, label="second", settings={})
|
||||
snaps = sort_snapshots_chronologically([s2, s1])
|
||||
older, newer = pick_older_newer(snaps, 0, 1)
|
||||
assert older.id == "aaa"
|
||||
assert newer.id == "zzz"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Dashboard inventory statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from shelly_manager.core.models import ShellyDevice
|
||||
from shelly_manager.ui.dashboard_stats import compute_inventory_stats
|
||||
|
||||
|
||||
def _d(
|
||||
i: str,
|
||||
*,
|
||||
gen: int = 2,
|
||||
online: bool = True,
|
||||
auth: bool = False,
|
||||
model: str = "SNSW-001P16EU",
|
||||
caps: list[str] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> ShellyDevice:
|
||||
return ShellyDevice(
|
||||
id=i,
|
||||
name=None,
|
||||
mac=f"AA:BB:CC:DD:EE:{i[:2]}",
|
||||
ip=f"192.168.1.{i}",
|
||||
generation=gen, # type: ignore[arg-type]
|
||||
model=model,
|
||||
firmware="1",
|
||||
online=online,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=caps or [],
|
||||
status={"sys": {"restart_required": False}},
|
||||
settings={},
|
||||
tags=tags or [],
|
||||
auth_required=auth,
|
||||
)
|
||||
|
||||
|
||||
def test_compute_inventory_stats_empty() -> None:
|
||||
s = compute_inventory_stats([])
|
||||
assert s.total == 0
|
||||
assert s.online == 0
|
||||
|
||||
|
||||
def test_compute_inventory_stats_mix() -> None:
|
||||
devices = [
|
||||
_d("01", gen=1, online=True, model="SHSW-1"),
|
||||
_d("02", gen=2, online=False, tags=["floor1"]),
|
||||
_d("03", gen=3, caps=["switch", "meter"]),
|
||||
]
|
||||
s = compute_inventory_stats(devices)
|
||||
assert s.total == 3
|
||||
assert s.online == 2
|
||||
assert s.offline == 1
|
||||
assert s.gen1 == 1 and s.gen2 == 1 and s.gen3 == 1
|
||||
assert s.devices_with_tags == 1
|
||||
assert s.unique_tag_labels == 1
|
||||
assert len(s.top_models) >= 2
|
||||
@@ -0,0 +1,147 @@
|
||||
"""DeviceManager with discovery mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelly_manager.core.device_manager import DeviceManager, shelly_from_probe
|
||||
from shelly_manager.core.models import DiscoveredEndpoint, ShellyDevice
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shelly_from_probe_gen2() -> None:
|
||||
d = shelly_from_probe(
|
||||
"10.0.0.2",
|
||||
2,
|
||||
{"gen": 2, "mac": "AABBCCDDEEFF", "model": "Plus1", "fw_id": "x", "id": "shellyplus1-abc"},
|
||||
)
|
||||
assert d.generation == 2
|
||||
assert d.ip == "10.0.0.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_all_persists_after_mdns_probe_and_enrich(
|
||||
app_config_sqlite,
|
||||
) -> None:
|
||||
dm = DeviceManager(app_config_sqlite)
|
||||
|
||||
enriched = ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name="enriched",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="10.0.0.1",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=["switch"],
|
||||
status={"ok": True},
|
||||
settings={"sys": {}},
|
||||
)
|
||||
|
||||
ep = DiscoveredEndpoint(ip="10.0.0.1", port=80, source="mdns_http", txt={})
|
||||
probe_payload = {
|
||||
"gen": 2,
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"model": "Plus1",
|
||||
"fw_id": "1",
|
||||
"id": "shellyplus1-abc",
|
||||
}
|
||||
|
||||
fake_rt = MagicMock()
|
||||
fake_rt.__aenter__ = AsyncMock(return_value=fake_rt)
|
||||
fake_rt.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("shelly_manager.core.device_manager.browse_mdns", new_callable=AsyncMock) as mdns,
|
||||
patch("shelly_manager.core.device_manager.probe_ip", new_callable=AsyncMock) as probe,
|
||||
patch(
|
||||
"shelly_manager.core.device_manager.fetch_device_snapshot",
|
||||
new_callable=AsyncMock,
|
||||
) as enrich,
|
||||
patch("shelly_manager.core.device_manager.ShellyRuntime", return_value=fake_rt),
|
||||
):
|
||||
mdns.return_value = [ep]
|
||||
probe.return_value = (2, probe_payload)
|
||||
enrich.return_value = enriched
|
||||
|
||||
sess = MagicMock()
|
||||
await dm.discover_all(session=sess)
|
||||
|
||||
stored = await dm.storage.get_device("AABBCCDDEEFF")
|
||||
assert stored is not None
|
||||
assert stored.name == "enriched"
|
||||
assert stored.status.get("ok") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_device_by_ip_invalid(app_config_sqlite) -> None:
|
||||
dm = DeviceManager(app_config_sqlite)
|
||||
dev, err = await dm.add_device_by_ip("not-an-ip")
|
||||
assert dev is None
|
||||
assert err is not None
|
||||
assert "Invalid" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_device_by_ip_no_shelly_json(app_config_sqlite) -> None:
|
||||
dm = DeviceManager(app_config_sqlite)
|
||||
with patch("shelly_manager.core.device_manager.fetch_shelly_json", new_callable=AsyncMock) as fj:
|
||||
fj.return_value = None
|
||||
sess = MagicMock()
|
||||
dev, err = await dm.add_device_by_ip("192.168.1.1", session=sess)
|
||||
assert dev is None
|
||||
assert err is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_device_by_ip_persists_after_enrich(app_config_sqlite) -> None:
|
||||
dm = DeviceManager(app_config_sqlite)
|
||||
enriched = ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name="enriched",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="10.0.0.5",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=["switch"],
|
||||
status={"ok": True},
|
||||
settings={"sys": {}},
|
||||
)
|
||||
probe_payload = {
|
||||
"gen": 2,
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"model": "Plus1",
|
||||
"fw_id": "1",
|
||||
"id": "shellyplus1-abc",
|
||||
}
|
||||
fake_rt = MagicMock()
|
||||
fake_rt.__aenter__ = AsyncMock(return_value=fake_rt)
|
||||
fake_rt.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("shelly_manager.core.device_manager.fetch_shelly_json", new_callable=AsyncMock) as fj,
|
||||
patch(
|
||||
"shelly_manager.core.device_manager.fetch_device_snapshot",
|
||||
new_callable=AsyncMock,
|
||||
) as enrich,
|
||||
patch("shelly_manager.core.device_manager.ShellyRuntime", return_value=fake_rt),
|
||||
):
|
||||
fj.return_value = probe_payload
|
||||
enrich.return_value = enriched
|
||||
sess = MagicMock()
|
||||
dev, err = await dm.add_device_by_ip("10.0.0.5", session=sess)
|
||||
|
||||
assert err is None
|
||||
assert dev is not None
|
||||
assert dev.name == "enriched"
|
||||
stored = await dm.storage.get_device("AABBCCDDEEFF")
|
||||
assert stored is not None
|
||||
assert stored.status.get("ok") is True
|
||||
@@ -0,0 +1,29 @@
|
||||
"""CIDR host listing for subnet discovery (incl. /32 fix)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
|
||||
from shelly_manager.core.discovery import (
|
||||
cidr_probe_host_list,
|
||||
hosts_for_ip_network,
|
||||
)
|
||||
|
||||
|
||||
def test_hosts_for_ip_network_slash_32_includes_address() -> None:
|
||||
net = ipaddress.ip_network("192.168.23.120/32", strict=False)
|
||||
assert net.num_addresses == 1
|
||||
assert hosts_for_ip_network(net) == ["192.168.23.120"]
|
||||
|
||||
|
||||
def test_cidr_probe_host_list_slash_24() -> None:
|
||||
hosts, ok = cidr_probe_host_list("192.168.23.0/24")
|
||||
assert ok
|
||||
assert "192.168.23.120" in hosts
|
||||
assert "192.168.23.1" in hosts
|
||||
|
||||
|
||||
def test_cidr_probe_host_list_invalid() -> None:
|
||||
hosts, ok = cidr_probe_host_list("not-a-cidr")
|
||||
assert not ok
|
||||
assert hosts == []
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Subnet scan with mocked /shelly responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_subnet_finds_shelly() -> None:
|
||||
async def fetch_impl(
|
||||
session: aiohttp.ClientSession,
|
||||
ip: str,
|
||||
port: int = 80,
|
||||
timeout_sec: float = 2.0,
|
||||
):
|
||||
if ip == "192.168.1.5":
|
||||
return {"type": "SHSW-21", "mac": "AABBCCDDEEFF", "fw": "1.0"}
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"shelly_manager.core.discovery.fetch_shelly_json",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fetch_impl,
|
||||
):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
from shelly_manager.core.discovery import scan_subnet_http
|
||||
|
||||
results = await scan_subnet_http(session, "192.168.1.0/29", concurrency=8)
|
||||
|
||||
ips = {r[0] for r in results}
|
||||
assert "192.168.1.5" in ips
|
||||
assert any("AABB" in r[1].get("mac", "") for r in results)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Discovery helpers: IPv6 URLs, probe port, mDNS task drain (unit-level)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelly_manager.core.urls import device_http_url, http_host_for_url
|
||||
|
||||
|
||||
def test_http_host_ipv4_unchanged() -> None:
|
||||
assert http_host_for_url("192.168.1.1") == "192.168.1.1"
|
||||
|
||||
|
||||
def test_http_host_ipv6_brackets() -> None:
|
||||
assert http_host_for_url("fe80::1") == "[fe80::1]"
|
||||
assert http_host_for_url("fe80::1%en0") == "[fe80::1]"
|
||||
|
||||
|
||||
def test_device_http_url_default_port() -> None:
|
||||
assert device_http_url("192.168.1.5") == "http://192.168.1.5/"
|
||||
|
||||
|
||||
def test_device_http_url_ipv6_and_port() -> None:
|
||||
assert device_http_url("fe80::1", port=8080) == "http://[fe80::1]:8080/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_ip_passes_port_to_fetch() -> None:
|
||||
from shelly_manager.api.client import probe_ip
|
||||
|
||||
with patch(
|
||||
"shelly_manager.api.client.fetch_shelly_json",
|
||||
new_callable=AsyncMock,
|
||||
) as fetch:
|
||||
fetch.return_value = {"type": "SHSW-21", "mac": "AA"}
|
||||
session = MagicMock()
|
||||
out = await probe_ip(session, "10.0.0.1", port=8080)
|
||||
fetch.assert_called_once_with(session, "10.0.0.1", port=8080, timeout_sec=3.0)
|
||||
assert out is not None
|
||||
assert out[0] == 1
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Gen2 config RPC mapping (no device)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shelly_manager.api.gen2_config_rpc import (
|
||||
iter_changed_top_level_keys,
|
||||
set_config_method_and_params,
|
||||
)
|
||||
|
||||
|
||||
def test_set_config_switch_indexed() -> None:
|
||||
m, p = set_config_method_and_params("switch:0", {"name": "R1"})
|
||||
assert m == "Switch.SetConfig"
|
||||
assert p == {"id": 0, "config": {"name": "R1"}}
|
||||
|
||||
|
||||
def test_set_config_sys_singleton() -> None:
|
||||
m, p = set_config_method_and_params("sys", {"device": {"name": "x"}})
|
||||
assert m == "Sys.SetConfig"
|
||||
assert p == {"config": {"device": {"name": "x"}}}
|
||||
|
||||
|
||||
def test_set_config_wifi() -> None:
|
||||
m, p = set_config_method_and_params("wifi", {"sta": {"ssid": "a"}})
|
||||
assert m == "WiFi.SetConfig"
|
||||
|
||||
|
||||
def test_iter_changed_top_level_keys() -> None:
|
||||
old = {"sys": {"a": 1}, "wifi": {"x": 1}}
|
||||
new = {"sys": {"a": 1}, "wifi": {"x": 2}}
|
||||
assert iter_changed_top_level_keys(old, new) == ["wifi"]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Guard against circular imports (e.g. api.client ↔ core.device_manager)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def test_mass_config_page_imports_cleanly() -> None:
|
||||
"""Importing the Mass Config page must not hit partially initialized api.client."""
|
||||
importlib.import_module("shelly_manager.ui.pages.3_Mass_Config")
|
||||
|
||||
|
||||
def test_core_package_lazy_device_manager() -> None:
|
||||
"""DeviceManager is available via lazy attribute without eager import cycle."""
|
||||
import shelly_manager.core as core
|
||||
|
||||
assert core.DeviceManager is not None
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Inventory URL drill-down helpers."""
|
||||
|
||||
from shelly_manager.ui.inventory_query_params import inventory_drilldown_href
|
||||
|
||||
|
||||
def test_inventory_drilldown_href_builds_query() -> None:
|
||||
h = inventory_drilldown_href(preset="online")
|
||||
assert h.startswith("?")
|
||||
assert "preset=online" in h
|
||||
|
||||
h2 = inventory_drilldown_href(preset="model", model="Shelly Plus 1")
|
||||
assert "preset=model" in h2
|
||||
assert "model=" in h2
|
||||
|
||||
h3 = inventory_drilldown_href(cap="relay")
|
||||
assert "cap=relay" in h3
|
||||
@@ -0,0 +1,29 @@
|
||||
"""json_path helper tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shelly_manager.core.json_path import get_json_path, is_missing
|
||||
|
||||
|
||||
def test_get_json_path_nested() -> None:
|
||||
d = {"ble": {"enable": True, "rpc": {"enable": False}}}
|
||||
assert get_json_path(d, "ble.enable") is True
|
||||
assert get_json_path(d, "ble.rpc.enable") is False
|
||||
|
||||
|
||||
def test_get_json_path_missing() -> None:
|
||||
assert is_missing(get_json_path({}, "ble.enable"))
|
||||
|
||||
|
||||
def test_get_json_path_empty() -> None:
|
||||
assert is_missing(get_json_path(None, "a")) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_get_json_path_pipe_segment() -> None:
|
||||
d = {"switch:0": {"output": True}}
|
||||
assert get_json_path(d, "switch:0|output") is True
|
||||
|
||||
|
||||
def test_get_json_path_list_index() -> None:
|
||||
d = {"relays": [{"ison": True}]}
|
||||
assert get_json_path(d, "relays.0.ison") is True
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Mass apply respects :attr:`DeviceFilter.only_device_ids`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelly_manager.api.mass_config_apply import _HANDLERS # noqa: SLF001
|
||||
from shelly_manager.core.device_manager import DeviceManager
|
||||
from shelly_manager.core.mass_config import MASS_CONFIG_OPERATIONS
|
||||
from shelly_manager.core.models import DeviceFilter, ShellyDevice
|
||||
|
||||
|
||||
def _dev(i: str, gen: int = 2) -> ShellyDevice:
|
||||
return ShellyDevice(
|
||||
id=i,
|
||||
name=None,
|
||||
mac=f"AA:BB:CC:DD:EE:{i[:2]}",
|
||||
ip=f"192.168.1.{i}",
|
||||
generation=gen, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mass_apply_operation_only_device_ids_filters_list(app_config_sqlite) -> None:
|
||||
dm = DeviceManager(app_config_sqlite)
|
||||
a, b = _dev("AABBCCDDEE01"), _dev("AABBCCDDEE02")
|
||||
await dm.storage.save_device(a)
|
||||
await dm.storage.save_device(b)
|
||||
|
||||
fake_rt = MagicMock()
|
||||
fake_rt.__aenter__ = AsyncMock(return_value=fake_rt)
|
||||
fake_rt.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_apply(runtime, device, operation_id):
|
||||
from shelly_manager.api.mass_config_apply import MassApplyResult
|
||||
|
||||
calls.append(device.id)
|
||||
return MassApplyResult(
|
||||
device_id=device.id,
|
||||
display_name=device.display_name,
|
||||
status="skipped",
|
||||
detail="mock",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("shelly_manager.core.device_manager.ShellyRuntime", return_value=fake_rt),
|
||||
patch(
|
||||
"shelly_manager.core.device_manager.apply_mass_operation",
|
||||
side_effect=fake_apply,
|
||||
),
|
||||
):
|
||||
await dm.mass_apply_operation(
|
||||
DeviceFilter(only_device_ids=["AABBCCDDEE01"]),
|
||||
"ble_disable",
|
||||
snapshot_before=False,
|
||||
)
|
||||
|
||||
assert calls == ["AABBCCDDEE01"]
|
||||
|
||||
|
||||
def test_all_rpc_mass_config_operations_have_handlers() -> None:
|
||||
rpc_ids = {o.id for o in MASS_CONFIG_OPERATIONS if o.kind == "rpc_config"}
|
||||
assert rpc_ids == set(_HANDLERS.keys())
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Mass config helper filters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from shelly_manager.core.models import ShellyDevice
|
||||
from shelly_manager.ui.mass_config_helpers import (
|
||||
apply_filters,
|
||||
device_to_row,
|
||||
firmware_update_category,
|
||||
firmware_update_display,
|
||||
prepare_table_rows_for_display,
|
||||
)
|
||||
|
||||
|
||||
def _dev(name: str, gen: int, online: bool) -> ShellyDevice:
|
||||
return ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name=name,
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="192.168.1.10",
|
||||
generation=gen, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=online,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=["switch"],
|
||||
status={},
|
||||
settings={"ble": {"enable": True}},
|
||||
tags=["t1"],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_filters_name() -> None:
|
||||
a = _dev("Kitchen", 2, True)
|
||||
b = _dev("Garage", 2, True)
|
||||
rows = [device_to_row(a), device_to_row(b)]
|
||||
out = apply_filters(rows, name_q="kit")
|
||||
assert len(out) == 1
|
||||
assert out[0]["name"] == "Kitchen"
|
||||
|
||||
|
||||
def test_needs_reboot_column_and_filter() -> None:
|
||||
yes = ShellyDevice(
|
||||
id="AA",
|
||||
name="A",
|
||||
mac="AA:BB:CC:DD:EE:01",
|
||||
ip="192.168.1.1",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={"sys": {"restart_required": True}},
|
||||
settings={},
|
||||
)
|
||||
no = ShellyDevice(
|
||||
id="BB",
|
||||
name="B",
|
||||
mac="AA:BB:CC:DD:EE:02",
|
||||
ip="192.168.1.2",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={"sys": {"restart_required": False}},
|
||||
settings={},
|
||||
)
|
||||
ra, rb = device_to_row(yes), device_to_row(no)
|
||||
assert ra["needs_reboot"] == "yes"
|
||||
assert rb["needs_reboot"] == "no"
|
||||
rows = [ra, rb]
|
||||
assert len(apply_filters(rows, needs_reboot="yes")) == 1
|
||||
assert apply_filters(rows, needs_reboot="yes")[0]["device_id"] == "AA"
|
||||
|
||||
|
||||
def test_apply_filters_versions_q() -> None:
|
||||
a = _dev("Kitchen", 2, True)
|
||||
rows = [
|
||||
device_to_row(a, version_summaries=["2026-03-19 12:00 — mass:ble_disable"]),
|
||||
device_to_row(_dev("Other", 2, True)),
|
||||
]
|
||||
out = apply_filters(rows, versions_q="mass:ble")
|
||||
assert len(out) == 1
|
||||
assert "mass:ble" in out[0]["versions"]
|
||||
|
||||
|
||||
def test_firmware_update_column_and_filter() -> None:
|
||||
gen1 = _dev("G1", 1, True)
|
||||
unchecked = ShellyDevice(
|
||||
id="U1",
|
||||
name="U",
|
||||
mac="AA:BB:CC:DD:EE:03",
|
||||
ip="192.168.1.3",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
firmware_check_result=None,
|
||||
)
|
||||
uptodate = ShellyDevice(
|
||||
id="U2",
|
||||
name="U2",
|
||||
mac="AA:BB:CC:DD:EE:04",
|
||||
ip="192.168.1.4",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
firmware_check_result={},
|
||||
)
|
||||
offer = ShellyDevice(
|
||||
id="U3",
|
||||
name="U3",
|
||||
mac="AA:BB:CC:DD:EE:05",
|
||||
ip="192.168.1.5",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
firmware_check_result={"stable": {"version": "2.0.0", "build_id": "abc"}},
|
||||
)
|
||||
assert firmware_update_display(gen1) == "N/A"
|
||||
assert firmware_update_display(unchecked) == "not checked"
|
||||
assert firmware_update_display(uptodate) == "up to date"
|
||||
assert "stable 2.0.0" in firmware_update_display(offer)
|
||||
|
||||
rows = [device_to_row(gen1), device_to_row(unchecked), device_to_row(uptodate), device_to_row(offer)]
|
||||
assert len(apply_filters(rows, fw_update_filter="N/A")) == 1
|
||||
assert len(apply_filters(rows, fw_update_filter="not checked")) == 1
|
||||
assert len(apply_filters(rows, fw_update_filter="has_stable")) == 1
|
||||
assert len(apply_filters(rows, fw_update_filter="has update")) == 1 # legacy alias → has_stable
|
||||
|
||||
|
||||
def test_apply_filters_tags_mode() -> None:
|
||||
tagged = _dev("T", 2, True)
|
||||
tagged.tags = ["a"]
|
||||
untagged = _dev("U", 2, True)
|
||||
untagged.tags = []
|
||||
rows = [device_to_row(tagged), device_to_row(untagged)]
|
||||
assert len(apply_filters(rows, tags_mode="has_tags")) == 1
|
||||
assert apply_filters(rows, tags_mode="has_tags")[0]["name"] == "T"
|
||||
assert len(apply_filters(rows, tags_mode="none")) == 1
|
||||
|
||||
|
||||
def test_apply_filters_fw_update_any_of() -> None:
|
||||
gen1 = _dev("G1", 1, True)
|
||||
auth = _dev("AU", 2, True)
|
||||
auth.auth_required = True
|
||||
rows = [device_to_row(gen1), device_to_row(auth)]
|
||||
out = apply_filters(rows, fw_update_any_of=["N/A", "—"])
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_firmware_offer_uses_sys_available_updates_when_not_checked() -> None:
|
||||
"""GetStatus `sys.available_updates` matches device web UI without explicit CheckForUpdate."""
|
||||
d = ShellyDevice(
|
||||
id="XX",
|
||||
name="X",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="192.168.1.1",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="SNSW-001P16EU",
|
||||
firmware="1.7.1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={
|
||||
"sys": {
|
||||
"available_updates": {
|
||||
"stable": {"version": "1.7.4"},
|
||||
}
|
||||
}
|
||||
},
|
||||
settings={},
|
||||
firmware_check_result=None,
|
||||
)
|
||||
assert firmware_update_category(d) == "has_stable"
|
||||
assert "1.7.4" in firmware_update_display(d)
|
||||
|
||||
|
||||
def test_firmware_beta_only_category_and_filters() -> None:
|
||||
beta_only = ShellyDevice(
|
||||
id="B1",
|
||||
name="B",
|
||||
mac="AA:BB:CC:DD:EE:99",
|
||||
ip="192.168.1.99",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
firmware_check_result={"beta": {"version": "2.1.0-beta", "build_id": "b1"}},
|
||||
)
|
||||
assert firmware_update_category(beta_only) == "has_beta_only"
|
||||
assert "beta" in firmware_update_display(beta_only).lower()
|
||||
assert "(beta only)" in firmware_update_display(beta_only)
|
||||
row = device_to_row(beta_only)
|
||||
assert row["_fw_update_cat"] == "has_beta_only"
|
||||
assert len(apply_filters([row], fw_update_filter="has_stable")) == 0
|
||||
assert len(apply_filters([row], fw_update_filter="has_beta_only")) == 1
|
||||
assert len(apply_filters([row], fw_update_filter="has_any")) == 1
|
||||
|
||||
|
||||
def test_prepare_table_rows_for_display_name_and_ip_links() -> None:
|
||||
a = _dev("Kitchen Light", 2, True)
|
||||
row = device_to_row(a, version_summaries=["snap-a", "snap-b"])
|
||||
assert row["versions"] == "snap-a · snap-b"
|
||||
assert "link" not in row
|
||||
assert row["ip_http_url"].startswith("http://")
|
||||
[prep] = prepare_table_rows_for_display([row])
|
||||
assert "_fw_update_cat" not in prep
|
||||
assert prep["name"].startswith("/Device?device=AABBCCDDEEFF&label=")
|
||||
assert "Kitchen" in prep["name"] or "%20" in prep["name"]
|
||||
assert prep["ip"] == row["ip_http_url"]
|
||||
assert "ip_http_url" not in prep
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Mass config metadata registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shelly_manager.core.mass_config import (
|
||||
get_mass_operation,
|
||||
get_settings_preset,
|
||||
get_status_preset,
|
||||
)
|
||||
|
||||
|
||||
def test_ble_disable_operation_registered() -> None:
|
||||
op = get_mass_operation("ble_disable")
|
||||
assert op is not None
|
||||
assert op.kind == "rpc_config"
|
||||
assert 2 in op.supported_generations
|
||||
assert op.suggested_preset_id == "ble_enabled"
|
||||
|
||||
|
||||
def test_reboot_operation_registered() -> None:
|
||||
op = get_mass_operation("reboot")
|
||||
assert op is not None
|
||||
assert op.kind == "reboot"
|
||||
assert op.suggested_status_preset_id == "g2_needs_reboot"
|
||||
|
||||
|
||||
def test_g2_needs_reboot_status_preset() -> None:
|
||||
p = get_status_preset("g2_needs_reboot")
|
||||
assert p is not None
|
||||
assert "restart" in p.status_path.lower()
|
||||
|
||||
|
||||
def test_ble_enabled_preset() -> None:
|
||||
p = get_settings_preset("ble_enabled")
|
||||
assert p is not None
|
||||
assert p.settings_path == "ble.enable"
|
||||
assert p.settings_match == "truthy"
|
||||
|
||||
|
||||
def test_status_preset_gen2_switch() -> None:
|
||||
from shelly_manager.core.mass_config import get_status_preset
|
||||
|
||||
p = get_status_preset("g2_sw0_on")
|
||||
assert p is not None
|
||||
assert "switch:0" in p.status_path
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shelly internal model code → display names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from shelly_manager.core.model_names import (
|
||||
format_model_for_ui,
|
||||
format_model_plain,
|
||||
shelly_model_display_name,
|
||||
)
|
||||
from shelly_manager.core.models import ShellyDevice
|
||||
|
||||
|
||||
def test_snsw_001p16eu_is_plus_1pm() -> None:
|
||||
assert shelly_model_display_name("SNSW-001P16EU") == "Plus 1PM"
|
||||
|
||||
|
||||
def test_unknown_model_returns_raw() -> None:
|
||||
assert shelly_model_display_name("UNKNOWN-XYZ") == "UNKNOWN-XYZ"
|
||||
|
||||
|
||||
def test_format_model_plain() -> None:
|
||||
assert "Plus 1PM" in format_model_plain("SNSW-001P16EU")
|
||||
assert "SNSW-001P16EU" in format_model_plain("SNSW-001P16EU")
|
||||
|
||||
|
||||
def test_shelly_device_model_label() -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name=None,
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="192.168.1.1",
|
||||
generation=2,
|
||||
model="SNSW-001P16EU",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
)
|
||||
assert d.model_display_name == "Plus 1PM"
|
||||
assert "Plus 1PM" in d.model_label
|
||||
assert "SNSW-001P16EU" in d.model_label
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Unit tests for models and helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from shelly_manager.core.discovery import classify_generation, normalize_mac
|
||||
from shelly_manager.core.models import (
|
||||
DeviceFilter,
|
||||
ShellyDevice,
|
||||
infer_capabilities,
|
||||
preserve_firmware_check_metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_classify_generation_gen1() -> None:
|
||||
assert classify_generation({"type": "SHSW-21", "mac": "A"}) == 1
|
||||
|
||||
|
||||
def test_classify_generation_gen2() -> None:
|
||||
assert classify_generation({"gen": 2, "model": "x"}) == 2
|
||||
assert classify_generation({"gen": 3, "model": "x"}) == 3
|
||||
|
||||
|
||||
def test_normalize_mac() -> None:
|
||||
assert normalize_mac("aa:bb:cc:dd:ee:ff") == "AABBCCDDEEFF"
|
||||
|
||||
|
||||
def test_device_filter_matches() -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABB",
|
||||
name="Kitchen",
|
||||
mac="AA:BB",
|
||||
ip="192.168.1.10",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=["switch"],
|
||||
status={},
|
||||
settings={},
|
||||
tags=["t1"],
|
||||
)
|
||||
assert DeviceFilter(generations=[2]).matches(d)
|
||||
assert not DeviceFilter(generations=[1]).matches(d)
|
||||
assert DeviceFilter(tags=["t1"]).matches(d)
|
||||
assert not DeviceFilter(tags=["missing"]).matches(d)
|
||||
assert DeviceFilter(name_contains="kit").matches(d)
|
||||
assert DeviceFilter(ip_prefix="192.168.1").matches(d)
|
||||
assert DeviceFilter(only_device_ids=["AABB"]).matches(d)
|
||||
assert not DeviceFilter(only_device_ids=["OTHER"]).matches(d)
|
||||
|
||||
|
||||
def test_device_filter_settings_truthy() -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABB",
|
||||
name="Kitchen",
|
||||
mac="AA:BB",
|
||||
ip="192.168.1.10",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={"ble": {"enable": True}},
|
||||
tags=[],
|
||||
)
|
||||
f = DeviceFilter(settings_path="ble.enable", settings_match="truthy")
|
||||
assert f.matches(d)
|
||||
d2 = d.model_copy(update={"settings": {"ble": {"enable": False}}})
|
||||
assert not f.matches(d2)
|
||||
|
||||
|
||||
def test_shelly_device_http_url() -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABB",
|
||||
name="Kitchen",
|
||||
mac="AA:BB",
|
||||
ip="192.168.1.10",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
tags=[],
|
||||
http_port=8080,
|
||||
)
|
||||
assert d.http_url == "http://192.168.1.10:8080/"
|
||||
|
||||
|
||||
def test_device_filter_capabilities_and_status() -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABB",
|
||||
name="Kitchen",
|
||||
mac="AA:BB",
|
||||
ip="192.168.1.10",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=["switch"],
|
||||
status={"switch:0": {"output": True}},
|
||||
settings={},
|
||||
tags=[],
|
||||
)
|
||||
assert DeviceFilter(capabilities_any=["switch"]).matches(d)
|
||||
assert not DeviceFilter(capabilities_any=["relay"]).matches(d)
|
||||
assert DeviceFilter(status_path="switch:0|output", status_match="truthy").matches(d)
|
||||
|
||||
|
||||
def test_device_filter_settings_falsy() -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABB",
|
||||
name="Kitchen",
|
||||
mac="AA:BB",
|
||||
ip="192.168.1.10",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
tags=[],
|
||||
)
|
||||
f = DeviceFilter(settings_path="ble.enable", settings_match="falsy")
|
||||
assert f.matches(d)
|
||||
|
||||
|
||||
def test_infer_capabilities_gen1_relay() -> None:
|
||||
caps = infer_capabilities(1, {"relays": [{"ison": True}]}, {})
|
||||
assert "relay" in caps
|
||||
|
||||
|
||||
def test_infer_capabilities_gen2_switch() -> None:
|
||||
caps = infer_capabilities(2, {"switch:0": {"output": True}}, {})
|
||||
assert "switch" in caps
|
||||
|
||||
|
||||
def test_preserve_firmware_check_metadata() -> None:
|
||||
ts = datetime.now(UTC)
|
||||
prev = ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name="A",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="192.168.1.1",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=ts,
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={},
|
||||
firmware_check_result={"stable": {"version": "1.7.4"}},
|
||||
firmware_check_at=ts,
|
||||
)
|
||||
fresh = ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name="A",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="192.168.1.1",
|
||||
generation=2, # type: ignore[arg-type]
|
||||
model="Plus1",
|
||||
firmware="1",
|
||||
online=True,
|
||||
last_seen=ts,
|
||||
capabilities=[],
|
||||
status={"sys": {}},
|
||||
settings={},
|
||||
firmware_check_result=None,
|
||||
firmware_check_at=None,
|
||||
)
|
||||
preserve_firmware_check_metadata(fresh, prev)
|
||||
assert fresh.firmware_check_result == prev.firmware_check_result
|
||||
assert fresh.firmware_check_at == prev.firmware_check_at
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Storage backend tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from shelly_manager.core.models import DeviceFilter, ShellyDevice
|
||||
from shelly_manager.storage.markdown_backend import MarkdownStorage
|
||||
from shelly_manager.storage.sqlite_backend import SqliteStorage
|
||||
|
||||
|
||||
def _sample_device() -> ShellyDevice:
|
||||
return ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name="Test",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="10.0.0.1",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.2.3",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=["switch"],
|
||||
status={"a": 1},
|
||||
settings={"b": 2},
|
||||
tags=["x"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_roundtrip(app_config_sqlite) -> None:
|
||||
s = SqliteStorage(app_config_sqlite.sqlite_path)
|
||||
d = _sample_device()
|
||||
await s.save_device(d)
|
||||
loaded = await s.get_device(d.id)
|
||||
assert loaded is not None
|
||||
assert loaded.ip == d.ip
|
||||
assert loaded.settings == d.settings
|
||||
lst = await s.list_devices(DeviceFilter(generations=[2]))
|
||||
assert len(lst) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_markdown_roundtrip(app_config_markdown) -> None:
|
||||
s = MarkdownStorage(app_config_markdown.markdown_dir)
|
||||
d = _sample_device()
|
||||
await s.save_device(d)
|
||||
loaded = await s.get_device(d.id)
|
||||
assert loaded is not None
|
||||
assert loaded.model == d.model
|
||||
lst = await s.list_devices(None)
|
||||
assert len(lst) == 1
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Streamlit AppTest — no live server (uses embedded script runner)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from shelly_manager.core.config import AppConfig
|
||||
from shelly_manager.core.models import ShellyDevice
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DASHBOARD_APP = ROOT / "src" / "shelly_manager" / "ui" / "Dashboard.py"
|
||||
MASS_CONFIG_PAGE = ROOT / "src" / "shelly_manager" / "ui" / "pages" / "3_Mass_Config.py"
|
||||
DEVICE_PAGE = ROOT / "src" / "shelly_manager" / "ui" / "pages" / "2_Device.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_manager() -> MagicMock:
|
||||
mgr = MagicMock()
|
||||
mgr.storage.list_devices = AsyncMock(return_value=[])
|
||||
mgr.storage.get_device = AsyncMock(return_value=None)
|
||||
mgr.storage.get_kv = AsyncMock(return_value=None)
|
||||
mgr.storage.set_kv = AsyncMock(return_value=None)
|
||||
mgr.storage.list_snapshots = AsyncMock(return_value=[])
|
||||
mgr.storage.recent_snapshot_labels_for_devices = AsyncMock(return_value={})
|
||||
mgr.discover_all = AsyncMock(return_value=[])
|
||||
mgr.refresh_all = AsyncMock(return_value=[])
|
||||
mgr.refresh_device = AsyncMock(return_value=None)
|
||||
mgr.mass_apply_operation = AsyncMock(return_value=[])
|
||||
mgr.apply_mass_tags = AsyncMock(return_value=0)
|
||||
mgr.apply_device_config = AsyncMock(return_value=[("sys", "ok", False)])
|
||||
mgr.reboot_device = AsyncMock(return_value=None)
|
||||
mgr.reboot_devices_by_ids = AsyncMock(return_value=[])
|
||||
mgr.check_firmware_update = AsyncMock(return_value={})
|
||||
mgr.check_firmware_updates_for_ids = AsyncMock(return_value=[])
|
||||
mgr.apply_firmware_update = AsyncMock(return_value=None)
|
||||
mgr.mass_apply_section_patch = AsyncMock(return_value=[])
|
||||
return mgr
|
||||
|
||||
|
||||
def test_dashboard_renders_title_and_metrics(mock_manager: MagicMock) -> None:
|
||||
with patch("shelly_manager.ui.Dashboard.get_manager", return_value=mock_manager):
|
||||
at = AppTest.from_file(str(DASHBOARD_APP), default_timeout=15)
|
||||
at.run(timeout=15)
|
||||
titles = [n.value for n in at.title]
|
||||
assert any("Shelly Manager" in (t or "") for t in titles)
|
||||
assert len(at.metric) >= 1
|
||||
labels = [m.label for m in at.metric]
|
||||
assert "Matching (after filters)" in labels
|
||||
md_blob = "\n".join((m.value or "") for m in at.markdown)
|
||||
assert "Total devices" in md_blob
|
||||
|
||||
|
||||
def test_mass_config_page_renders(mock_manager: MagicMock) -> None:
|
||||
with patch("shelly_manager.ui.state.get_manager", return_value=mock_manager):
|
||||
at = AppTest.from_file(str(MASS_CONFIG_PAGE), default_timeout=20)
|
||||
at.run(timeout=20)
|
||||
assert not at.exception
|
||||
titles = [n.value for n in at.title]
|
||||
assert any("Mass configuration" in (t or "") for t in titles)
|
||||
|
||||
|
||||
def test_device_detail_page_renders(mock_manager: MagicMock) -> None:
|
||||
d = ShellyDevice(
|
||||
id="AABBCCDDEEFF",
|
||||
name="Kitchen",
|
||||
mac="AA:BB:CC:DD:EE:FF",
|
||||
ip="192.168.1.10",
|
||||
generation=2,
|
||||
model="Plus1",
|
||||
firmware="1.0",
|
||||
online=True,
|
||||
last_seen=datetime.now(UTC),
|
||||
capabilities=[],
|
||||
status={},
|
||||
settings={"sys": {"device": {"name": "Test"}}},
|
||||
tags=[],
|
||||
)
|
||||
mock_manager.storage.list_devices = AsyncMock(return_value=[d])
|
||||
mock_manager.storage.get_device = AsyncMock(return_value=d)
|
||||
with (
|
||||
patch("shelly_manager.ui.state.get_manager", return_value=mock_manager),
|
||||
patch("shelly_manager.ui.state.get_config", return_value=AppConfig()),
|
||||
):
|
||||
at = AppTest.from_file(str(DEVICE_PAGE), default_timeout=20)
|
||||
at.run(timeout=20)
|
||||
assert not at.exception
|
||||
titles = [n.value for n in at.title]
|
||||
assert any("Device" in (t or "") for t in titles)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""UI AppConfig persistence (Streamlit state module)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from shelly_manager.core.config import AppConfig
|
||||
|
||||
|
||||
def test_app_config_roundtrip_json(tmp_path: Path) -> None:
|
||||
cfg = AppConfig(
|
||||
subnet_scan_cidr="192.168.23.0/24",
|
||||
discovery_http_timeout_sec=4.5,
|
||||
sqlite_path=tmp_path / "db.sqlite",
|
||||
markdown_dir=tmp_path / "md",
|
||||
)
|
||||
p = tmp_path / "ui.json"
|
||||
p.write_text(cfg.model_dump_json(indent=2), encoding="utf-8")
|
||||
loaded = AppConfig.model_validate_json(p.read_text(encoding="utf-8"))
|
||||
assert loaded.subnet_scan_cidr == "192.168.23.0/24"
|
||||
assert loaded.discovery_http_timeout_sec == 4.5
|
||||
|
||||
|
||||
def test_persist_path_respects_env(tmp_path: Path) -> None:
|
||||
target = tmp_path / "x" / "cfg.json"
|
||||
with patch.dict(os.environ, {"SHELLY_UI_CONFIG": str(target)}, clear=False):
|
||||
from shelly_manager.ui import state as state_mod
|
||||
|
||||
assert state_mod._persist_path() == target
|
||||
|
||||
|
||||
def test_load_config_from_disk_returns_none_if_missing(tmp_path: Path) -> None:
|
||||
with patch.dict(os.environ, {"SHELLY_UI_CONFIG": str(tmp_path / "nope.json")}, clear=False):
|
||||
from shelly_manager.ui import state as state_mod
|
||||
|
||||
assert state_mod._load_config_from_disk() is None
|
||||
Reference in New Issue
Block a user