All checks were successful
Testing Build / build (push) Successful in 8m44s
Adds an optional per-printer integration with KXGauge (https://gitea.it-drui.de/viewit/kxgauge), a small ESP32 round-face display that shows printer status as an emotion and hotend temperature as a color ring. KXGauge only exposes a GET-only HTTP API with no push/websocket, so the bridge actively pushes to it from the existing MQTT callbacks (_on_temp for the heat ring, _on_print + offline transitions for the emotion) whenever state actually changes, with a built-in dedupe so it doesn't spam the device every poll tick. - kxgauge_client.py: thin synchronous HTTP client (mirrors spoolman_client.py's shape - called from the MQTT reader thread). - New [kxgauge]/[kxgauge_mapping] config.ini sections; the mapping (kobra_state -> KXGauge emotion) is user-editable with a sane default and falls back per-key if only partially configured. - Settings UI: new card under Integrations with enable/URL/target-temp fields, a per-state emotion mapping list, and a connection-test button (/api/kxgauge/test). - Multi-printer aware: kxgauge_url/enabled/heat_peak merge per [printer_N] like the existing power-switch settings. Also fixes a real bug found while testing this: _find_config_path() resolves to the live project config/config.ini, not a sandboxed path, so any test hitting /api/settings POST without stubbing it out will silently overwrite the real printer config. test_settings.py already guards against this - test_kxgauge.py now does too.
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""KXGauge display integration (https://gitea.it-drui.de/viewit/kxgauge).
|
|
|
|
KXGauge is a small ESP32 round-face display with a GET-only HTTP API and no
|
|
push/websocket - the bridge has to actively poke it on printer state/temp
|
|
changes. Covers: the KXGaugeClient dedupe logic, settings GET/POST roundtrip
|
|
for the new fields, the mapping config loader, and the /api/kxgauge/test
|
|
connectivity check endpoint.
|
|
"""
|
|
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
|
|
from kxgauge_client import KXGaugeClient
|
|
|
|
|
|
# ── KXGaugeClient (sync HTTP client, dedupe logic) ──────────────────────────
|
|
|
|
def test_set_emotion_sends_get_and_dedupes():
|
|
kg = KXGaugeClient("http://192.168.1.99")
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
|
|
kg.set_emotion("happy")
|
|
kg.set_emotion("happy") # same emotion again - must not re-send
|
|
assert mock_get.call_count == 1
|
|
assert mock_get.call_args[0][0] == "http://192.168.1.99/emotion/happy"
|
|
|
|
|
|
def test_set_emotion_sends_again_after_change():
|
|
kg = KXGaugeClient("http://192.168.1.99")
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
|
|
kg.set_emotion("happy")
|
|
kg.set_emotion("scared")
|
|
assert mock_get.call_count == 2
|
|
assert mock_get.call_args[0][0] == "http://192.168.1.99/emotion/scared"
|
|
|
|
|
|
def test_set_heat_celsius_sends_peak_once_then_dedupes_within_tolerance():
|
|
kg = KXGaugeClient("http://192.168.1.99", heat_peak=230)
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
|
|
kg.set_heat_celsius(200.0)
|
|
kg.set_heat_celsius(200.5) # within tolerance - no new /heat/celsius call
|
|
urls = [c.args[0] for c in mock_get.call_args_list]
|
|
assert "http://192.168.1.99/heat/peak/celsius/230" in urls
|
|
assert urls.count("http://192.168.1.99/heat/celsius/200.0") == 1
|
|
assert not any("/heat/celsius/200.5" in u for u in urls)
|
|
|
|
|
|
def test_set_heat_celsius_sends_again_beyond_tolerance():
|
|
kg = KXGaugeClient("http://192.168.1.99")
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
|
|
kg.set_heat_celsius(200.0)
|
|
kg.set_heat_celsius(210.0)
|
|
heat_calls = [c.args[0] for c in mock_get.call_args_list if "/heat/celsius/" in c.args[0]]
|
|
assert len(heat_calls) == 2
|
|
|
|
|
|
def test_client_swallows_connection_errors():
|
|
kg = KXGaugeClient("http://192.168.1.99")
|
|
with patch("requests.get", side_effect=OSError("unreachable")):
|
|
kg.set_emotion("happy") # must not raise
|
|
assert kg._last_emotion is None # failed send - state not updated
|
|
|
|
|
|
def test_health_check_true_on_200():
|
|
kg = KXGaugeClient("http://192.168.1.99")
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
|
|
assert kg.health_check() is True
|
|
|
|
|
|
def test_health_check_false_on_error():
|
|
kg = KXGaugeClient("http://192.168.1.99")
|
|
with patch("requests.get", side_effect=OSError("unreachable")):
|
|
assert kg.health_check() is False
|
|
|
|
|
|
# ── Bridge integration: settings GET/POST, test endpoint ───────────────────
|
|
|
|
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="kxgauge-"),
|
|
kxgauge_url="", kxgauge_enabled=0, kxgauge_heat_peak=250,
|
|
)
|
|
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 kxgauge_client():
|
|
bridge = _make_bridge(kxgauge_url="http://192.168.1.77", kxgauge_enabled=1, kxgauge_heat_peak=230)
|
|
# _find_config_path() otherwise resolves to the real repo config/config.ini
|
|
# (env_loader.find_config_path() walks from the script location, ignoring
|
|
# args.data_dir) - a settings POST in a test must never touch that file.
|
|
import pathlib
|
|
sandbox_cfg = pathlib.Path(tempfile.mkdtemp(prefix="kxgauge-cfg-")) / "config.ini"
|
|
bridge._find_config_path = lambda: sandbox_cfg
|
|
bridge._restart_bridge = lambda: None # a settings POST schedules a restart - don't kill the test process
|
|
app = build_app(bridge)
|
|
async with TestClient(TestServer(app)) as c:
|
|
yield c, bridge
|
|
|
|
|
|
def _fake_get_response(status=200, json_body=None):
|
|
resp = MagicMock()
|
|
resp.status = status
|
|
resp.json = AsyncMock(return_value=json_body or {})
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=resp)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
return ctx
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_creates_kxgauge_client_when_enabled():
|
|
bridge = _make_bridge(kxgauge_url="http://192.168.1.77", kxgauge_enabled=1)
|
|
assert bridge._kxgauge is not None
|
|
assert bridge._kxgauge.base_url == "http://192.168.1.77"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_no_kxgauge_client_when_disabled():
|
|
bridge = _make_bridge(kxgauge_url="http://192.168.1.77", kxgauge_enabled=0)
|
|
assert bridge._kxgauge is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_no_kxgauge_client_when_no_url():
|
|
bridge = _make_bridge(kxgauge_enabled=1, kxgauge_url="")
|
|
assert bridge._kxgauge is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_roundtrip_persists_kxgauge_fields(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
resp = await c.get("/api/settings")
|
|
data = await resp.json()
|
|
assert data["kxgauge_url"] == "http://192.168.1.77"
|
|
assert data["kxgauge_enabled"] == 1
|
|
assert data["kxgauge_heat_peak"] == 230
|
|
assert isinstance(data["kxgauge_mapping"], dict)
|
|
assert data["kxgauge_mapping"]["printing"] == "happy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kxgauge_mapping_default_when_no_config_section():
|
|
bridge = _make_bridge()
|
|
assert bridge._kxgauge_mapping["free"] == "neutral"
|
|
assert bridge._kxgauge_mapping["error"] == "scared"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kxgauge_test_endpoint_success(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, {"emotion": "HAPPY"})) as mock_get:
|
|
resp = await c.post("/api/kxgauge/test", json={})
|
|
data = await resp.json()
|
|
assert resp.status == 200
|
|
assert data["result"] == "ok"
|
|
assert data["emotion"] == "HAPPY"
|
|
assert mock_get.call_args[0][0] == "http://192.168.1.77/status"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kxgauge_test_endpoint_uses_url_from_body_over_configured():
|
|
bridge = _make_bridge() # no configured kxgauge_url
|
|
app = build_app(bridge)
|
|
async with TestClient(TestServer(app)) as c:
|
|
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, {"emotion": "NEUTRAL"})) as mock_get:
|
|
resp = await c.post("/api/kxgauge/test", json={"url": "http://192.168.1.88"})
|
|
data = await resp.json()
|
|
assert resp.status == 200
|
|
assert mock_get.call_args[0][0] == "http://192.168.1.88/status"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kxgauge_test_endpoint_no_url_configured_error():
|
|
bridge = _make_bridge()
|
|
app = build_app(bridge)
|
|
async with TestClient(TestServer(app)) as c:
|
|
resp = await c.post("/api/kxgauge/test", json={})
|
|
assert resp.status == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kxgauge_test_endpoint_unreachable_returns_502(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
with patch("aiohttp.ClientSession.get", side_effect=OSError("connection refused")):
|
|
resp = await c.post("/api/kxgauge/test", json={})
|
|
assert resp.status == 502
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_post_saves_custom_mapping(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
resp = await c.post("/api/settings", json={
|
|
"printer_ip": "192.168.1.50", "mqtt_port": 9883,
|
|
"kxgauge_url": "http://192.168.1.77", "kxgauge_enabled": 1,
|
|
"kxgauge_heat_peak": 240,
|
|
"kxgauge_mapping": {"printing": "love", "error": "furious"},
|
|
})
|
|
assert resp.status == 200
|
|
assert bridge._kxgauge_mapping["printing"] == "love"
|
|
assert bridge._kxgauge_mapping["error"] == "furious"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_post_rejects_invalid_emotion(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
before = dict(bridge._kxgauge_mapping)
|
|
resp = await c.post("/api/settings", json={
|
|
"printer_ip": "192.168.1.50", "mqtt_port": 9883,
|
|
"kxgauge_mapping": {"printing": "not_a_real_emotion"},
|
|
})
|
|
assert resp.status == 200
|
|
# invalid value must be rejected, mapping keeps its previous value
|
|
assert bridge._kxgauge_mapping["printing"] == before["printing"]
|
|
|
|
|
|
# ── MQTT callback hooks fire the mapped emotion / heat value ───────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_temp_pushes_heat_to_kxgauge(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
bridge._kxgauge.set_heat_celsius = MagicMock()
|
|
bridge._on_temp({"data": {"curr_nozzle_temp": 205, "target_nozzle_temp": 210,
|
|
"curr_hotbed_temp": 60, "target_hotbed_temp": 60}})
|
|
bridge._kxgauge.set_heat_celsius.assert_called_once_with(205.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_print_pushes_mapped_emotion_to_kxgauge(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
bridge._kxgauge.set_emotion = MagicMock()
|
|
bridge._on_print({"state": "printing", "data": {}})
|
|
bridge._kxgauge.set_emotion.assert_called_once_with("happy")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_print_unmapped_state_does_not_call_kxgauge(kxgauge_client):
|
|
c, bridge = kxgauge_client
|
|
bridge._kxgauge_mapping = {} # no entries at all
|
|
bridge._kxgauge.set_emotion = MagicMock()
|
|
bridge._on_print({"state": "printing", "data": {}})
|
|
bridge._kxgauge.set_emotion.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_kxgauge_configured_callbacks_are_noop():
|
|
bridge = _make_bridge() # kxgauge disabled
|
|
assert bridge._kxgauge is None
|
|
# must not raise even though _kxgauge is None
|
|
bridge._on_temp({"data": {"curr_nozzle_temp": 200, "target_nozzle_temp": 200,
|
|
"curr_hotbed_temp": 60, "target_hotbed_temp": 60}})
|
|
bridge._on_print({"state": "printing", "data": {}})
|