Files
KX-Bridge-Release/tests/test_kxgauge.py
viewit 6ab5c31035
All checks were successful
Testing Build / build (push) Successful in 8m44s
feat(kxgauge): add KXGauge round-display integration
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.
2026-08-05 15:13:43 +02:00

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": {}})