From ecc53cd7cbaf2dee5d27ceed054dffd2b7ad18b9 Mon Sep 17 00:00:00 2001 From: viewit Date: Sun, 2 Aug 2026 15:59:35 +0200 Subject: [PATCH] feat(printer): add smart-plug power switch button (Issue #103); fix(update): stable update check missing behind prereleases (Issue #104) 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. --- NIGHTLY_CHANGELOG.md | 2 + config_loader.py | 24 +++-- env_loader.py | 3 + kobrax_moonraker_bridge.py | 101 +++++++++++++++++++- tests/test_printer_power.py | 175 ++++++++++++++++++++++++++++++++++ tests/test_update_check.py | 57 +++++++++++ web/themes/default/app.js | 44 +++++++++ web/themes/default/index.html | 16 ++++ web/translations/de.json | 9 ++ web/translations/en.json | 9 ++ web/translations/es.json | 9 ++ web/translations/fr.json | 9 ++ web/translations/it.json | 9 ++ web/translations/zh-cn.json | 9 ++ 14 files changed, 462 insertions(+), 14 deletions(-) create mode 100644 tests/test_printer_power.py create mode 100644 tests/test_update_check.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index caf633d..478eb08 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -2,3 +2,5 @@ - Feat: `server/files/metadata` now uses the printer's own `buried/report` analytics event (fires once per print start, regardless of slicer) as a fallback for `size`/`estimated_time`/`layer_count` — fixes broken `size: 1`/`estimated_time: null` placeholders for files not in the bridge's own GCode store, e.g. prints started directly from Anycubic Slicer Next (Issue #102, thanks @fmontagna). Also surfaces the printer's storage usage (`storage_total_mb`/`storage_used_mb`) in `/api/state`. - Fix: **the bridge could get stuck in an endless reconnect loop after a printer disconnect, even once the printer was back online and reachable** — a container restart was the only way out. Two independent reconnect paths (the MQTT reader thread and the status-poll loop) could race into competing TLS handshakes, and the poll loop never noticed a dead MQTT session on its own since a failed send silently returned no data instead of raising an error. The bridge now reconnects automatically without manual intervention (Issue #105, thanks @p2l for the precise report). +- Fix: the in-app update check on **stable** releases only ever looked at the single newest release on Gitea regardless of type — since nightly/dev prereleases publish far more often than stable ones, that newest release is almost always a prerelease, so the check found nothing and reported "no stable releases found" even though a newer stable release existed (Issue #104, thanks @Nerdinat0r). +- Feat: printers with no MQTT-level standby/power-off command (i.e. all of them) can now be switched via an external smart plug (e.g. Tasmota) directly from the dashboard — configure a power-on/power-off/status URL per printer in Settings, and a power button with a live on/off indicator appears on that printer's card (Issue #103, thanks @ok24). Also fixes `config.ini` values containing a literal `%` (e.g. Tasmota's `cmnd=Power%20on` URLs) being rejected/corrupted by `configparser`'s default string interpolation. diff --git a/config_loader.py b/config_loader.py index 459942e..f443e87 100644 --- a/config_loader.py +++ b/config_loader.py @@ -59,6 +59,9 @@ CONFIG_ENV_MAPPING = { "MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"), "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), + "POWER_ON_URL": (CONFIG_SECTION_CONNECTION, "power_on_url"), + "POWER_OFF_URL": (CONFIG_SECTION_CONNECTION, "power_off_url"), + "POWER_STATUS_URL": (CONFIG_SECTION_CONNECTION, "power_status_url"), "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), @@ -76,7 +79,7 @@ CONFIG_ENV_MAPPING = { def _load_config_file(path: pathlib.Path): """Loads config.ini and sets keys in os.environ (only if not already set).""" - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") for env_key, (section, option) in CONFIG_ENV_MAPPING.items(): @@ -113,7 +116,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): env_vals[k.strip()] = v.strip() config_path.parent.mkdir(parents=True, exist_ok=True) - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg[CONFIG_SECTION_CONNECTION] = { "printer_ip": env_vals.get("PRINTER_IP", ""), "mqtt_port": env_vals.get("MQTT_PORT", "9883"), @@ -175,7 +178,7 @@ def list_printers() -> list[dict]: path = _find_config_file() if not path: return [] - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") printers: list[dict] = [] idx = 1 @@ -237,7 +240,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]: path = _find_config_file() if not path: return {} - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") section = _filament_section(printer_id) if not cfg.has_section(section): @@ -278,7 +281,7 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] path = _find_config_file() if not path: return False - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") section = _filament_section(printer_id) # visible_vendors (Issue #41) is not a slot mapping - preserve it when @@ -320,7 +323,7 @@ def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]: path = _find_config_file() if not path: return [] - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") section = _filament_section(printer_id) if not cfg.has_option(section, "visible_vendors"): @@ -342,7 +345,7 @@ def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) - path = _find_config_file() if not path: return False - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") section = _filament_section(printer_id) if not cfg.has_section(section): @@ -402,7 +405,7 @@ def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]: path = _find_config_file() if not path: return {} - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") section = _spoolman_map_section(printer_id) if cfg.has_option(section, "slot_spools"): @@ -422,7 +425,7 @@ def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None path = _find_config_file() if not path: return False - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(path, encoding="utf-8") section = _spoolman_map_section(printer_id) clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0} @@ -448,6 +451,9 @@ USERNAME = get("MQTT_USERNAME", "") PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") +POWER_ON_URL = get("POWER_ON_URL", "") +POWER_OFF_URL = get("POWER_OFF_URL", "") +POWER_STATUS_URL = get("POWER_STATUS_URL", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) diff --git a/env_loader.py b/env_loader.py index 97f37af..7cbe68f 100644 --- a/env_loader.py +++ b/env_loader.py @@ -46,6 +46,9 @@ USERNAME = get("MQTT_USERNAME", "") PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") +POWER_ON_URL = get("POWER_ON_URL", "") +POWER_OFF_URL = get("POWER_OFF_URL", "") +POWER_STATUS_URL = get("POWER_STATUS_URL", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index b7f3c13..d6ca743 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1312,7 +1312,7 @@ class KobraXBridge: cfg_path = self._find_config_path() if not cfg_path.is_file(): return defaults - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) cfg.read(cfg_path, encoding="utf-8") sec = "ace_dry_presets" if not cfg.has_section(sec): @@ -3409,9 +3409,78 @@ class KobraXBridge: "bridge_url": bridge_url, "printer_ip": br._args.printer_ip, "device_id": br._args.device_id or "", + "has_power_control": bool( + (getattr(br._args, "power_on_url", "") or "").strip() + or (getattr(br._args, "power_off_url", "") or "").strip() + ), }) return self._json_cors({"result": out}) + async def handle_kx_printer_power(self, request): + """Toggles an external smart plug (e.g. Tasmota) for a printer that + has no MQTT-level power-off/standby command of its own (Issue #103). + + Just fires a plain HTTP GET at the configured power_on_url/power_off_url - + works for Tasmota's cmnd=Power%20on/off style URLs and any other + switch that exposes a GET-triggered on/off endpoint.""" + pid = str(request.match_info.get("pid", "")).strip() + br = self._all_bridges.get(pid) + if br is None: + return self._json_cors({"error": "unknown printer id"}, status=404) + try: + body = await request.json() + except Exception: + body = {} + action = str(body.get("action", "")).lower() + if action not in ("on", "off"): + return self._json_cors({"error": "action must be 'on' or 'off'"}, status=400) + url = getattr(br._args, f"power_{action}_url", "") or "" + if not url: + return self._json_cors({"error": f"no power_{action}_url configured"}, status=400) + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + ok = resp.status == 200 + except Exception as e: + return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502) + return self._json_cors({"result": "ok" if ok else "error", "status": "on" if action == "on" else "off"}) + + async def handle_kx_printer_power_status(self, request): + """Queries the configured smart plug for its current on/off state. + + Tries to parse a Tasmota-style {"POWER":"ON"/"OFF"} JSON body first, + falls back to a plain substring search for "ON"/"OFF" in the raw + response so other switch firmwares with a simpler status endpoint + still work.""" + pid = str(request.match_info.get("pid", "")).strip() + br = self._all_bridges.get(pid) + if br is None: + return self._json_cors({"error": "unknown printer id"}, status=404) + url = getattr(br._args, "power_status_url", "") or "" + if not url: + return self._json_cors({"error": "no power_status_url configured"}, status=400) + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + text = await resp.text() + except Exception as e: + return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502) + state = "unknown" + try: + data = json.loads(text) + power = str(data.get("POWER", "")).upper() + if power in ("ON", "OFF"): + state = power.lower() + except Exception: + pass + if state == "unknown": + up = text.upper() + if "ON" in up and "OFF" not in up: + state = "on" + elif "OFF" in up: + state = "off" + return self._json_cors({"state": state}) + async def handle_kx_print(self, request): """Print start from the GCode store with optional filament assignments.""" try: @@ -4919,6 +4988,9 @@ class KobraXBridge: "password": self._args.password, "mode_id": self._args.mode_id, "device_id": self._args.device_id, + "power_on_url": getattr(self._args, "power_on_url", "") or "", + "power_off_url": getattr(self._args, "power_off_url", "") or "", + "power_status_url": getattr(self._args, "power_status_url", "") or "", "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "auto_leveling": getattr(self._args, "auto_leveling", 1), "vibration_compensation": getattr(self._args, "vibration_compensation", 0), @@ -4941,7 +5013,7 @@ class KobraXBridge: config_path.parent.mkdir(parents=True, exist_ok=True) # Read the existing config.ini (comments are lost, but values are kept) - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) if config_path.is_file(): cfg.read(config_path, encoding="utf-8") @@ -4957,6 +5029,9 @@ class KobraXBridge: cfg.set("connection", "password", str(data.get("password", self._args.password or ""))) cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or ""))) cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or ""))) + cfg.set("connection", "power_on_url", str(data.get("power_on_url", getattr(self._args, "power_on_url", "") or "")).strip()) + cfg.set("connection", "power_off_url", str(data.get("power_off_url", getattr(self._args, "power_off_url", "") or "")).strip()) + cfg.set("connection", "power_status_url", str(data.get("power_status_url", getattr(self._args, "power_status_url", "") or "")).strip()) cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) @@ -5026,7 +5101,7 @@ class KobraXBridge: import configparser config_path = self._find_config_path() - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) if config_path.is_file(): cfg.read(config_path, encoding="utf-8") @@ -5094,7 +5169,7 @@ class KobraXBridge: import configparser config_path = self._find_config_path() - cfg = configparser.ConfigParser() + cfg = configparser.ConfigParser(interpolation=None) if config_path.is_file(): cfg.read(config_path, encoding="utf-8") @@ -5190,7 +5265,12 @@ class KobraXBridge: # ─── Update ────────────────────────────────────────────────────────────── - STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1" + # limit=1 would only ever see the single newest release regardless of type - + # if that happens to be a nightly/dev prerelease (the common case, since + # those publish far more often than stable), the stable_releases filter + # below finds nothing and update checks fail with "no stable releases + # found" even though older stable releases exist (Issue #104). + STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=20" NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true" DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true" GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag" @@ -5926,6 +6006,8 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_get("/kx/printers", bridge.handle_kx_printers) r.add_post("/kx/printers/add", bridge.handle_kx_printer_add) r.add_delete("/kx/printers/{pid}", bridge.handle_kx_printer_remove) + r.add_post("/kx/printers/{pid}/power", bridge.handle_kx_printer_power) + r.add_get("/kx/printers/{pid}/power-status", bridge.handle_kx_printer_power_status) r.add_post("/kx/print", bridge.handle_kx_print) r.add_get("/kx/files", bridge.handle_kx_files) r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete) @@ -5981,6 +6063,9 @@ def _build_per_printer_args(base_args, p: dict): a.mode_id = p.get("mode_id") or base_args.mode_id a.device_id = p.get("device_id") or base_args.device_id a.port = int(p.get("http_port") or base_args.port) + a.power_on_url = p.get("power_on_url") or getattr(base_args, "power_on_url", "") or "" + a.power_off_url = p.get("power_off_url") or getattr(base_args, "power_off_url", "") or "" + a.power_status_url = p.get("power_status_url") or getattr(base_args, "power_status_url", "") or "" return a @@ -6123,6 +6208,12 @@ def main(): parser.add_argument("--password", default=env_loader.PASSWORD) parser.add_argument("--mode-id", default=env_loader.MODE_ID) parser.add_argument("--device-id", default=env_loader.DEVICE_ID) + parser.add_argument("--power-on-url", default=env_loader.POWER_ON_URL, + help="HTTP GET URL to power the printer on (e.g. a Tasmota smart plug)") + parser.add_argument("--power-off-url", default=env_loader.POWER_OFF_URL, + help="HTTP GET URL to power the printer off") + parser.add_argument("--power-status-url", default=env_loader.POWER_STATUS_URL, + help="HTTP GET URL returning the smart plug's current on/off state") parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION) diff --git a/tests/test_printer_power.py b/tests/test_printer_power.py new file mode 100644 index 0000000..1892626 --- /dev/null +++ b/tests/test_printer_power.py @@ -0,0 +1,175 @@ +"""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" diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..29f67f1 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,57 @@ +"""Update-check regression for Issue #104. + +STABLE_RELEASE_API used limit=1, so it only ever saw the single newest +release on Gitea regardless of type. Since nightly/dev prereleases publish +far more often than stable releases, that newest release is almost always a +prerelease - the stable_releases filter (not prerelease) then found nothing +and /api/update/check returned "no stable releases found" even though older +stable releases exist. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _fake_releases_response(payload): + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock(return_value=payload) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + +@pytest.mark.asyncio +async def test_stable_update_check_finds_release_behind_newer_prereleases(client): + c, bridge = client + bridge._read_version = lambda: "0.9.27" + + releases = ( + [{"tag_name": f"nightly-0.9.30-nightly{i}", "prerelease": True} for i in range(1, 7)] + + [{"tag_name": "v0.9.29", "prerelease": False, "body": "changelog"}] + ) + + with patch("aiohttp.ClientSession.get", return_value=_fake_releases_response(releases)): + resp = await c.get("/api/update/check") + data = await resp.json() + + assert resp.status == 200 + assert data["latest"] == "0.9.29" + assert data["update_available"] is True + + +@pytest.mark.asyncio +async def test_stable_update_check_requests_enough_releases_to_skip_prereleases(client): + """The API URL itself must ask for more than the single newest release - + a limit=1 request can never find a stable release behind a run of + prereleases no matter how the response is parsed.""" + c, bridge = client + bridge._read_version = lambda: "0.9.27" + + import re + assert not re.search(r"limit=1(?!\d)", bridge.STABLE_RELEASE_API), ( + "STABLE_RELEASE_API must request more than 1 release, otherwise a " + "recent nightly/dev prerelease being the newest release hides all " + "stable releases behind it (Issue #104)" + ) diff --git a/web/themes/default/app.js b/web/themes/default/app.js index aa8d591..98dd81a 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -450,6 +450,11 @@ function applyLang(){ setText('lbl-password',T.settings_password); setText('lbl-device-id',T.settings_device_id); setText('lbl-mode-id',T.settings_mode_id); + setText('modal-sec-power',T.settings_power||'Power Switch'); + setText('lbl-power-on-url',T.settings_power_on_url||'Power-On URL'); + setText('lbl-power-off-url',T.settings_power_off_url||'Power-Off URL'); + setText('lbl-power-status-url',T.settings_power_status_url||'Status URL'); + setText('lbl-power-hint',T.settings_power_hint||'Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer\'s mains power. Leave empty to hide the power button.'); setText('lbl-default-slot',T.settings_default_slot); setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); @@ -1127,6 +1132,9 @@ function openSettings(){ document.getElementById('s-password').value=d.password||''; document.getElementById('s-device-id').value=d.device_id||''; document.getElementById('s-mode-id').value=d.mode_id||''; + var pon=document.getElementById('s-power-on-url');if(pon)pon.value=d.power_on_url||''; + var poff=document.getElementById('s-power-off-url');if(poff)poff.value=d.power_off_url||''; + var pstat=document.getElementById('s-power-status-url');if(pstat)pstat.value=d.power_status_url||''; document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation; @@ -1895,6 +1903,9 @@ function saveSettings(){ password: document.getElementById('s-password').value, device_id: document.getElementById('s-device-id').value, mode_id: document.getElementById('s-mode-id').value, + power_on_url: (document.getElementById('s-power-on-url')||{}).value||'', + power_off_url: (document.getElementById('s-power-off-url')||{}).value||'', + power_status_url: (document.getElementById('s-power-status-url')||{}).value||'', default_ams_slot: document.getElementById('s-default-slot').value, auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0, @@ -3984,6 +3995,7 @@ function loadPrinterTab(){ '🖨 '+p.name+''+ ''+ (isActive?''+T.printers_active+'':'')+ + (p.has_power_control?'':'')+ ''+ ''+ ''+ @@ -4000,8 +4012,40 @@ function loadPrinterTab(){ (!isActive?''+T.printers_switch+'':'
'+T.printers_current+'
')+ ''; }).join(''); + results.forEach(function(res){ + if(res.printer.has_power_control)_refreshPrinterPowerIcon(res.printer.id,(res.printer.bridge_url||'').replace(/\/+$/,'')); + }); }); }).catch(function(e){ if(grid)grid.innerHTML='
Fehler: '+e+'
'; }); } + +function _refreshPrinterPowerIcon(pid,bridgeUrl){ + fetch((bridgeUrl||'')+'/kx/printers/'+encodeURIComponent(pid)+'/power-status',{signal:AbortSignal.timeout(5000)}) + .then(function(r){return r.json()}) + .then(function(d){ + var btn=document.getElementById('power-btn-'+pid); + if(!btn)return; + if(d.state==='on'){btn.style.color='var(--ok)';btn.title=T.printers_power_on||'Power: On';} + else if(d.state==='off'){btn.style.color='var(--txt2)';btn.title=T.printers_power_off||'Power: Off';} + }) + .catch(function(){/* status endpoint optional - icon just stays neutral */}); +} + +function togglePrinterPower(pid){ + var btn=document.getElementById('power-btn-'+pid); + var currentlyOn=btn&&btn.style.color&&btn.style.color.indexOf('var(--ok)')!==-1; + // Without a known current state, default to "on" - turning an already-off + // switch on is harmless, whereas guessing "off" on a printer mid-print is not. + var action=currentlyOn?'off':'on'; + if(action==='off'&&!confirm(T.printers_power_off_confirm||'Turn printer power off? Make sure no print is running.'))return; + if(btn)btn.style.opacity='0.5'; + post('/kx/printers/'+encodeURIComponent(pid)+'/power',{action:action}).then(function(){ + if(btn)btn.style.opacity='1'; + setTimeout(function(){loadPrinterTab();},1500); + }).catch(function(e){ + if(btn)btn.style.opacity='1'; + clog('Power-Fehler: '+e,'msg-err'); + }); +} diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 30836fa..bbcbef6 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -548,6 +548,22 @@ +
+
🔌 Power Switch
+ + + +
diff --git a/web/translations/de.json b/web/translations/de.json index 2a5857e..478bc31 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -224,6 +224,10 @@ "printers_loading": "Lade…", "printers_none": "Keine Drucker konfiguriert.", "printers_remove": "Drucker entfernen", + "printers_power": "Drucker-Stromversorgung schalten", + "printers_power_on": "Strom: An", + "printers_power_off": "Strom: Aus", + "printers_power_off_confirm": "Drucker-Strom ausschalten? Stelle sicher, dass kein Druck läuft.", "printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.", "printers_switch": "Wechseln →", "progress_action_clear": "Leeren", @@ -257,6 +261,11 @@ "settings_language": "Sprache", "settings_mode_id": "Mode-ID", "settings_mode_id_placeholder": "20030", + "settings_power": "Steckdose (Ein/Aus)", + "settings_power_on_url": "Einschalt-URL", + "settings_power_off_url": "Ausschalt-URL", + "settings_power_status_url": "Status-URL", + "settings_power_hint": "Optional: einfache HTTP-GET-URLs für eine Steckdose (z.B. Tasmota), die den Drucker per Netzstrom schaltet. Leer lassen blendet den Power-Button aus.", "settings_mqtt_port": "MQTT-Port", "settings_mqtt_username_placeholder": "userXXXXXXXX", "settings_orca_profiles_import": "Profile importieren", diff --git a/web/translations/en.json b/web/translations/en.json index c1aa333..a516848 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -224,6 +224,10 @@ "printers_loading": "Loading…", "printers_none": "No printers configured.", "printers_remove": "Remove printer", + "printers_power": "Toggle printer power", + "printers_power_on": "Power: On", + "printers_power_off": "Power: Off", + "printers_power_off_confirm": "Turn printer power off? Make sure no print is running.", "printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.", "printers_switch": "Switch →", "progress_action_clear": "Clear", @@ -257,6 +261,11 @@ "settings_language": "Language", "settings_mode_id": "Mode ID", "settings_mode_id_placeholder": "20030", + "settings_power": "Power Switch", + "settings_power_on_url": "Power-On URL", + "settings_power_off_url": "Power-Off URL", + "settings_power_status_url": "Status URL", + "settings_power_hint": "Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer's mains power. Leave empty to hide the power button.", "settings_mqtt_port": "MQTT Port", "settings_mqtt_username_placeholder": "userXXXXXXXX", "settings_orca_profiles_import": "Import profiles", diff --git a/web/translations/es.json b/web/translations/es.json index 6725e6a..c2f2999 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -224,6 +224,10 @@ "printers_loading": "Cargando…", "printers_none": "No hay impresoras configuradas.", "printers_remove": "Eliminar impresora", + "printers_power": "Alternar alimentación de la impresora", + "printers_power_on": "Alimentación: Encendida", + "printers_power_off": "Alimentación: Apagada", + "printers_power_off_confirm": "¿Apagar la alimentación de la impresora? Asegúrate de que no haya ninguna impresión en curso.", "printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.", "printers_switch": "Cambiar →", "progress_action_clear": "Vaciar", @@ -257,6 +261,11 @@ "settings_language": "Idioma", "settings_mode_id": "ID de modo", "settings_mode_id_placeholder": "20030", + "settings_power": "Enchufe inteligente", + "settings_power_on_url": "URL de encendido", + "settings_power_off_url": "URL de apagado", + "settings_power_status_url": "URL de estado", + "settings_power_hint": "Opcional: URLs HTTP GET para un enchufe inteligente (p.ej. Tasmota) que controla la alimentación de la impresora. Déjalo vacío para ocultar el botón de encendido.", "settings_mqtt_port": "MQTT Port", "settings_mqtt_username_placeholder": "userXXXXXXXX", "settings_orca_profiles_import": "Importar perfiles", diff --git a/web/translations/fr.json b/web/translations/fr.json index 28a3605..16b4ae0 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -212,6 +212,10 @@ "printers_loading": "Chargement…", "printers_none": "Aucune imprimante configurée.", "printers_remove": "Supprimer l'imprimante", + "printers_power": "Basculer l'alimentation de l'imprimante", + "printers_power_on": "Alimentation : Allumée", + "printers_power_off": "Alimentation : Éteinte", + "printers_power_off_confirm": "Éteindre l'alimentation de l'imprimante ? Assurez-vous qu'aucune impression n'est en cours.", "printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.", "printers_switch": "Changer →", "progress_action_clear": "Vider", @@ -245,6 +249,11 @@ "settings_language": "Langue", "settings_mode_id": "ID du mode", "settings_mode_id_placeholder": "20030", + "settings_power": "Prise électrique", + "settings_power_on_url": "URL d'allumage", + "settings_power_off_url": "URL d'extinction", + "settings_power_status_url": "URL de statut", + "settings_power_hint": "Optionnel : URL HTTP GET pour une prise connectée (ex. Tasmota) contrôlant l'alimentation secteur de l'imprimante. Laisser vide masque le bouton d'alimentation.", "settings_mqtt_port": "Port MQTT", "settings_mqtt_username_placeholder": "userXXXXXXXX", "settings_orca_profiles_import": "Importer des profils", diff --git a/web/translations/it.json b/web/translations/it.json index 2478eb8..46f6461 100644 --- a/web/translations/it.json +++ b/web/translations/it.json @@ -212,6 +212,10 @@ "printers_loading": "Caricamento in corso…", "printers_none": "Nessuna stampante configurata.", "printers_remove": "Rimuovi stampante", + "printers_power": "Attiva/disattiva alimentazione stampante", + "printers_power_on": "Alimentazione: Accesa", + "printers_power_off": "Alimentazione: Spenta", + "printers_power_off_confirm": "Spegnere l'alimentazione della stampante? Assicurati che non sia in corso alcuna stampa.", "printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.", "printers_switch": "Cambia →", "progress_action_clear": "Cancella", @@ -245,6 +249,11 @@ "settings_language": "Lingua", "settings_mode_id": "ID modalità", "settings_mode_id_placeholder": "20030", + "settings_power": "Presa elettrica", + "settings_power_on_url": "URL accensione", + "settings_power_off_url": "URL spegnimento", + "settings_power_status_url": "URL stato", + "settings_power_hint": "Opzionale: URL HTTP GET per una presa smart (es. Tasmota) che controlla l'alimentazione della stampante. Lasciare vuoto per nascondere il pulsante di accensione.", "settings_mqtt_port": "Porta MQTT", "settings_mqtt_username_placeholder": "userXXXXXXXX", "settings_orca_profiles_import": "Importa profili", diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index d460fc0..554ff0b 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -224,6 +224,10 @@ "printers_loading": "加载中…", "printers_none": "未配置打印机。", "printers_remove": "移除打印机", + "printers_power": "切换打印机电源", + "printers_power_on": "电源:开", + "printers_power_off": "电源:关", + "printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。", "printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。", "printers_switch": "切换 →", "progress_action_clear": "清除", @@ -257,6 +261,11 @@ "settings_language": "语言", "settings_mode_id": "模式 ID", "settings_mode_id_placeholder": "20030", + "settings_power": "电源插座", + "settings_power_on_url": "开机 URL", + "settings_power_off_url": "关机 URL", + "settings_power_status_url": "状态 URL", + "settings_power_hint": "可选:智能插座(如 Tasmota)的 HTTP GET 控制 URL,用于控制打印机的电源。留空则隐藏电源按钮。", "settings_mqtt_port": "MQTT 端口", "settings_mqtt_username_placeholder": "userXXXXXXXX", "settings_orca_profiles_import": "导入配置文件",