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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user