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.
This commit is contained in:
2026-08-02 15:59:35 +02:00
parent 0a9bf6def6
commit ecc53cd7cb
14 changed files with 462 additions and 14 deletions

View File

@ -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)