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:
2026-03-23 21:51:59 +01:00
commit 71803418e5
152 changed files with 23405 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Test package (enables imports from tests.*)."""
+32
View File
@@ -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",
)
+1
View File
@@ -0,0 +1 @@
"""LAN integration tests (opt-in via SHELLY_INTEGRATION=1)."""
+35
View File
@@ -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).",
)
+158
View File
@@ -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]}")
+19
View File
@@ -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}}
+84
View File
@@ -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"
+58
View File
@@ -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
+147
View File
@@ -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
+29
View File
@@ -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 == []
+35
View File
@@ -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)
+42
View File
@@ -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
+31
View File
@@ -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"]
+17
View File
@@ -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
+16
View File
@@ -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
+29
View File
@@ -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
+75
View File
@@ -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())
+235
View File
@@ -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
+45
View File
@@ -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
+45
View File
@@ -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
+184
View File
@@ -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
+54
View File
@@ -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
+93
View File
@@ -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)
+38
View File
@@ -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