"""External smart-plug power control (Issue #103). The printer itself has no MQTT command to power off or enter standby - only heaters/motors/etc. can be controlled remotely. For users running the printer through a Tasmota-style smart plug, the bridge exposes plain HTTP GET on/off/status URLs (configured per printer) as its own dashboard button, instead of routing through Moonraker's device_power API. """ import argparse import tempfile from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from aiohttp.test_utils import TestClient, TestServer from kobrax_moonraker_bridge import KobraXBridge, build_app def _make_bridge(pid="1", **arg_overrides): c = MagicMock() c.callbacks = {} c.connected = False args = argparse.Namespace( printer_ip="192.168.1.50", mqtt_port=9883, username="", password="", mode_id="20030", device_id="", host="127.0.0.1", port=7125, data_dir=tempfile.mkdtemp(prefix="kxpower-"), power_on_url="", power_off_url="", power_status_url="", ) for k, v in arg_overrides.items(): setattr(args, k, v) all_bridges = {} bridge = KobraXBridge(c, args=args, printer_id=pid, all_bridges=all_bridges) all_bridges[pid] = bridge return bridge @pytest_asyncio.fixture async def power_client(): bridge = _make_bridge( power_on_url="http://192.168.1.99/cm?cmnd=Power%20on", power_off_url="http://192.168.1.99/cm?cmnd=Power%20off", power_status_url="http://192.168.1.99/cm?cmnd=Power", ) app = build_app(bridge) async with TestClient(TestServer(app)) as c: yield c, bridge def _fake_get_response(status=200, text=""): resp = MagicMock() resp.status = status resp.text = AsyncMock(return_value=text) ctx = MagicMock() ctx.__aenter__ = AsyncMock(return_value=resp) ctx.__aexit__ = AsyncMock(return_value=False) return ctx @pytest.mark.asyncio async def test_printers_list_reports_has_power_control(power_client): c, bridge = power_client resp = await c.get("/kx/printers") data = await resp.json() assert data["result"][0]["has_power_control"] is True @pytest.mark.asyncio async def test_printers_list_no_power_control_when_unconfigured(): bridge = _make_bridge() app = build_app(bridge) async with TestClient(TestServer(app)) as c: resp = await c.get("/kx/printers") data = await resp.json() assert data["result"][0]["has_power_control"] is False @pytest.mark.asyncio async def test_power_on_hits_configured_url(power_client): c, bridge = power_client with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200)) as mock_get: resp = await c.post("/kx/printers/1/power", json={"action": "on"}) data = await resp.json() assert resp.status == 200 assert data["result"] == "ok" mock_get.assert_called_once() assert mock_get.call_args[0][0] == "http://192.168.1.99/cm?cmnd=Power%20on" @pytest.mark.asyncio async def test_power_off_hits_configured_url(power_client): c, bridge = power_client with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200)) as mock_get: resp = await c.post("/kx/printers/1/power", json={"action": "off"}) data = await resp.json() assert resp.status == 200 assert data["result"] == "ok" assert mock_get.call_args[0][0] == "http://192.168.1.99/cm?cmnd=Power%20off" @pytest.mark.asyncio async def test_power_invalid_action_rejected(power_client): c, bridge = power_client resp = await c.post("/kx/printers/1/power", json={"action": "toggle"}) assert resp.status == 400 @pytest.mark.asyncio async def test_power_unknown_printer_id_404(power_client): c, bridge = power_client resp = await c.post("/kx/printers/99/power", json={"action": "on"}) assert resp.status == 404 @pytest.mark.asyncio async def test_power_missing_url_configured_error(): bridge = _make_bridge() # no power_on_url set app = build_app(bridge) async with TestClient(TestServer(app)) as c: resp = await c.post("/kx/printers/1/power", json={"action": "on"}) assert resp.status == 400 @pytest.mark.asyncio async def test_power_switch_unreachable_returns_502(power_client): c, bridge = power_client with patch("aiohttp.ClientSession.get", side_effect=OSError("connection refused")): resp = await c.post("/kx/printers/1/power", json={"action": "on"}) assert resp.status == 502 @pytest.mark.asyncio async def test_power_status_parses_tasmota_json(power_client): c, bridge = power_client with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, '{"POWER":"ON"}')): resp = await c.get("/kx/printers/1/power-status") data = await resp.json() assert data["state"] == "on" @pytest.mark.asyncio async def test_power_status_parses_tasmota_json_off(power_client): c, bridge = power_client with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, '{"POWER":"OFF"}')): resp = await c.get("/kx/printers/1/power-status") data = await resp.json() assert data["state"] == "off" @pytest.mark.asyncio async def test_power_status_falls_back_to_plain_text(power_client): c, bridge = power_client with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, "STATE: ON")): resp = await c.get("/kx/printers/1/power-status") data = await resp.json() assert data["state"] == "on" @pytest.mark.asyncio async def test_power_status_missing_url_configured_error(): bridge = _make_bridge() # no power_status_url set app = build_app(bridge) async with TestClient(TestServer(app)) as c: resp = await c.get("/kx/printers/1/power-status") assert resp.status == 400 @pytest.mark.asyncio async def test_settings_roundtrip_persists_power_urls(power_client): c, bridge = power_client resp = await c.get("/api/settings") data = await resp.json() assert data["power_on_url"] == "http://192.168.1.99/cm?cmnd=Power%20on" assert data["power_off_url"] == "http://192.168.1.99/cm?cmnd=Power%20off" assert data["power_status_url"] == "http://192.168.1.99/cm?cmnd=Power"