Issue #103: the printer has no MQTT-level command to power off or enter standby, so users on a separate-room setup have to physically walk over or use a smart plug (e.g. Tasmota) manually. Added per-printer power_on_url/power_off_url/power_status_url config (Settings > Power Switch) and a power button with a live on/off indicator on each printer's card in the Printers grid - plain HTTP GET calls, no Moonraker device_power dependency. Issue #104: the stable-release update check only ever requested the single newest Gitea release (limit=1) regardless of type. Since nightly/dev prereleases publish far more often than stable ones, that newest release is almost always a prerelease, so the "not a prerelease" filter found nothing and reported "no stable releases found" even though a newer stable release existed further back in the list. Fixed by requesting enough releases (limit=20) to look past a run of prereleases. Also fixes a related pre-existing bug surfaced while testing #103's config: configparser's default string interpolation rejected any config.ini value containing a literal '%' (ValueError: invalid interpolation syntax) - this broke saving Tasmota-style power URLs (cmnd=Power%20on) and would have broken any other value with a '%' character. Fixed globally with interpolation=None on every ConfigParser() instantiation in config_loader.py and kobrax_moonraker_bridge.py.
176 lines
6.1 KiB
Python
176 lines
6.1 KiB
Python
"""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"
|