Files
KX-Bridge-Release/kobrax_moonraker_bridge.py
viewit 6ab5c31035
All checks were successful
Testing Build / build (push) Successful in 8m44s
feat(kxgauge): add KXGauge round-display integration
Adds an optional per-printer integration with KXGauge
(https://gitea.it-drui.de/viewit/kxgauge), a small ESP32 round-face
display that shows printer status as an emotion and hotend temperature
as a color ring. KXGauge only exposes a GET-only HTTP API with no
push/websocket, so the bridge actively pushes to it from the existing
MQTT callbacks (_on_temp for the heat ring, _on_print + offline
transitions for the emotion) whenever state actually changes, with a
built-in dedupe so it doesn't spam the device every poll tick.

- kxgauge_client.py: thin synchronous HTTP client (mirrors
  spoolman_client.py's shape - called from the MQTT reader thread).
- New [kxgauge]/[kxgauge_mapping] config.ini sections; the mapping
  (kobra_state -> KXGauge emotion) is user-editable with a sane
  default and falls back per-key if only partially configured.
- Settings UI: new card under Integrations with enable/URL/target-temp
  fields, a per-state emotion mapping list, and a connection-test
  button (/api/kxgauge/test).
- Multi-printer aware: kxgauge_url/enabled/heat_peak merge per
  [printer_N] like the existing power-switch settings.

Also fixes a real bug found while testing this: _find_config_path()
resolves to the live project config/config.ini, not a sandboxed path,
so any test hitting /api/settings POST without stubbing it out will
silently overwrite the real printer config. test_settings.py already
guards against this - test_kxgauge.py now does too.
2026-08-05 15:13:43 +02:00

1377 lines
67 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
kobrax_moonraker_bridge.py - Moonraker-compatible HTTP/WebSocket bridge for the Anycubic Kobra X
Emulates the Moonraker/Klipper API so OrcaSlicer can control the Kobra X directly.
Verwendung:
python kobrax_moonraker_bridge.py --printer-ip 192.168.178.94
OrcaSlicer-Konfiguration:
Drucker-Typ: Klipper | Host: 127.0.0.1 | Port: 7125
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License v3.0 as published
by the Free Software Foundation. See the LICENSE file in the project root
or <https://www.gnu.org/licenses/gpl-3.0.html> for the full text.
This program is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Reverse-engineering of the Anycubic Kobra X MQTT protocol was carried out
for interoperability purposes (§69e UrhG / EU Software Directive Art. 6).
This project is not affiliated with Anycubic. See NOTICE.md for details.
"""
import argparse
try:
import config_loader as env_loader
except ImportError:
import env_loader
import asyncio
import copy
import json
import logging
import os
import re
import sys
import time
import threading
# For PyInstaller binaries everything sits next to sys.executable, otherwise next to __file__
_BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _BASE)
# Read-only web assets (themes) are embedded in the onefile binary via --add-data under
# sys._MEIPASS entpackt; im Script-/Docker-Modus liegen sie neben dieser Datei.
_WEB_BASE = getattr(sys, "_MEIPASS", _BASE)
from kobrax_client import KobraXClient
# Extracted modules, re-exported here so existing imports (tests, callers)
# keep working against kobrax_moonraker_bridge unchanged.
from spoolman_client import SpoolmanClient
from kxgauge_client import KXGaugeClient
from gcode_store import GCodeStore
from gcode_meta import (
_parse_gcode_estimated_time,
_parse_gcode_layer_heights,
_extract_thumbnail,
_extract_filament_info,
)
from camera import CameraCache, _find_ffmpeg
from credentials import _kx_fetch_credentials, _kx_generate_signature, _kx_decrypt_info
from bridge_spoolman import SpoolmanMixin
from bridge_mqtt import MqttCallbacksMixin
from bridge_ams import AmsFilamentMixin
from bridge_moonraker import MoonrakerCompatMixin
from bridge_endpoints import EndpointsMixin
try:
from aiohttp import web
import aiohttp
except ImportError:
print("Error: aiohttp is not installed. Run: pip install aiohttp")
sys.exit(1)
logging.basicConfig(level=logging.INFO,
format="[%(asctime)s] %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S")
log = logging.getLogger("bridge")
# aiohttp logs one INFO line per HTTP request (access log) — with 2s frontend
# polling that drowns out the bridge's own logs by default. Toggleable at
# runtime via the verbose_http_log setting (see handle_api_settings_post).
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
# UI theme-name validation (used in __init__); the /kx/ui asset-serving
# constants live in bridge_endpoints alongside their only consumers.
_UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
# Browser log stream (ring buffer + SSE queues + handler) lives in
# bridge_logging; import the shared buffer/queues so the log-stream and
# log-download endpoints below operate on the same objects the handler writes.
from bridge_logging import (
_set_verbose_http_log,
_log_buffer,
_log_sse_queues,
_BrowserLogHandler,
_browser_handler,
)
from bridge_constants import (
KOBRA_TO_KLIPPER_STATE, MOONRAKER_VERSION, KLIPPER_VERSION,
DEFAULT_KXGAUGE_MAPPING,
)
class KobraXBridge(SpoolmanMixin, MqttCallbacksMixin, AmsFilamentMixin,
MoonrakerCompatMixin, EndpointsMixin):
def _kxgauge_notify_state(self, kobra_state: str) -> None:
"""Fires the mapped KXGauge emotion for a state set outside of
_on_print (offline transitions in _poll_loop/run_bridge, which set
self._state["kobra_state"] directly rather than through the MQTT
print/report callback)."""
if not self._kxgauge:
return
emotion = self._kxgauge_mapping.get(kobra_state)
if emotion:
self._kxgauge.set_emotion(emotion)
def _load_kxgauge_mapping_config(self) -> dict[str, str]:
"""Reads [kxgauge_mapping] (kobra_state -> KXGauge emotion name) from
config.ini. Falls back to DEFAULT_KXGAUGE_MAPPING for any state not
present in the file, or entirely if the section is missing."""
import configparser
cfg_path = self._find_config_path()
if not cfg_path.is_file():
return dict(DEFAULT_KXGAUGE_MAPPING)
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(cfg_path, encoding="utf-8")
sec = "kxgauge_mapping"
if not cfg.has_section(sec):
return dict(DEFAULT_KXGAUGE_MAPPING)
out = dict(DEFAULT_KXGAUGE_MAPPING)
for key, value in cfg.items(sec):
if value.strip():
out[key] = value.strip().lower()
return out
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
self.client = client
self._args = args
self._printer_id = printer_id
self._all_bridges = all_bridges if all_bridges is not None else {}
self.ws_clients: set[web.WebSocketResponse] = set()
# In-memory KV store for Moonraker /server/database/item (moonraker-obico,
# mainsail presets etc.). Not persistent - does not survive a restart.
self._moonraker_kv_store: dict[str, dict] = {}
# Slot -> Orca filament profile mapping (from config.ini [filament_profiles]).
# Format: {slot_idx: {"id": "OGFL01", "vendor": "Polymaker"}}.
# Used in _build_lane_data so OrcaSlicer shows the concrete
# brand ("PolyTerra PLA - Polymaker") instead of just "Generic PLA".
try:
import config_loader as _cl
self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles(self._printer_id)
except Exception:
self._filament_profiles = {}
# Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
# Empty list = all vendors visible (backwards compatible).
try:
import config_loader as _cl
self._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id)
except Exception:
self._visible_vendors = []
self._last_state: dict = {}
self._last_ams_set_request: dict | None = None
self._state = {
"nozzle_temp": 0.0,
"nozzle_target": 0.0,
"bed_temp": 0.0,
"bed_target": 0.0,
"print_state": "standby",
"kobra_state": "free",
"filename": "",
"slicer_time": 0,
"progress": 0.0,
"print_duration": 0,
"remain_time": 0,
"curr_layer": 0,
"total_layers": 0,
# Layer heights for the currently running file (parsed from the
# GCode header). Set in the upload path + in _fetch_from_store.
# Obico uses currentZ from gcode_position[2] - the bridge computes
# currentZ from curr_layer + these values in build_print_payload.
"layer_height": 0.0,
"first_layer_height": 0.0,
"printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"),
"firmware_version": "unknown",
"upload_url": "",
"camera_url": "",
"fan_speed": 0,
"light_on": False,
"light_brightness": 80,
"taskid": "-1",
"print_speed_mode": 2,
"connection_error": "",
"file_ready": "",
"filament_mismatch": None,
"print_start_dialog": getattr(args, "print_start_dialog", 1),
"filament_mode": "toolhead",
"supplies_usage": 0,
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
"error_code": 0,
"pause_msg": "",
"storage_total_mb": 0,
"storage_used_mb": 0,
}
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot
self._pending_load_slot: int = -1 # global slot index requested via /api/ams/feed type=1
self._ace_box_ids: list[int] = [] # detected ACE unit IDs (0..3)
self._ace_auto_feed: dict[int, int] = {} # per-box auto_feed state (0/1)
self._head_tools_model: int = -1
self._filament_mode: str = "toolhead"
self._last_uploaded_file: str = ""
# Pending waiters for a specific file/report `action` (e.g. "listLocal",
# "deleteBatch"). publish()'s own return value for these actions is just
# a generic immediate ACK skeleton (code=0, all fields empty) - the real
# answer arrives later via the file/report callback (_on_file), same
# as the existing fileDetails fire-and-forget pattern. Format:
# {action: {"event": threading.Event(), "result": dict|None}}.
self._file_action_waiters: dict[str, dict] = {}
# Thumbnail cache for files on the printer's own storage (filename ->
# base64 PNG string, "" if the file has no embedded thumbnail).
# In-memory only - not persisted, cleared on restart.
self._printer_thumbnail_cache: dict[str, str] = {}
# Last buried/report payload (printer's own analytics event, fired once
# per print start regardless of slicer - see reference_buried_report_trigger
# memory). Carries gcode_size/estimate_duration/total_layers that are
# otherwise unavailable for files not uploaded through the bridge itself
# (Issue #102). Single entry only - just the most recent print.
self._buried_cache: dict | None = None
self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = ""
# Filename of the file backing _current_job_id, kept alongside it so
# the "finished" handler can still delete it from the printer's own
# storage (Issue: delete-after-print) after self._state["filename"]
# has already been cleared as part of the terminal-state reset below.
self._current_job_filename: str = ""
self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # user manually stopped the camera during a print
self.camera_cache: CameraCache = CameraCache()
self._thumbnail_b64: str = ""
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
# Part skip: most recent skip list reported by the printer (v0.9.10)
self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0}
# Pre-Print-Skip: pending until printer enters printing state
self._pending_preprint_skip: list[str] = []
self._pending_preprint_skip_deadline: float = 0.0
# Spoolman filament tracking
_sm_url = (getattr(args, "spoolman_server", "") or "").strip()
self._spoolman: SpoolmanClient | None = (
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
if _sm_url else None
)
# Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden.
# Fix: this used to reference `config_loader`, but the module alias is
# `env_loader` (line 32) -> NameError swallowed by the bare `except`,
# so persistence never loaded. Now via the local import + per printer.
try:
import config_loader as _cl
self._spoolman_slot_spools: dict[int, int] = _cl.list_spool_map(self._printer_id)
except Exception as _e:
log.warning("Spoolman: failed to load slot map: %s", _e)
self._spoolman_slot_spools = {} # {ams_slot_idx: spoolman_spool_id}
self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print
self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman
self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick
self._spoolman_last_sync: float = 0.0
# KXGauge display (https://gitea.it-drui.de/viewit/kxgauge)
_kg_url = (getattr(args, "kxgauge_url", "") or "").strip()
_kg_enabled = bool(int(getattr(args, "kxgauge_enabled", 0) or 0))
try:
_kg_heat_peak = float(getattr(args, "kxgauge_heat_peak", 250) or 250)
except (TypeError, ValueError):
_kg_heat_peak = 250.0
self._kxgauge: KXGaugeClient | None = (
KXGaugeClient(_kg_url, _kg_heat_peak) if (_kg_enabled and _kg_url) else None
)
self._kxgauge_mapping: dict[str, str] = self._load_kxgauge_mapping_config()
# Validate theme name (no special characters or umlauts)
raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
if not _UI_THEME_NAME_RE.match(raw_theme):
log.warning("Invalid UI theme name %r using default", raw_theme)
raw_theme = "default"
self._ui_theme = raw_theme
self._index_tpl_cache: str | None = None
self._index_tpl_cache_key: tuple[str, float] | None = None
# Register MQTT push callbacks
client.callbacks["tempature/report"] = self._on_temp
client.callbacks["print/report"] = self._on_print
client.callbacks["info/report"] = self._on_info
client.callbacks["file/report"] = self._on_file
client.callbacks["buried/report"] = self._on_buried
client.callbacks["multiColorBox/report"] = self._on_multicolor_box
client.callbacks["light/report"] = self._on_light
client.callbacks["skip/report"] = self._on_skip
# Reachability is rechecked periodically (not just once at boot) so the
# UI status dot reflects the printer's/Spoolman's actual current state
# instead of freezing on the boot-time result.
self._spoolman_reachable: bool = False
self._spoolman_last_health_check: float = 0.0
if self._spoolman:
def _check():
ok = self._spoolman.health_check()
self._spoolman_reachable = ok
self._spoolman_last_health_check = time.time()
log.info(f"Spoolman: {'OK' if ok else 'unreachable'} at {self._spoolman.server_url}")
threading.Thread(target=_check, daemon=True, name="spoolman-health").start()
@staticmethod
def _layer_height_from_filename(fname: str) -> float:
"""OrcaSlicer-Default-Filename-Pattern: `<plate>_<material>_<layer>_<dur>.gcode`
z.B. `adapter_e27_plate(01)_PLA_0.2_41m1s.gcode` → 0.2.
Fallback when the GCode header was not parsed (e.g. file started directly
on the slicer, or uploaded before v0.9.18). Returns 0.0 when the
pattern does not match."""
import re
if not fname:
return 0.0
m = re.search(r"_(0\.\d+)_(\d+[hms])", fname)
if not m:
return 0.0
try:
return float(m.group(1))
except Exception:
return 0.0
def _estimate_current_z(self) -> float:
"""Estimates the current Z height from curr_layer + layer heights.
The printer provides no real Z position via MQTT, but Obico
(moonraker-obico/printer.py:267) reads currentZ from `gcode_position[2]`.
We back-compute it with the layer_height from the GCode header:
z = first_layer_height + (curr_layer - 1) * layer_height
Values are set in the upload path and only reset on print cancel/end
(slot/color changes do not affect them). If the values are
missing (e.g. because the print was started directly on the slicer
without an upload through the bridge), they are reloaded once from
the GCode store. Returns 0.0 when nothing is known - Obico then shows
keinen Z-Wert."""
s = self._state
layer_h = float(s.get("layer_height") or 0.0)
first_h = float(s.get("first_layer_height") or 0.0)
fname = s.get("filename", "")
if not layer_h and fname:
try:
gf = self._store.get_file_by_name(fname)
if gf:
layer_h = float(gf.get("layer_height") or 0.0)
first_h = float(gf.get("first_layer_height") or layer_h)
except Exception:
pass
if not layer_h and fname:
# Last fallback: the OrcaSlicer default filename contains the layer height
layer_h = self._layer_height_from_filename(fname)
if layer_h and not first_h:
first_h = layer_h
if layer_h:
# cache in state so not every build queries the store again
s["layer_height"] = layer_h
s["first_layer_height"] = first_h
if not layer_h:
return 0.0
curr = int(s.get("curr_layer") or 0)
if curr <= 0:
return 0.0
# Layer 1 = first_layer_height, Layer 2 = first + layer_h, …
return round(first_h + max(0, curr - 1) * layer_h, 3)
# -------------------------------------------------------------------------
# WebSocket push
# -------------------------------------------------------------------------
# Static objects that never change at runtime. They are delivered once
# via objects.query/subscribe, but NOT included in every
# notify_status_update - otherwise Mobileraker's
# ConfigFile.parse (expensive + strict) runs on every status tick and the app
# hangs/crashes on refresh (Issue #48).
_STATIC_STATUS_OBJECTS = ("configfile", "webhooks", "heaters", "history")
def _push_status_update(self):
if not self.ws_clients:
return
objs = self._build_printer_objects()
live = {k: v for k, v in objs.items() if k not in self._STATIC_STATUS_OBJECTS}
msg = {
"jsonrpc": "2.0",
"method": "notify_status_update",
"params": [live, time.time()],
}
text = json.dumps(msg)
dead = set()
for ws in self.ws_clients:
try:
asyncio.run_coroutine_threadsafe(ws.send_str(text), ws._loop)
except Exception:
dead.add(ws)
self.ws_clients -= dead
def _build_mmu_object(self) -> dict:
# POSITIONSTREU: ein Gate je physischem Slot, in Reihenfolge. Leere Slots
# get gate_status=0 (instead of being omitted) - otherwise the
# Farben in OrcaSlicer auf falsche Gates (Slot 1=gelb, 2=leer, 3=rot →
# red must not land on gate 1). gate_status 0=empty, 1=available.
slots = sorted(
((int(s.get("global_index", i)), s) for i, s in enumerate(self._ams_slots)),
key=lambda item: item[0],
)
if not slots:
return {}
_TEMP = {"PLA": 210, "PETG": 230, "ABS": 240, "ASA": 250,
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
num_gates = len(slots)
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
gate_filament_name = []
gate_spool_id = []
for _global_index, slot in slots:
occupied = slot.get("status") == 5
gate_status.append(1 if occupied else 0)
material = self._normalize_material(slot.get("type") or "PLA") if occupied else ""
gate_material.append(material)
c = slot.get("color", [0, 0, 0]) if occupied else [0, 0, 0]
# Happy Hare expects gate_color as RRGGBB WITHOUT '#' (Klipper limitation).
# Leerer Gate: leerer String + RGB [0,0,0].
gate_color.append("{:02X}{:02X}{:02X}".format(*c[:3]) if occupied else "")
gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)] if occupied else [0.0, 0.0, 0.0])
gate_temperature.append(_TEMP.get(material, 210) if occupied else 0)
# gate_filament_name from user override or material default for the
# HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den
# HH path (MMU detection), PR #13719 evaluates this field as a
# preset name -> 'Anycubic PLA' matches the printer-specific
# preset; an empty string previously led to Generic PLA.
if occupied:
# Stale-profile guard (see _effective_slot_profile): only apply the
# override while its material family still matches the loaded filament.
user_profile = self._effective_slot_profile(_global_index, material)
fila_name = user_profile.get("name") or self._default_filament_name(material)
gate_filament_name.append(fila_name)
else:
gate_filament_name.append("")
# Spoolman spool ID per gate from the (printer-specific) slot map so
# Happy Hare/OrcaSlicer can show the bound spool (-1 = none).
gate_spool_id.append(self._spoolman_slot_spools.get(_global_index, -1) if occupied else -1)
loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(slots)}
active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
return {
"num_gates": num_gates,
"enabled": True,
"gate_status": gate_status,
"gate_material": gate_material,
"gate_color": gate_color,
"gate_temperature": gate_temperature,
"gate_color_rgb": gate_color_rgb,
"gate_filament_name": gate_filament_name,
"gate_spool_id": gate_spool_id,
"ttg_map": list(range(num_gates)),
"tool": active_gate,
"gate": active_gate,
}
def _default_filament_name(self, material: str) -> str:
"""Default name for `gate_filament_name`/`name` in lane_data when no
user override is set. Deliberate design decision: **always
Generic <type>** as the default - the library profile is `compatible_printers:[]`
(= compatible with every printer) and therefore guaranteed to be visible.
OrcaSlicer then matches the neutral generic preset and the user
can set a concrete brand per slot if they want to."""
if not material:
return ""
mat = self._normalize_material(material)
profs = self._load_orca_filaments()
# Varianten-Mapping: Drucker meldet z.B. "PLA SILK", OrcaSlicer speichert
# all variants under type=PLA with the variant name in the name field.
_VARIANT_NAME = {
"PLA SILK": "Generic PLA Silk",
"PLA MATTE": "Generic PLA Matte",
"PLA+": "Generic PLA",
"PLA-CF": "Generic PLA-CF",
"PETG-CF": "Generic PETG-CF",
}
if mat in _VARIANT_NAME:
target = _VARIANT_NAME[mat]
for p in profs:
if p.get("vendor") == "Generic" and p.get("name") == target:
return p["name"]
def _match_type(p: dict) -> bool:
pt = (p.get("type") or "").upper()
return pt == mat or pt.startswith(mat + "-") or pt.startswith(mat + " ")
# Library-Generic-Profil (immer is_visible+is_compatible)
for p in profs:
if p.get("vendor") == "Generic" and p.get("name", "").startswith("Generic ") and _match_type(p):
return p.get("name", "")
# If the library generic for this exotic material type is missing,
# we return nothing - OrcaSlicer falls back to filament_id_by_type.
return ""
def _build_printer_objects(self) -> dict:
s = self._state
return {
"extruder": {
"temperature": s["nozzle_temp"],
"target": s["nozzle_target"],
"power": 0.0,
},
"heater_bed": {
"temperature": s["bed_temp"],
"target": s["bed_target"],
"power": 0.0,
},
"print_stats": {
"state": s["print_state"],
"filename": s["filename"],
"print_duration": s["print_duration"],
"total_duration": s["print_duration"],
"remain_time": s["remain_time"],
"info": {
"current_layer": s["curr_layer"],
"total_layer": s["total_layers"],
},
},
"display_status": {
"progress": s["progress"],
"message": "",
},
"virtual_sdcard": {
"progress": s["progress"],
"is_active": s["print_state"] == "printing",
"file_path": s["filename"],
# file_position approximiert: fraction × est_total_size.
# The printer does not provide an exact value; Obico only uses it for display.
"file_position": int(s["progress"] * 1_000_000) if s["progress"] else 0,
},
"toolhead": {
"position": [0, 0, 0, 0],
"homed_axes": "xyz",
"print_time": s["print_duration"],
"estimated_print_time": s["print_duration"],
},
"mmu": self._build_mmu_object(),
# -- Moonraker compatibility for moonraker-obico --
"heaters": {
"available_heaters": ["extruder", "heater_bed"],
"available_sensors": [],
"available_monitors": [],
},
"webhooks": {
"state": "ready",
"state_message": "Printer is ready",
},
# speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5)
# Estimate the current Z height for Obico from curr_layer + layer heights
# (the printer provides no real Z position via MQTT). gcode_position[2]
# is the value moonraker-obico reads as currentZ in printer.py.
"gcode_move": {
"speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0),
"extrude_factor": 1.0,
"speed": 0,
"gcode_position": [0, 0, self._estimate_current_z(), 0],
"absolute_coordinates": True,
"absolute_extrude": True,
"homing_origin": [0, 0, 0, 0],
"position": [0, 0, self._estimate_current_z(), 0],
},
# motion_report: Mobileraker reads the live velocity here
# (live_velocity). The Kobra X MQTT provides NO real mm/s, only
# a print_speed_mode (1-4). live_velocity therefore stays 0 - but the
# object must exist, otherwise Mobileraker displays nothing
# (motion_report used to be null). live_position mirrors the
# estimated Z height (like gcode_move).
"motion_report": {
"live_position": [0, 0, self._estimate_current_z(), 0],
"live_velocity": 0.0,
"live_extruder_velocity": 0.0,
},
"fan": {
"speed": (int(s.get("fan_speed") or 0)) / 100.0,
"rpm": None,
},
# history (object): Obico subscribes to it as an object; the actual
# /server/history/list endpoint delivers the real list separately.
"history": {
"job_totals": {
"total_jobs": 0,
"total_time": 0,
"total_print_time": 0,
"total_filament_used": 0.0,
"longest_job": 0,
"longest_print": 0,
},
"current_job": None,
},
# Pseudo Klipper macros for moonraker-obico:
# - _OBICO_LAYER_CHANGE reports the current layer number. Obico uses this
# for "first layer scan" triggers and layer-aligned time-lapse frames.
# We feed this from the MQTT stream (s["curr_layer"]).
# - TIMELAPSE_TAKE_FRAME signals that the current pause comes from the
# time-lapse (otherwise Obico would interpret the pause as a user
# pause). We set is_paused=False because our pauses are
# never time-lapse pauses.
"gcode_macro _OBICO_LAYER_CHANGE": {
"current_layer": int(s.get("curr_layer") or 0),
"first_layer_scanning": False,
"first_layer_scan_enabled": False,
},
"gcode_macro TIMELAPSE_TAKE_FRAME": {
"is_paused": False,
},
# configfile stub - Mobileraker and other clients crash without
# this object (Missing field: configFile). Values from the
# decrypted avata_main.conf (ACCFG1.0 - Kobra X firmware).
# Mobileraker (Issue #48) parses BOTH branches config + settings via
# denselben ConfigFile.parse → ConfigExtruder.fromJson; ein leeres
# config:{} crashed the non-nullable Dart parser. Therefore
# config identisch zu settings gespiegelt.
"configfile": self._klipper_configfile_stub(),
}
def _klipper_configfile_stub(self) -> dict:
"""Minimal Klipper configfile stub for Mobileraker/OctoApp (Issue #48).
Mobileraker parses BOTH branches `config` and `settings` through the same
ConfigFile.parse → ConfigExtruder.fromJson. Ein leeres `config: {}`
crashed the non-nullable Dart parser, therefore `config` is
mirrored identically to `settings`. Values from the decrypted
avata_main.conf (ACCFG1.0 — Kobra X Firmware).
"""
settings = {
"printer": {
"kinematics": "cartesian",
"max_velocity": 450,
"max_accel": 10000,
"max_z_velocity": 12,
"max_z_accel": 100,
"square_corner_velocity": 20.0,
},
"extruder": {
"nozzle_diameter": 0.4,
"filament_diameter": 1.75,
"sensor_type": "ATC Semitec 104GT-2",
"min_temp": 0,
"max_temp": 320,
"min_extrude_temp": 10,
# Mobileraker ConfigExtruder erwartet diese Felder non-nullable
# (max_extrude_only_distance, max_power) or present as a key
# (max_extrude_only_velocity/accel may be null). Missing =
# Crash in ConfigExtruder.fromJson (Issue #48).
"max_extrude_only_distance": 100.0,
"max_power": 1.0,
"max_extrude_only_velocity": None,
"max_extrude_only_accel": None,
},
"heater_bed": {
# Mobileraker ConfigHeaterBed: heater_pin, sensor_type, control
# are non-nullable. Values are placeholders (the bridge does not know
# the real pins - Anycubic firmware, no Klipper printer.cfg).
"heater_pin": "PA0",
"sensor_type": "ATC Semitec 104GT-2",
"control": "pid",
"min_temp": 0,
"max_temp": 120,
},
# Fill stepper_* with non-nullable required fields (step_pin, dir_pin,
# rotation_distance), otherwise ConfigStepper.fromJson crashes.
"stepper_x": {"step_pin": "PA1", "dir_pin": "PA2", "rotation_distance": 40,
"position_min": -18.5, "position_max": 280},
"stepper_y": {"step_pin": "PA3", "dir_pin": "PA4", "rotation_distance": 40,
"position_min": -6.5, "position_max": 272.5},
"stepper_z": {"step_pin": "PA5", "dir_pin": "PA6", "rotation_distance": 8,
"position_min": -4, "position_max": 262},
"virtual_sdcard": {"path": "/data/gcodes"},
"pause_resume": {},
"display_status": {},
}
# config + settings must contain the same fields - Mobileraker
# parses both. deepcopy so no client is affected by a shared reference
# versehentlich beide Zweige mutiert.
return {
"config": copy.deepcopy(settings),
"settings": settings,
"warnings": [],
"save_config_pending": False,
"save_config_pending_items": {},
}
# -------------------------------------------------------------------------
# /kx/ API handlers (GCode Store, History, Filament)
# -------------------------------------------------------------------------
_CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
def _json_cors(self, data, status=200):
return web.json_response(data, status=status, headers=self._CORS)
# -------------------------------------------------------------------------
# WebSocket handler
# -------------------------------------------------------------------------
async def handle_websocket(self, request):
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
ws._loop = asyncio.get_event_loop()
self.ws_clients.add(ws)
log.info(f"WS client connected ({len(self.ws_clients)} total)")
# Send klippy_ready notification
await ws.send_str(json.dumps({
"jsonrpc": "2.0",
"method": "notify_klippy_ready",
"params": [],
}))
# Send initial status
await ws.send_str(json.dumps({
"jsonrpc": "2.0",
"method": "notify_status_update",
"params": [self._build_printer_objects(), time.time()],
}))
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_ws_rpc(ws, msg.data)
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break
self.ws_clients.discard(ws)
log.info(f"WS client disconnected ({len(self.ws_clients)} remaining)")
return ws
async def _handle_ws_rpc(self, ws: web.WebSocketResponse, raw: str):
try:
req = json.loads(raw)
except Exception:
return
rpc_id = req.get("id")
method = req.get("method", "")
log.info(f"WS RPC: {method} params={str(req.get('params',''))[:120]}")
params = req.get("params") or {}
if isinstance(params, list):
params = params[0] if params else {}
result = None
error = None
try:
if method in ("printer.info", "printer_info"):
result = {
"state": "ready",
"state_message": "Printer is ready",
"hostname": "kobrax-bridge",
"software_version": KLIPPER_VERSION,
"cpu_info": self._state["printer_name"],
"klipper_path": "/home/pi/klipper",
"python_path": "/home/pi/klippy-env/bin/python",
}
elif method in ("server.info", "server_info"):
result = {
"klippy_connected": True,
"klippy_state": "ready",
"moonraker_version": MOONRAKER_VERSION,
"components": [],
"failed_components": [],
"registered_directories": ["gcodes"],
"warnings": [],
}
elif method in ("printer.objects.list",):
result = {"objects": list(self._build_printer_objects().keys())}
elif method in ("printer.objects.query", "printer.objects.get"):
objects = params.get("objects", {})
all_objs = self._build_printer_objects()
if objects:
filtered = {k: all_objs.get(k, {}) for k in objects}
else:
filtered = all_objs
result = {"status": filtered, "eventtime": time.time()}
elif method == "printer.objects.subscribe":
objects = params.get("objects", {})
all_objs = self._build_printer_objects()
if objects:
filtered = {k: all_objs.get(k, {}) for k in objects}
else:
filtered = all_objs
result = {"status": filtered, "eventtime": time.time()}
elif method == "printer.print.start":
filename = params.get("filename", self._last_uploaded_file)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None, lambda: self.client.publish("print", "start",
{"filename": filename, "use_ams": False}, timeout=15.0)
)
result = "ok" if resp else "timeout"
elif method == "printer.print.pause":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.pause_print)
result = "ok"
elif method == "printer.print.resume":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.resume_print)
result = "ok"
elif method == "printer.print.cancel":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.stop_print)
result = "ok"
elif method == "machine.system_info":
result = {"system_info": {"cpu_info": {"cpu_desc": "Kobra X Bridge"}}}
elif method == "server.files.list":
result = []
# ── moonraker-obico passthru-Targets ──
elif method == "printer.gcode.script":
script = (params.get("script") or "").strip().upper() if isinstance(params, dict) else ""
result = await self._exec_gcode_script(script)
elif method in ("server.connection.identify",):
# Obico identifies itself on connect. Connection ID doesn't matter.
result = {"connection_id": 1}
elif method == "connection.register_remote_method":
# Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
result = "ok"
elif method == "server.webcams.list":
# WS variant: absolute URL with the real LAN IP instead of localhost
_lip = getattr(self, "_local_ip", None) or "127.0.0.1"
_base = f"http://{_lip}:{self._args.port}"
result = {"webcams": [{
"name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer",
"enabled": True,
"stream_url": f"{_base}/api/camera/stream",
"snapshot_url": f"{_base}/api/camera/snapshot",
"flip_horizontal": False, "flip_vertical": False, "rotation": 0,
"target_fps": 5, "aspect_ratio": "16:9",
}]}
elif method == "server.history.list":
# Reuse the HTTP handler logic (Moonraker schema with Unix TS).
try:
jobs = self._store.list_jobs(limit=50) or []
except Exception:
jobs = []
from datetime import datetime, timezone as _tz
def _ts(iso):
try:
return datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_tz.utc).timestamp()
except Exception:
return 0.0
result = {"count": len(jobs), "jobs": [
{"job_id": j.get("id"), "exists": True, "filename": j.get("filename",""),
"status": j.get("status") or "completed",
"print_duration": j.get("duration_sec") or 0,
"total_duration": j.get("duration_sec") or 0,
"start_time": _ts(j.get("started_at")),
"end_time": (_ts(j.get("started_at")) + (j.get("duration_sec") or 0)) if j.get("started_at") and j.get("duration_sec") else None,
"filament_used": 0.0, "metadata": {}}
for j in jobs
]}
elif method == "machine.update.status":
result = {"busy": False, "version_info": {}}
elif method == "server.files.metadata":
# Obico + Mobileraker request metadata for a file. Same
# logic as the HTTP endpoint (previously a separate broken path with
# a non-existent store method -> empty response ->
# Mobileraker-Endlosschleife, Issue #48).
fname = (params or {}).get("filename") if isinstance(params, dict) else None
fname = fname or self._state.get("filename", "")
result = self._build_file_metadata(fname) if fname else {}
else:
log.debug(f"Unbekannte RPC-Methode: {method}")
result = {}
except Exception as e:
log.error(f"RPC error for {method}: {e}")
error = {"code": -32603, "message": str(e)}
if rpc_id is not None:
response = {"jsonrpc": "2.0", "id": rpc_id}
if error:
response["error"] = error
else:
response["result"] = result
await ws.send_str(json.dumps(response))
# -------------------------------------------------------------------------
# Poll loop (sync, runs in executor)
# -------------------------------------------------------------------------
def _printer_reachable(self) -> bool:
"""TCP probe on the MQTT port - no ICMP needed, no root required."""
import socket as _socket
try:
with _socket.create_connection(
(self._args.printer_ip, self._args.mqtt_port), timeout=2.0
):
return True
except OSError:
return False
def _poll_loop(self, stop_event: threading.Event):
_offline = self._state["kobra_state"] == "offline"
_probe_interval = 10.0 # Sekunden zwischen TCP-Probes im Offline-Modus
while not stop_event.is_set():
# ── Offline-Modus: warten bis Drucker wieder erreichbar ──────────
if _offline:
if self._printer_reachable():
log.info("Printer reachable - establishing MQTT connection...")
try:
self.client.connect()
_offline = False
self._state["print_state"] = "standby"
self._state["kobra_state"] = "free"
self._kxgauge_notify_state("free")
self._state["connection_error"] = ""
log.info("MQTT connection re-established")
except Exception as e:
err = _mqtt_error_msg(e)
self._state["connection_error"] = err
log.warning(f"Connection attempt failed: {err}")
stop_event.wait(_probe_interval)
continue
else:
stop_event.wait(_probe_interval)
continue
# ── Online-Modus: normaler Poll ──────────────────────────────────
try:
info = self.client.query_info()
if info:
self._on_info(info)
elif not self.client.is_connected():
# publish() swallows send/reconnect failures internally and
# just returns None (Issue #105) - a falsy `info` alone
# doesn't distinguish "printer sent nothing this tick" from
# "the MQTT session itself is dead". Check is_connected()
# explicitly so a dead session gets routed into the same
# clean offline/reconnect path as a TCP-unreachable printer,
# instead of silently retrying every poll_interval forever.
log.warning("MQTT connection lost (query returned no response) - switching to offline mode")
self._state["print_state"] = "error"
self._state["kobra_state"] = "offline"
self._kxgauge_notify_state("offline")
self._state["connection_error"] = f"MQTT connection lost ({self._args.printer_ip})"
try:
self.client.disconnect()
except Exception:
pass
_offline = True
stop_event.wait(getattr(self._args, "poll_interval", 3))
continue
# While printing: query print/report directly
if self._state["print_state"] in ("printing", "preheating",
"auto_leveling", "checking", "init"):
print_r = self.client.publish("print", "query", timeout=3.0)
if print_r:
self._on_print(print_r)
# Spoolman mid-print sync
if (self._spoolman and self._spoolman.sync_rate > 0
and self._spoolman_slot_spools
and self._state.get("print_state") == "printing"):
now = time.time()
if now - self._spoolman_last_sync >= self._spoolman.sync_rate:
self._spoolman_sync_midprint()
self._spoolman_last_sync = now
box = self.client.query_multicolor_box()
if box:
data = box.get("data") or {}
self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
boxes = data.get("multi_color_box") or []
if boxes:
self._update_ace_drying_state(data, boxes)
self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model)
self._state["filament_mode"] = self._filament_mode
global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode)
activity_map = self._slot_activity_map(boxes, global_loaded)
for s in global_slots:
s["activity"] = activity_map.get(s.get("global_index"), "")
if global_slots:
self._ams_slots = global_slots
self._ams_loaded_slot = global_loaded
self._spoolman_attribute_tick(activity_map)
else:
# No multiColorBox data — still attribute (no transitions to skip)
self._spoolman_attribute_tick({})
# Recheck Spoolman reachability periodically so the UI status
# dot reflects the current state, not just the boot-time result.
if self._spoolman and time.time() - self._spoolman_last_health_check >= 30.0:
self._spoolman_reachable = self._spoolman.health_check()
self._spoolman_last_health_check = time.time()
except Exception as e:
log.warning(f"Poll error: {e}")
# Check whether the printer is really gone
if not self._printer_reachable():
log.info("Printer unreachable - switching to offline mode")
self._state["print_state"] = "error"
self._state["kobra_state"] = "offline"
self._kxgauge_notify_state("offline")
self._state["connection_error"] = f"Printer unreachable ({self._args.printer_ip})"
try:
self.client.disconnect()
except Exception:
pass
_offline = True
stop_event.wait(getattr(self._args, "poll_interval", 3))
# ---------------------------------------------------------------------------
# App factory + main
# ---------------------------------------------------------------------------
def _mqtt_error_msg(exc: Exception) -> str:
msg = str(exc)
if "20020005" in msg:
return "Wrong MQTT credentials (username, password or device ID incorrect)"
return msg
@web.middleware
async def cors_middleware(request, handler):
if request.method == "OPTIONS":
return web.Response(status=204, headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
})
resp = await handler(request)
resp.headers["Access-Control-Allow-Origin"] = "*"
return resp
def build_app(bridge: KobraXBridge) -> web.Application:
app = web.Application(
client_max_size=256 * 1024 * 1024,
middlewares=[cors_middleware],
)
r = app.router
# Moonraker API
r.add_get("/server/info", bridge.handle_server_info)
r.add_get("/printer/info", bridge.handle_printer_info)
r.add_get("/machine/system_info", bridge.handle_machine_system_info)
r.add_get("/printer/objects/list", bridge.handle_objects_list)
r.add_get("/printer/objects/query", bridge.handle_objects_query)
r.add_get("/printer/objects/subscribe", bridge.handle_objects_subscribe)
r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe)
r.add_get("/server/files/list", bridge.handle_files_list)
r.add_get("/server/files/metadata", bridge.handle_files_metadata)
r.add_post("/server/files/upload", bridge.handle_file_upload)
r.add_post("/printer/print/start", bridge.handle_print_start)
r.add_post("/printer/print/pause", bridge.handle_print_pause)
r.add_post("/printer/print/resume", bridge.handle_print_resume)
r.add_post("/printer/print/cancel", bridge.handle_print_cancel)
# Moonraker stubs for moonraker-obico
r.add_get("/access/api_key", bridge.handle_access_api_key)
r.add_get("/machine/update/status", bridge.handle_machine_update_status)
r.add_get("/server/history/list", bridge.handle_history_list)
r.add_get("/server/webcams/list", bridge.handle_webcams_list)
r.add_post("/printer/gcode/script", bridge.handle_printer_gcode_script)
# OctoPrint compatibility (OrcaSlicer probes this + uploads here)
r.add_get("/api/version", bridge.handle_octoprint_version)
r.add_post("/api/files/local", bridge.handle_file_upload)
r.add_post("/api/files/{path:.*}", bridge.handle_file_upload)
# Moonraker database (OrcaSlicer AMS-Sync)
r.add_get("/server/database/item", bridge.handle_moonraker_database)
r.add_post("/server/database/item", bridge.handle_moonraker_database_post)
r.add_get("/server/database/list", bridge.handle_database_list)
# New API endpoints
r.add_post("/api/light", bridge.handle_api_light)
r.add_post("/api/fan", bridge.handle_api_fan)
r.add_post("/api/connect", bridge.handle_api_connect)
r.add_post("/api/disconnect", bridge.handle_api_disconnect)
r.add_post("/api/restart", bridge.handle_api_restart)
r.add_post("/api/kxgauge/test", bridge.handle_kx_kxgauge_test)
r.add_post("/api/speed", bridge.handle_api_speed)
r.add_post("/api/ams/feed", bridge.handle_api_ams_feed)
r.add_post("/api/ams/set_slot", bridge.handle_api_ams_set_slot)
r.add_post("/api/ace/auto_feed", bridge.handle_api_ace_auto_feed)
r.add_post("/api/ace/dry", bridge.handle_api_ace_dry)
r.add_post("/api/axis", bridge.handle_api_axis)
r.add_post("/api/temperature", bridge.handle_api_temperature)
r.add_get("/api/camera", bridge.handle_api_camera)
r.add_get("/api/camera/stream", bridge.handle_camera_stream)
r.add_get("/api/camera/h264", bridge.handle_camera_h264)
r.add_get("/api/camera/snapshot", bridge.handle_api_camera_snapshot)
r.add_post("/api/camera/start", bridge.handle_api_camera_start)
r.add_post("/api/camera/stop", bridge.handle_api_camera_stop)
r.add_post("/api/camera/reset", bridge.handle_api_camera_reset)
r.add_get("/api/state", bridge.handle_api_state)
r.add_get("/api/settings", bridge.handle_api_settings_get)
r.add_post("/api/settings", bridge.handle_api_settings_post)
r.add_get("/api/update/check", bridge.handle_api_update_check)
r.add_post("/api/update/apply", bridge.handle_api_update_apply)
r.add_post("/api/file_ready/clear", bridge.handle_api_file_ready_clear)
r.add_get("/api/log/stream", bridge.handle_api_log_stream)
r.add_get("/api/log/download", bridge.handle_api_log_download)
r.add_get("/serve/{filename}", bridge.handle_serve_file)
# /kx/ GCode Store + History + Filament
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)
r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download)
r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify)
r.add_get("/kx/printer-files", bridge.handle_kx_printer_files)
r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete)
r.add_get("/kx/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail)
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
r.add_get("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors)
r.add_post("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors)
# Custom profile import (Issue #41) - the user uploads their own Orca filament
# profiles as ZIP/JSON (e.g. from ~/.config/OrcaSlicer/user/<id>/filament/),
# because the bridge typically does not run on the same host as OrcaSlicer.
r.add_get("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_list)
r.add_post("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_import)
r.add_delete("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_delete)
r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
r.add_post("/kx/skip", bridge.handle_kx_skip)
r.add_post("/kx/skip/query", bridge.handle_kx_skip_query)
r.add_get("/kx/skip/state", bridge.handle_kx_skip_state)
r.add_get("/kx/spoolman/status", bridge.handle_kx_spoolman_status)
r.add_get("/kx/spoolman/spools", bridge.handle_kx_spoolman_spools)
r.add_post("/kx/spoolman/active-spool", bridge.handle_kx_spoolman_set_active)
r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options)
# Root + Printer-Routen (Single-Page, JS liest Pathname)
r.add_get("/", bridge.handle_index)
r.add_get(r"/printer{num:\d+}", bridge.handle_index)
r.add_get("/favicon.ico", bridge.handle_favicon)
# WebSocket
r.add_get("/websocket", bridge.handle_websocket)
# Catch-all: log all unknown requests instead of 404
r.add_route("*", "/{path:.*}", bridge.handle_catchall)
return app
def _build_per_printer_args(base_args, p: dict):
"""Copy CLI args, override with the printer entry from config.ini."""
import copy
a = copy.copy(base_args)
a.printer_ip = p.get("printer_ip") or base_args.printer_ip
a.mqtt_port = int(p.get("mqtt_port") or base_args.mqtt_port)
a.username = p.get("username") or base_args.username
a.password = p.get("password") or base_args.password
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 ""
a.kxgauge_url = p.get("kxgauge_url") or getattr(base_args, "kxgauge_url", "") or ""
a.kxgauge_enabled = p.get("kxgauge_enabled") or getattr(base_args, "kxgauge_enabled", 0) or 0
a.kxgauge_heat_peak = p.get("kxgauge_heat_peak") or getattr(base_args, "kxgauge_heat_peak", 250) or 250
return a
async def run_bridge(args):
_set_verbose_http_log(bool(getattr(args, "verbose_http_log", 0)))
printers = env_loader.list_printers()
multi_mode = bool(printers)
if not printers:
printers = [{
"id": "1",
"name": getattr(args, "printer_name", None) or "Anycubic Kobra X",
"printer_ip": args.printer_ip,
"mqtt_port": args.mqtt_port,
"username": args.username,
"password": args.password,
"mode_id": args.mode_id,
"device_id": args.device_id,
"http_port": args.port,
}]
store = GCodeStore(args.data_dir)
all_bridges: dict = {}
runners = []
stop_event = threading.Event()
loop = asyncio.get_event_loop()
for idx, p in enumerate(printers):
pid = str(p.get("id") or (idx + 1))
per_args = _build_per_printer_args(args, p)
# Default port convention: 7125 + (id-1) when no http_port is set
if not p.get("http_port") and multi_mode:
try:
per_args.port = 7125 + (int(pid) - 1)
except ValueError:
per_args.port = 7125 + idx
client = KobraXClient(
host=per_args.printer_ip,
port=per_args.mqtt_port,
username=per_args.username,
password=per_args.password,
mode_id=per_args.mode_id,
device_id=per_args.device_id,
client_id=f"kobrax_bridge_{pid}",
)
bridge = KobraXBridge(
client, args=per_args, store=store,
printer_id=pid, all_bridges=all_bridges,
)
# Adopt printer_name from config.ini if set
if p.get("name"):
bridge._state["printer_name"] = p["name"]
bridge._name_locked = True
all_bridges[pid] = bridge
log.info(f"[Printer {pid}] Connecting to {per_args.printer_ip}:{per_args.mqtt_port}...")
try:
await loop.run_in_executor(None, client.connect)
log.info(f"[Printer {pid}] MQTT connected")
except Exception as e:
err = _mqtt_error_msg(e)
log.warning(f"[Printer {pid}] Connection failed: {err} - offline mode")
bridge._state["print_state"] = "error"
bridge._state["kobra_state"] = "offline"
bridge._kxgauge_notify_state("offline")
bridge._state["connection_error"] = err
threading.Thread(
target=bridge._poll_loop, args=(stop_event,),
daemon=True, name=f"poll-{pid}",
).start()
app = build_app(bridge)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, args.host, per_args.port)
await site.start()
runners.append((runner, client, pid))
import socket as _socket
_in_docker = os.path.exists("/.dockerenv")
_host_ip_override = env_loader.BRIDGE_HOST_IP.strip()
if _host_ip_override:
_local_ip = _host_ip_override
else:
try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
_s.connect(("8.8.8.8", 80))
_local_ip = _s.getsockname()[0]
except Exception:
_local_ip = args.host
# Propagate to all bridge instances - used for absolute webcam URLs
for _b in all_bridges.values():
_b._local_ip = _local_ip
ports = ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values())
if _in_docker and not _host_ip_override:
# In a container the UDP trick only yields the Docker-internal IP - don't show it
log.info(f"OrcaSlicer → Klipper → http://<IP of this Docker host>:{ports}")
log.info("Running in Docker — set BRIDGE_HOST_IP to show the exact address")
else:
log.info(f"OrcaSlicer → Klipper → http://{_local_ip}:{ports}")
log.info("Press Ctrl-C to stop")
try:
while True:
await asyncio.sleep(3600)
except (KeyboardInterrupt, asyncio.CancelledError):
pass
finally:
stop_event.set()
for runner, client, pid in runners:
try:
await runner.cleanup()
except Exception:
pass
try:
client.disconnect()
except Exception:
pass
log.info("Bridge stopped")
def _default_data_dir() -> str:
"""Persistenz-Verzeichnis: Docker setzt KX_DATA_DIR, Binary nutzt <exe-dir>/data,
Dev script uses <repo>/data (or /app/data if present)."""
if os.environ.get("KX_DATA_DIR"):
return os.environ["KX_DATA_DIR"]
if getattr(sys, "frozen", False):
return os.path.join(os.path.dirname(sys.executable), "data")
if os.path.isdir("/app"):
return "/app/data"
return os.path.normpath(os.path.join(_BASE, "..", "data"))
def main():
parser = argparse.ArgumentParser(description="Moonraker bridge for the Anycubic Kobra X")
parser.add_argument("--printer-ip", default=env_loader.PRINTER_IP,
help="IP-Adresse des Druckers")
parser.add_argument("--mqtt-port", type=int, default=env_loader.MQTT_PORT)
parser.add_argument("--username", default=env_loader.USERNAME)
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)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--delete-printer-file-after-print", type=int,
default=env_loader.DELETE_PRINTER_FILE_AFTER_PRINT,
help="After a successful print, delete the file from the printer's "
"own storage if it's also in the bridge's own GCode store")
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int)
parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER,
help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable")
parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE,
help="Mid-print filament sync interval in seconds (0 = only on print end)")
parser.add_argument("--kxgauge-url", default=env_loader.KXGAUGE_URL,
help="KXGauge display base URL (e.g. http://192.168.x.x); leave empty to disable")
parser.add_argument("--kxgauge-enabled", type=int, default=env_loader.KXGAUGE_ENABLED)
parser.add_argument("--kxgauge-heat-peak", type=float, default=env_loader.KXGAUGE_HEAT_PEAK,
help="Target temperature (°C) the KXGauge heat-ring scale maxes out at")
parser.add_argument("--poll-interval", type=int, default=env_loader.POLL_INTERVAL,
help="Printer poll interval in seconds")
parser.add_argument("--verbose-http-log", type=int, default=env_loader.VERBOSE_HTTP_LOG,
help="Log every HTTP request (aiohttp access log)")
parser.add_argument("--host", default="0.0.0.0",
help="Bind address for the bridge server")
parser.add_argument("--port", type=int, default=7125,
help="HTTP/WS-Port (Moonraker-Standard: 7125)")
parser.add_argument("--data-dir", default=_default_data_dir(),
help="Persistence directory for the GCode store and DB")
parser.add_argument(
"--ui-theme",
default=os.environ.get("KX_UI_THEME", "default"),
metavar="NAME",
help="Web-UI-Theme (Ordner web/themes/NAME/, Standard: default). "
"Alternativ: Umgebungsvariable KX_UI_THEME.",
)
args = parser.parse_args()
if args.printer_ip and ":" in args.printer_ip:
args.printer_ip = args.printer_ip.split(":")[0]
# Windows needs ProactorEventLoop for asyncio.create_subprocess_exec
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(run_bridge(args))
if __name__ == "__main__":
main()