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:
@@ -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`.
|
- 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 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.
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ CONFIG_ENV_MAPPING = {
|
|||||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_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"),
|
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||||
@@ -76,7 +79,7 @@ CONFIG_ENV_MAPPING = {
|
|||||||
|
|
||||||
def _load_config_file(path: pathlib.Path):
|
def _load_config_file(path: pathlib.Path):
|
||||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
"""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")
|
cfg.read(path, encoding="utf-8")
|
||||||
|
|
||||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
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()
|
env_vals[k.strip()] = v.strip()
|
||||||
|
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg[CONFIG_SECTION_CONNECTION] = {
|
cfg[CONFIG_SECTION_CONNECTION] = {
|
||||||
"printer_ip": env_vals.get("PRINTER_IP", ""),
|
"printer_ip": env_vals.get("PRINTER_IP", ""),
|
||||||
"mqtt_port": env_vals.get("MQTT_PORT", "9883"),
|
"mqtt_port": env_vals.get("MQTT_PORT", "9883"),
|
||||||
@@ -175,7 +178,7 @@ def list_printers() -> list[dict]:
|
|||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return []
|
return []
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
printers: list[dict] = []
|
printers: list[dict] = []
|
||||||
idx = 1
|
idx = 1
|
||||||
@@ -237,7 +240,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
|||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return {}
|
return {}
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
section = _filament_section(printer_id)
|
section = _filament_section(printer_id)
|
||||||
if not cfg.has_section(section):
|
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()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
section = _filament_section(printer_id)
|
section = _filament_section(printer_id)
|
||||||
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
|
# 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()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return []
|
return []
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
section = _filament_section(printer_id)
|
section = _filament_section(printer_id)
|
||||||
if not cfg.has_option(section, "visible_vendors"):
|
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()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
section = _filament_section(printer_id)
|
section = _filament_section(printer_id)
|
||||||
if not cfg.has_section(section):
|
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()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return {}
|
return {}
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
section = _spoolman_map_section(printer_id)
|
section = _spoolman_map_section(printer_id)
|
||||||
if cfg.has_option(section, "slot_spools"):
|
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()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
section = _spoolman_map_section(printer_id)
|
section = _spoolman_map_section(printer_id)
|
||||||
clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0}
|
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", "")
|
PASSWORD = get("MQTT_PASSWORD", "")
|
||||||
MODE_ID = get("MODE_ID", "")
|
MODE_ID = get("MODE_ID", "")
|
||||||
DEVICE_ID = get("DEVICE_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")
|
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ USERNAME = get("MQTT_USERNAME", "")
|
|||||||
PASSWORD = get("MQTT_PASSWORD", "")
|
PASSWORD = get("MQTT_PASSWORD", "")
|
||||||
MODE_ID = get("MODE_ID", "")
|
MODE_ID = get("MODE_ID", "")
|
||||||
DEVICE_ID = get("DEVICE_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")
|
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||||
|
|||||||
@@ -1312,7 +1312,7 @@ class KobraXBridge:
|
|||||||
cfg_path = self._find_config_path()
|
cfg_path = self._find_config_path()
|
||||||
if not cfg_path.is_file():
|
if not cfg_path.is_file():
|
||||||
return defaults
|
return defaults
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
cfg.read(cfg_path, encoding="utf-8")
|
cfg.read(cfg_path, encoding="utf-8")
|
||||||
sec = "ace_dry_presets"
|
sec = "ace_dry_presets"
|
||||||
if not cfg.has_section(sec):
|
if not cfg.has_section(sec):
|
||||||
@@ -3409,9 +3409,78 @@ class KobraXBridge:
|
|||||||
"bridge_url": bridge_url,
|
"bridge_url": bridge_url,
|
||||||
"printer_ip": br._args.printer_ip,
|
"printer_ip": br._args.printer_ip,
|
||||||
"device_id": br._args.device_id or "",
|
"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})
|
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):
|
async def handle_kx_print(self, request):
|
||||||
"""Print start from the GCode store with optional filament assignments."""
|
"""Print start from the GCode store with optional filament assignments."""
|
||||||
try:
|
try:
|
||||||
@@ -4919,6 +4988,9 @@ class KobraXBridge:
|
|||||||
"password": self._args.password,
|
"password": self._args.password,
|
||||||
"mode_id": self._args.mode_id,
|
"mode_id": self._args.mode_id,
|
||||||
"device_id": self._args.device_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"),
|
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||||
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
||||||
@@ -4941,7 +5013,7 @@ class KobraXBridge:
|
|||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Read the existing config.ini (comments are lost, but values are kept)
|
# 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():
|
if config_path.is_file():
|
||||||
cfg.read(config_path, encoding="utf-8")
|
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", "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", "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", "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", "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", "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))))))
|
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
|
import configparser
|
||||||
config_path = self._find_config_path()
|
config_path = self._find_config_path()
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
if config_path.is_file():
|
if config_path.is_file():
|
||||||
cfg.read(config_path, encoding="utf-8")
|
cfg.read(config_path, encoding="utf-8")
|
||||||
|
|
||||||
@@ -5094,7 +5169,7 @@ class KobraXBridge:
|
|||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
config_path = self._find_config_path()
|
config_path = self._find_config_path()
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser(interpolation=None)
|
||||||
if config_path.is_file():
|
if config_path.is_file():
|
||||||
cfg.read(config_path, encoding="utf-8")
|
cfg.read(config_path, encoding="utf-8")
|
||||||
|
|
||||||
@@ -5190,7 +5265,12 @@ class KobraXBridge:
|
|||||||
|
|
||||||
# ─── Update ──────────────────────────────────────────────────────────────
|
# ─── 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"
|
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"
|
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"
|
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_get("/kx/printers", bridge.handle_kx_printers)
|
||||||
r.add_post("/kx/printers/add", bridge.handle_kx_printer_add)
|
r.add_post("/kx/printers/add", bridge.handle_kx_printer_add)
|
||||||
r.add_delete("/kx/printers/{pid}", bridge.handle_kx_printer_remove)
|
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_post("/kx/print", bridge.handle_kx_print)
|
||||||
r.add_get("/kx/files", bridge.handle_kx_files)
|
r.add_get("/kx/files", bridge.handle_kx_files)
|
||||||
r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete)
|
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.mode_id = p.get("mode_id") or base_args.mode_id
|
||||||
a.device_id = p.get("device_id") or base_args.device_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.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
|
return a
|
||||||
|
|
||||||
|
|
||||||
@@ -6123,6 +6208,12 @@ def main():
|
|||||||
parser.add_argument("--password", default=env_loader.PASSWORD)
|
parser.add_argument("--password", default=env_loader.PASSWORD)
|
||||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||||
parser.add_argument("--device-id", default=env_loader.DEVICE_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("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
|
||||||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
||||||
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
|
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
|
||||||
|
|||||||
175
tests/test_printer_power.py
Normal file
175
tests/test_printer_power.py
Normal file
@@ -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"
|
||||||
57
tests/test_update_check.py
Normal file
57
tests/test_update_check.py
Normal file
@@ -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)"
|
||||||
|
)
|
||||||
@@ -450,6 +450,11 @@ function applyLang(){
|
|||||||
setText('lbl-password',T.settings_password);
|
setText('lbl-password',T.settings_password);
|
||||||
setText('lbl-device-id',T.settings_device_id);
|
setText('lbl-device-id',T.settings_device_id);
|
||||||
setText('lbl-mode-id',T.settings_mode_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('lbl-default-slot',T.settings_default_slot);
|
||||||
setText('opt-slot-auto',T.settings_slot_auto);
|
setText('opt-slot-auto',T.settings_slot_auto);
|
||||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||||
@@ -1127,6 +1132,9 @@ function openSettings(){
|
|||||||
document.getElementById('s-password').value=d.password||'';
|
document.getElementById('s-password').value=d.password||'';
|
||||||
document.getElementById('s-device-id').value=d.device_id||'';
|
document.getElementById('s-device-id').value=d.device_id||'';
|
||||||
document.getElementById('s-mode-id').value=d.mode_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-default-slot').value=d.default_ams_slot||'auto';
|
||||||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
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;
|
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,
|
password: document.getElementById('s-password').value,
|
||||||
device_id: document.getElementById('s-device-id').value,
|
device_id: document.getElementById('s-device-id').value,
|
||||||
mode_id: document.getElementById('s-mode-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,
|
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
||||||
@@ -3984,6 +3995,7 @@ function loadPrinterTab(){
|
|||||||
'<span style="font-weight:700;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">🖨 '+p.name+'</span>'+
|
'<span style="font-weight:700;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">🖨 '+p.name+'</span>'+
|
||||||
'<span style="display:flex;align-items:center;gap:8px;flex-shrink:0">'+
|
'<span style="display:flex;align-items:center;gap:8px;flex-shrink:0">'+
|
||||||
(isActive?'<span style="font-size:11px;color:var(--accent);font-weight:600">'+T.printers_active+'</span>':'')+
|
(isActive?'<span style="font-size:11px;color:var(--accent);font-weight:600">'+T.printers_active+'</span>':'')+
|
||||||
|
(p.has_power_control?'<button id="power-btn-'+printerNum+'" onclick="togglePrinterPower(\''+printerNum+'\')" title="'+T.printers_power+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">🔌</button>':'')+
|
||||||
'<button onclick="removePrinter(\''+printerNum+'\',\''+nameEsc+'\')" title="'+T.printers_remove+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">✕</button>'+
|
'<button onclick="removePrinter(\''+printerNum+'\',\''+nameEsc+'\')" title="'+T.printers_remove+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">✕</button>'+
|
||||||
'</span>'+
|
'</span>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
@@ -4000,8 +4012,40 @@ function loadPrinterTab(){
|
|||||||
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
results.forEach(function(res){
|
||||||
|
if(res.printer.has_power_control)_refreshPrinterPowerIcon(res.printer.id,(res.printer.bridge_url||'').replace(/\/+$/,''));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}).catch(function(e){
|
}).catch(function(e){
|
||||||
if(grid)grid.innerHTML='<div style="color:var(--err);font-size:13px;padding:20px">Fehler: '+e+'</div>';
|
if(grid)grid.innerHTML='<div style="color:var(--err);font-size:13px;padding:20px">Fehler: '+e+'</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -548,6 +548,22 @@
|
|||||||
<input type="text" id="s-mode-id" placeholder="20030">
|
<input type="text" id="s-mode-id" placeholder="20030">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title"><span>🔌</span> <span id="modal-sec-power">Power Switch</span></div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label id="lbl-power-on-url">Power-On URL</label>
|
||||||
|
<input type="text" id="s-power-on-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20on">
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label id="lbl-power-off-url">Power-Off URL</label>
|
||||||
|
<input type="text" id="s-power-off-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20off">
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label id="lbl-power-status-url">Status URL</label>
|
||||||
|
<input type="text" id="s-power-status-url" placeholder="http://192.168.x.x/cm?cmnd=Power">
|
||||||
|
<small id="lbl-power-hint" style="color:var(--txt2)"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drucker -->
|
<!-- Drucker -->
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
"printers_loading": "Lade…",
|
"printers_loading": "Lade…",
|
||||||
"printers_none": "Keine Drucker konfiguriert.",
|
"printers_none": "Keine Drucker konfiguriert.",
|
||||||
"printers_remove": "Drucker entfernen",
|
"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_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
|
||||||
"printers_switch": "Wechseln →",
|
"printers_switch": "Wechseln →",
|
||||||
"progress_action_clear": "Leeren",
|
"progress_action_clear": "Leeren",
|
||||||
@@ -257,6 +261,11 @@
|
|||||||
"settings_language": "Sprache",
|
"settings_language": "Sprache",
|
||||||
"settings_mode_id": "Mode-ID",
|
"settings_mode_id": "Mode-ID",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"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_port": "MQTT-Port",
|
||||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||||
"settings_orca_profiles_import": "Profile importieren",
|
"settings_orca_profiles_import": "Profile importieren",
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
"printers_loading": "Loading…",
|
"printers_loading": "Loading…",
|
||||||
"printers_none": "No printers configured.",
|
"printers_none": "No printers configured.",
|
||||||
"printers_remove": "Remove printer",
|
"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_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
|
||||||
"printers_switch": "Switch →",
|
"printers_switch": "Switch →",
|
||||||
"progress_action_clear": "Clear",
|
"progress_action_clear": "Clear",
|
||||||
@@ -257,6 +261,11 @@
|
|||||||
"settings_language": "Language",
|
"settings_language": "Language",
|
||||||
"settings_mode_id": "Mode ID",
|
"settings_mode_id": "Mode ID",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"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_port": "MQTT Port",
|
||||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||||
"settings_orca_profiles_import": "Import profiles",
|
"settings_orca_profiles_import": "Import profiles",
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
"printers_loading": "Cargando…",
|
"printers_loading": "Cargando…",
|
||||||
"printers_none": "No hay impresoras configuradas.",
|
"printers_none": "No hay impresoras configuradas.",
|
||||||
"printers_remove": "Eliminar impresora",
|
"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_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
|
||||||
"printers_switch": "Cambiar →",
|
"printers_switch": "Cambiar →",
|
||||||
"progress_action_clear": "Vaciar",
|
"progress_action_clear": "Vaciar",
|
||||||
@@ -257,6 +261,11 @@
|
|||||||
"settings_language": "Idioma",
|
"settings_language": "Idioma",
|
||||||
"settings_mode_id": "ID de modo",
|
"settings_mode_id": "ID de modo",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"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_port": "MQTT Port",
|
||||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||||
"settings_orca_profiles_import": "Importar perfiles",
|
"settings_orca_profiles_import": "Importar perfiles",
|
||||||
|
|||||||
@@ -212,6 +212,10 @@
|
|||||||
"printers_loading": "Chargement…",
|
"printers_loading": "Chargement…",
|
||||||
"printers_none": "Aucune imprimante configurée.",
|
"printers_none": "Aucune imprimante configurée.",
|
||||||
"printers_remove": "Supprimer l'imprimante",
|
"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_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
|
||||||
"printers_switch": "Changer →",
|
"printers_switch": "Changer →",
|
||||||
"progress_action_clear": "Vider",
|
"progress_action_clear": "Vider",
|
||||||
@@ -245,6 +249,11 @@
|
|||||||
"settings_language": "Langue",
|
"settings_language": "Langue",
|
||||||
"settings_mode_id": "ID du mode",
|
"settings_mode_id": "ID du mode",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"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_port": "Port MQTT",
|
||||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||||
"settings_orca_profiles_import": "Importer des profils",
|
"settings_orca_profiles_import": "Importer des profils",
|
||||||
|
|||||||
@@ -212,6 +212,10 @@
|
|||||||
"printers_loading": "Caricamento in corso…",
|
"printers_loading": "Caricamento in corso…",
|
||||||
"printers_none": "Nessuna stampante configurata.",
|
"printers_none": "Nessuna stampante configurata.",
|
||||||
"printers_remove": "Rimuovi stampante",
|
"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_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
|
||||||
"printers_switch": "Cambia →",
|
"printers_switch": "Cambia →",
|
||||||
"progress_action_clear": "Cancella",
|
"progress_action_clear": "Cancella",
|
||||||
@@ -245,6 +249,11 @@
|
|||||||
"settings_language": "Lingua",
|
"settings_language": "Lingua",
|
||||||
"settings_mode_id": "ID modalità",
|
"settings_mode_id": "ID modalità",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"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_port": "Porta MQTT",
|
||||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||||
"settings_orca_profiles_import": "Importa profili",
|
"settings_orca_profiles_import": "Importa profili",
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
"printers_loading": "加载中…",
|
"printers_loading": "加载中…",
|
||||||
"printers_none": "未配置打印机。",
|
"printers_none": "未配置打印机。",
|
||||||
"printers_remove": "移除打印机",
|
"printers_remove": "移除打印机",
|
||||||
|
"printers_power": "切换打印机电源",
|
||||||
|
"printers_power_on": "电源:开",
|
||||||
|
"printers_power_off": "电源:关",
|
||||||
|
"printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。",
|
||||||
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
|
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
|
||||||
"printers_switch": "切换 →",
|
"printers_switch": "切换 →",
|
||||||
"progress_action_clear": "清除",
|
"progress_action_clear": "清除",
|
||||||
@@ -257,6 +261,11 @@
|
|||||||
"settings_language": "语言",
|
"settings_language": "语言",
|
||||||
"settings_mode_id": "模式 ID",
|
"settings_mode_id": "模式 ID",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"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_port": "MQTT 端口",
|
||||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||||
"settings_orca_profiles_import": "导入配置文件",
|
"settings_orca_profiles_import": "导入配置文件",
|
||||||
|
|||||||
Reference in New Issue
Block a user