@@ -222,7 +222,7 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
elif unit == " m " : secs + = int ( val ) * 60
elif unit == " s " : secs + = int ( val )
if secs :
log . info ( f " Slicer-Schätzzeit : { secs } s ( { m . group ( 1 ) . strip ( ) } ) " )
log . info ( f " Slicer estimate : { secs } s ( { m . group ( 1 ) . strip ( ) } ) " )
return secs
@@ -732,6 +732,40 @@ class CameraCache:
await asyncio . sleep ( 2.0 )
class SpoolmanClient :
""" Thin synchronous HTTP client for Spoolman filament tracking.
Designed to be called from daemon threads (poll loop, _on_print callbacks).
Uses requests (already in requirements) so no event-loop dependency.
"""
def __init__ ( self , server_url : str , sync_rate : int = 0 ) :
self . server_url = server_url . rstrip ( " / " )
self . sync_rate = sync_rate
def _req ( self , method : str , path : str , * * kwargs ) :
import requests
r = requests . request ( method , f " { self . server_url } { path } " , timeout = 5 , * * kwargs )
r . raise_for_status ( )
return r . json ( )
def health_check ( self ) - > bool :
try :
self . _req ( " GET " , " /api/v1/health " )
return True
except Exception :
return False
def list_spools ( self ) - > list :
return self . _req ( " GET " , " /api/v1/spool " )
def use_filament ( self , spool_id : int , use_length_mm : float ) - > None :
""" Report consumed filament length in mm. Spoolman converts to weight
using the spool ' s filament profile density. """
self . _req ( " PUT " , f " /api/v1/spool/ { spool_id } /use " ,
json = { " use_length " : round ( use_length_mm , 2 ) } )
class KobraXBridge :
def __init__ ( self , client : KobraXClient , args = None , store = None , printer_id : str = " 1 " , all_bridges = None ) :
self . client = client
@@ -784,6 +818,7 @@ class KobraXBridge:
" connection_error " : " " ,
" file_ready " : " " ,
" filament_mode " : " toolhead " ,
" supplies_usage " : 0 ,
" ace_drying " : { " status " : 0 , " target_temp " : 0 , " duration " : 0 , " remain_time " : 0 , " humidity " : None , " current_temp " : None } ,
}
self . _ams_slots : list [ dict ] = [ ] # flat global list; each entry has global_index + box_id
@@ -798,6 +833,7 @@ class KobraXBridge:
self . _serve_dir_path : str = self . _store . _gcode_dir
self . _current_job_id : str = " "
self . _camera_autostarted : bool = False
self . _camera_user_stopped : bool = False # User hat Kamera während Druck manuell gestoppt
self . camera_cache : CameraCache = CameraCache ( )
self . _thumbnail_b64 : str = " "
@@ -806,10 +842,20 @@ class KobraXBridge:
# Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10)
self . _skip_state : dict = { " objects " : [ ] , " skipped " : [ ] , " ts " : 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
)
self . _spoolman_slot_spools : dict [ int , int ] = { } # {ams_slot_idx: spoolman_spool_id}
self . _spoolman_reported_mm : float = 0.0
self . _spoolman_last_sync : float = 0.0
# Theme-Name prüfen (keine Sonderzeichen oder Umlaute)
raw_theme = ( getattr ( args , " ui_theme " , None ) or " default " ) . strip ( )
if not _UI_THEME_NAME_RE . match ( raw_theme ) :
log . warning ( " Ungültiger UI-T heme-N ame %r – nutze default " , raw_theme )
log . warning ( " Invalid UI t heme n ame %r – using default " , raw_theme )
raw_theme = " default "
self . _ui_theme = raw_theme
self . _index_tpl_cache : str | None = None
@@ -824,6 +870,117 @@ class KobraXBridge:
client . callbacks [ " light/report " ] = self . _on_light
client . callbacks [ " skip/report " ] = self . _on_skip
if self . _spoolman :
threading . Thread (
target = lambda : log . info (
f " Spoolman: { ' OK ' if self . _spoolman . health_check ( ) else ' unreachable ' } "
f " at { self . _spoolman . server_url } "
) ,
daemon = True , name = " spoolman-health " ,
) . start ( )
# ── Spoolman helpers ──────────────────────────────────────────────────────
def _spoolman_filament_mm ( self ) - > float :
""" Total filament_used_mm for the current print file from the GCode DB. """
filename = self . _state . get ( " filename " , " " )
if not filename :
return 0.0
try :
gf = self . _store . get_file_by_name ( filename )
return float ( gf . get ( " filament_used_mm " ) or 0.0 ) if gf else 0.0
except Exception :
return 0.0
def _spoolman_notify_end ( self ) :
""" Report remaining filament to Spoolman on print end (fire-and-forget).
Uses supplies_usage from the MQTT stream — the printer ' s own cumulative
extrusion counter in mm, reset each print. Accurate for both completed
and cancelled prints with no estimation needed.
For multi-slot prints the delta is split equally across all mapped spools
(v1 approximation — per-slot breakdown is not in the MQTT stream). """
if not self . _spoolman or not self . _spoolman_slot_spools :
return
used_mm = self . _state . get ( " supplies_usage " , 0 )
delta_mm = used_mm - self . _spoolman_reported_mm
if delta_mm < 0.1 :
return
n = len ( self . _spoolman_slot_spools )
per_spool_mm = delta_mm / n
sm = self . _spoolman
self . _spoolman_reported_mm = used_mm
for spool_id in self . _spoolman_slot_spools . values ( ) :
def _report ( sid = spool_id , mm = per_spool_mm ) :
try :
sm . use_filament ( sid , mm )
log . info ( f " Spoolman: reported { mm : .1f } mm to spool { sid } " )
except Exception as e :
log . warning ( f " Spoolman: end-report failed (spool { sid } ): { e } " )
threading . Thread ( target = _report , daemon = True , name = " spoolman-end " ) . start ( )
def _spoolman_sync_midprint ( self ) :
""" Report incremental filament usage to Spoolman during a print. """
if not self . _spoolman or not self . _spoolman_slot_spools :
return
used_mm = self . _state . get ( " supplies_usage " , 0 )
delta_mm = used_mm - self . _spoolman_reported_mm
if delta_mm < 10.0 :
return
n = len ( self . _spoolman_slot_spools )
per_spool_mm = delta_mm / n
sm = self . _spoolman
self . _spoolman_reported_mm = used_mm
for spool_id in self . _spoolman_slot_spools . values ( ) :
def _report ( sid = spool_id , mm = per_spool_mm ) :
try :
sm . use_filament ( sid , mm )
log . info ( f " Spoolman: mid-print { mm : .1f } mm to spool { sid } " )
except Exception as e :
log . warning ( f " Spoolman: mid-print sync failed (spool { sid } ): { e } " )
threading . Thread ( target = _report , daemon = True , name = " spoolman-sync " ) . start ( )
# ── Spoolman API handlers ─────────────────────────────────────────────────
async def handle_kx_spoolman_status ( self , request ) :
""" GET /kx/spoolman/status """
return self . _json_cors ( {
" configured " : bool ( self . _spoolman ) ,
" server " : self . _spoolman . server_url if self . _spoolman else " " ,
" sync_rate " : self . _spoolman . sync_rate if self . _spoolman else 0 ,
" slot_spools " : { str ( k ) : v for k , v in self . _spoolman_slot_spools . items ( ) } ,
} )
async def handle_kx_spoolman_spools ( self , request ) :
""" GET /kx/spoolman/spools — proxied from Spoolman. """
if not self . _spoolman :
return self . _json_cors ( { " error " : " Spoolman not configured " } , status = 503 )
try :
spools = await asyncio . get_event_loop ( ) . run_in_executor (
None , self . _spoolman . list_spools
)
return self . _json_cors ( { " spools " : spools } )
except Exception as e :
log . warning ( f " Spoolman: list_spools failed: { e } " )
return self . _json_cors ( { " error " : str ( e ) } , status = 502 )
async def handle_kx_spoolman_set_active ( self , request ) :
""" POST /kx/spoolman/active-spool
Body: { " slot_map " : { " 0 " : 42, " 2 " : 17}} — AMS slot index → Spoolman spool ID.
An empty slot_map clears all assignments. """
try :
data = await request . json ( )
except Exception :
return self . _json_cors ( { " error " : " invalid JSON " } , status = 400 )
slot_map = data . get ( " slot_map " ) or { }
self . _spoolman_slot_spools = {
int ( k ) : int ( v ) for k , v in slot_map . items ( )
if str ( v ) . isdigit ( ) and int ( v ) > 0
}
self . _spoolman_reported_mm = 0.0
return self . _json_cors ( { " slot_spools " : { str ( k ) : v for k , v in self . _spoolman_slot_spools . items ( ) } } )
def _default_ace_dry_presets ( self ) - > dict [ str , dict ] :
return {
" pla " : { " temp " : 45 , " duration_sec " : 4 * 3600 } ,
@@ -914,7 +1071,9 @@ class KobraXBridge:
# Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI).
# _camera_autostarted verhindert Mehrfach-Trigger pro Druck.
if kobra_state == " printing " :
if getattr ( self . _args , " camera_on_print " , 0 ) and not getattr ( self , " _camera_autostarted " , False ) :
if ( getattr ( self . _args , " camera_on_print " , 0 )
and not self . _camera_autostarted
and not self . _camera_user_stopped ) :
self . _camera_autostarted = True
try :
self . client . start_camera ( )
@@ -923,6 +1082,7 @@ class KobraXBridge:
log . warning ( f " Kamera-Autostart fehlgeschlagen: { e } " )
elif kobra_state in ( " free " , " finished " , " stoped " , " canceled " ) :
self . _camera_autostarted = False
self . _camera_user_stopped = False # für nächsten Druck freigeben
# Job-History: Druckstart erkennen
if kobra_state == " printing " and not self . _current_job_id :
@@ -934,16 +1094,20 @@ class KobraXBridge:
gcode_file_id = gf [ " id " ] ,
printer_id = self . _printer_id ,
)
log . info ( f " Job ge startet : { self . _current_job_id } fü r { filename } " )
log . info ( f " Job started : { self . _current_job_id } fo r { filename } " )
self . _spoolman_reported_mm = 0.0
self . _spoolman_last_sync = 0.0
# Job-History: Druckende erkennen
if kobra_state in ( " finished " , ) and self . _current_job_id :
self . _store . finish_job ( self . _current_job_id , status = " completed " )
log . info ( f " Job abgeschlossen: { self . _current_job_id } " )
self . _spoolman_notify_end ( )
self . _current_job_id = " "
elif kobra_state in ( " stoped " , " canceled " ) and self . _current_job_id :
self . _store . finish_job ( self . _current_job_id , status = " cancelled " )
log . info ( f " Job abgebrochen: { self . _current_job_id } " )
self . _spoolman_notify_end ( )
self . _current_job_id = " "
# Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der
@@ -960,6 +1124,7 @@ class KobraXBridge:
self . _state [ " slicer_time " ] = 0
self . _state [ " layer_height " ] = 0.0
self . _state [ " first_layer_height " ] = 0.0
self . _state [ " supplies_usage " ] = 0
self . _thumbnail_b64 = " "
self . _state [ " filename " ] = d . get ( " filename " , self . _state [ " filename " ] )
if " progress " in d :
@@ -974,6 +1139,8 @@ class KobraXBridge:
self . _state [ " total_layers " ] = d [ " total_layers " ]
if " taskid " in d :
self . _state [ " taskid " ] = str ( d [ " taskid " ] )
if " supplies_usage " in d :
self . _state [ " supplies_usage " ] = int ( d [ " supplies_usage " ] )
settings = d . get ( " settings " ) or { }
if " print_speed_mode " in settings :
self . _state [ " print_speed_mode " ] = int ( settings [ " print_speed_mode " ] )
@@ -1001,7 +1168,9 @@ class KobraXBridge:
# Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report).
# _camera_autostarted-Guard verhindert Doppel-Start mit _on_print.
if kobra_state == " printing " :
if getattr ( self . _args , " camera_on_print " , 0 ) and not getattr ( self , " _camera_autostarted " , False ) :
if ( getattr ( self . _args , " camera_on_print " , 0 )
and not self . _camera_autostarted
and not self . _camera_user_stopped ) :
self . _camera_autostarted = True
try :
self . client . start_camera ( )
@@ -1010,6 +1179,7 @@ class KobraXBridge:
log . warning ( f " Kamera-Autostart fehlgeschlagen: { e } " )
elif kobra_state in ( " free " , " finished " , " stoped " , " canceled " ) :
self . _camera_autostarted = False
self . _camera_user_stopped = False # für nächsten Druck freigeben
if project :
if " filename " in project :
self . _state [ " filename " ] = project [ " filename " ]
@@ -1075,7 +1245,7 @@ class KobraXBridge:
if filename :
try :
self . _store . update_file_objects ( filename , objs , svg )
log . info ( f " Skip-O bjekte fü r { filename } : { len ( objs ) } ( { ' m it SVG' if svg else ' ohne SVG ' } ) " )
log . info ( f " Skip o bjects fo r { filename } : { len ( objs ) } ( { ' w ith SVG' if svg else ' n o SVG' } ) " )
except Exception as e :
log . warning ( f " update_file_objects fehlgeschlagen: { e } " )
self . _push_status_update ( )
@@ -1494,12 +1664,30 @@ class KobraXBridge:
self . _push_status_update ( )
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
# Default-Mapping pro Material-Typ wenn der User keinen Slot-Profil-
# Override gesetzt hat. Für den Kobra X bevorzugen wir Anycubic-eigene
# Filament-IDs aus den `@Anycubic Kobra X 0.4 nozzle`-Profilen — die
# sind druckerspezifisch is_compatible und werden von OrcaSlicer direkt
# gematched. Library-Fallbacks (OGF*) nur für Material-Typen ohne
# Kobra-X-spezifisches Anycubic-Profil — deren @System-Profile haben
# `compatible_printers: []` (= mit allen Druckern kompatibel).
_TRAY_INFO_IDX = {
" PLA " : " OGFL99 " , " PLA-CF " : " OGFL98 " , " PLA SILK " : " OGFL96 " ,
" PETG " : " O GFG99 " , " PETG-CF " : " OGFG98 " ,
" ABS " : " O GFB99 " , " ASA " : " OGFB98 " ,
" TPU " : " OGFT99 " , " PA " : " O GFP99 " , " PA-CF " : " OGFP98 " ,
" PC " : " OGFC99 " , " HIPS " : " O GFH99 " , " PVA " : " OGFV99 " ,
# Anycubic-eigene Kobra-X-Profile
" PLA " : " GFPLA " ,
" PLA+ " : " GFPLA+ " ,
" PLA SILK " : " GFPLA Silk " ,
" PETG " : " GFPETG " ,
" ABS " : " GFABS " ,
" ASA " : " GFASA " ,
" TPU " : " GFTPU 95A " ,
" PVA " : " GFPVA " ,
# Kein Anycubic-Kobra-X-Profil → Library-Fallback
" PLA-CF " : " OGFL98 " ,
" PETG-CF " : " OGFG98 " ,
" PA " : " OGFN99 " ,
" PA-CF " : " OGFN98 " ,
" PC " : " OGFC99 " ,
" HIPS " : " OGFS98 " ,
}
def _build_lane_data ( self ) - > dict :
@@ -1556,9 +1744,13 @@ class KobraXBridge:
fila_name = user_profile . get ( " name " , " " )
tray_info_idx = user_profile . get ( " id " ) or self . _TRAY_INFO_IDX . get ( material , " OGFL99 " )
else :
vendor = " "
fila_name = " "
tray_info_idx = self . _TRAY_INFO_IDX . get ( material , " OGFL99 " )
# Default: Library-Generic-Profil (siehe _default_filament_name) —
# ist mit allen Druckern kompatibel und garantiert sichtbar.
# Der User wählt pro Slot bewusst eine konkrete Marke wenn er
# eine will; Default bleibt neutral.
fila_name = self . _default_filament_name ( material )
vendor = " Generic " if fila_name . startswith ( " Generic " ) else " "
tray_info_idx = self . _lookup_filament_id ( vendor , fila_name ) or self . _TRAY_INFO_IDX . get ( material , " OGFL99 " )
tray_array . append ( {
" id " : str ( slot_id ) ,
" tag_uid " : " 0000000000000000 " ,
@@ -1566,17 +1758,20 @@ class KobraXBridge:
" tray_type " : material ,
" tray_color " : color_hex ,
" tray_sub_brands " : vendor ,
# OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
# `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
# filament_id_by_type). Wir senden beide Schreibweisen mit
# damit ältere Patch-Varianten + zukünftige Upstream-PRs beide
# bedient sind.
" name " : fila_name ,
" vendor_name " : vendor ,
# Aliase für ältere Patch-Varianten (Variante 2,
# MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
# sonst preset-Name per find_preset() auflösen.
" filament_id " : tray_info_idx ,
" filament_vendor " : vendor ,
# Für den OrcaSlicer-Empfangs-Patch (Variante 2 ,
# MoonrakerPrinterAgent.cpp): `filament_id` direkt
# übernehmen (exakt), sonst `preset`-Name per
# find_preset() auflösen. tray_info_idx ist im Orca-
# Datenmodell nicht eindeutig (z.B. OGFL99 für 136
# Profile), aber der Bare-Name aus orca_filaments.json
# ist eindeutig — find_preset() parsed @-Suffixe weg.
" filament_id " : tray_info_idx ,
" preset " : fila_name ,
" filament_name " : fila_name , # ältere Aliase
" filament_name " : fila_name ,
" preset " : fila_name ,
} )
else :
tray_array . append ( {
@@ -1695,6 +1890,7 @@ class KobraXBridge:
" 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 = [ ]
for _global_index , slot in slots :
occupied = slot . get ( " status " ) == 5
gate_status . append ( 1 if occupied else 0 )
@@ -1706,6 +1902,17 @@ class KobraXBridge:
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 aus User-Override oder Material-Default für den
# HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den
# HH-Pfad wählt (MMU-Erkennung), wertet PR #13719 dieses Feld als
# Preset-Namen aus → 'Anycubic PLA' matched das druckerspezifische
# Preset, leerer String führte vorher auf Generic PLA.
if occupied :
user_profile = self . _filament_profiles . get ( _global_index ) or { }
fila_name = user_profile . get ( " name " ) or self . _default_filament_name ( material )
gate_filament_name . append ( fila_name )
else :
gate_filament_name . append ( " " )
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 )
@@ -1717,13 +1924,36 @@ class KobraXBridge:
" gate_color " : gate_color ,
" gate_temperature " : gate_temperature ,
" gate_color_rgb " : gate_color_rgb ,
" gate_filament_name " : [ " " ] * num_gates ,
" gate_filament_name " : gate_filament_name ,
" gate_spool_id " : [ - 1 ] * num_gates ,
" ttg_map " : list ( range ( num_gates ) ) ,
" tool " : active_gate ,
" gate " : active_gate ,
}
def _default_filament_name ( self , material : str ) - > str :
""" Default-Name für `gate_filament_name`/`name` in lane_data wenn kein
User-Override gesetzt ist. Bewusste Designentscheidung: **immer
Generic <Typ>** als Default — das Library-Profil ist `compatible_printers:[]`
(= mit jedem Drucker kompatibel) und damit garantiert sichtbar.
OrcaSlicer matcht dann das neutrale Generic-Preset und der User
kann pro Slot eine konkrete Marke setzen wenn er das will. """
if not material :
return " "
mat = material . upper ( ) . strip ( )
profs = self . _load_orca_filaments ( )
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 " , " " )
# Falls die Library-Generic für diesen exotischen Material-Typ fehlt,
# liefern wir nichts — OrcaSlicer fällt auf filament_id_by_type zurück.
return " "
def _build_printer_objects ( self ) - > dict :
s = self . _state
return {
@@ -1824,6 +2054,43 @@ class KobraXBridge:
" gcode_macro TIMELAPSE_TAKE_FRAME " : {
" is_paused " : False ,
} ,
# configfile stub — Mobileraker und andere Clients crashen ohne
# dieses Objekt (Missing field: configFile). Werte aus der
# entschlüsselten avata_main.conf (ACCFG1.0 — Kobra X Firmware).
" configfile " : {
" config " : { } ,
" 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 ,
} ,
" heater_bed " : {
" min_temp " : 0 ,
" max_temp " : 120 ,
} ,
" stepper_x " : { " position_min " : - 18.5 , " position_max " : 280 } ,
" stepper_y " : { " position_min " : - 6.5 , " position_max " : 272.5 } ,
" stepper_z " : { " position_min " : - 4 , " position_max " : 262 } ,
" virtual_sdcard " : { " path " : " /data/gcodes " } ,
" pause_resume " : { } ,
" display_status " : { } ,
} ,
" warnings " : [ ] ,
" save_config_pending " : False ,
" save_config_pending_items " : { } ,
} ,
}
# -------------------------------------------------------------------------
@@ -1946,6 +2213,128 @@ class KobraXBridge:
profiles = [ p for p in profiles if p . get ( " vendor " , " " ) == vendor_filter ]
return self . _json_cors ( { " result " : profiles } )
async def handle_kx_filament_profiles_user_list ( self , request ) :
""" GET /kx/filament/profiles/user — nur die User-importierten Profile,
für den Settings-Tab (Verwaltung mit Lösch-Buttons). """
path = self . _orca_filaments_user_path ( )
if not os . path . isfile ( path ) :
return self . _json_cors ( { " result " : [ ] } )
try :
with open ( path , encoding = " utf-8 " ) as f :
user_profiles = json . load ( f ) or [ ]
except Exception :
user_profiles = [ ]
return self . _json_cors ( { " result " : user_profiles } )
async def handle_kx_filament_profiles_import ( self , request ) :
""" POST /kx/filament/profiles/user — multipart-Upload mit einer
ZIP-Datei oder mehreren `.json`-Files aus
~/.config/OrcaSlicer/user/<id>/filament/.
Bestehende User-Profile mit gleichem (vendor, name)-Key werden
überschrieben. Geparste Profile haben dasselbe Schema wie
orca_filaments.json (id, name, vendor, type, color). """
import io , zipfile
from orca_filaments import parse_profile_bytes
added : list [ dict ] = [ ]
skipped : int = 0
# System-Index für Inherits-Resolve: User-Profile referenzieren
# System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit
# können wir filament_id/vendor/type/color aus dem System-Parent
# ziehen wenn das User-Profil sie selbst nicht setzt.
sys_idx = [ p for p in self . _load_orca_filaments ( ) if not p . get ( " is_user " ) ]
try :
reader = await request . multipart ( )
except Exception :
return self . _json_cors ( { " error " : " expected multipart " } , status = 400 )
async for part in reader :
if part . name not in ( " file " , " files " , " upload " ) :
continue
blob = await part . read ( )
fn = ( part . filename or " " ) . lower ( )
if fn . endswith ( " .zip " ) :
try :
with zipfile . ZipFile ( io . BytesIO ( blob ) ) as zf :
for inner in zf . namelist ( ) :
if not inner . lower ( ) . endswith ( " .json " ) :
continue
try :
with zf . open ( inner ) as zf_in :
p = parse_profile_bytes ( zf_in . read ( ) , source_name = inner , system_index = sys_idx )
except Exception :
skipped + = 1
continue
if p :
added . append ( p )
else :
skipped + = 1
except zipfile . BadZipFile :
return self . _json_cors ( { " error " : " bad zip " } , status = 400 )
elif fn . endswith ( " .json " ) :
p = parse_profile_bytes ( blob , source_name = fn , system_index = sys_idx )
if p :
added . append ( p )
else :
skipped + = 1
if not added :
return self . _json_cors ( { " result " : " ok " , " added " : 0 , " skipped " : skipped } )
# Merge mit existierender User-JSON (gleicher (vendor,name) → ersetzen)
path = self . _orca_filaments_user_path ( )
existing : list [ dict ] = [ ]
if os . path . isfile ( path ) :
try :
with open ( path , encoding = " utf-8 " ) as f :
existing = json . load ( f ) or [ ]
except Exception :
existing = [ ]
by_key = { ( p . get ( " vendor " ) , p . get ( " name " ) ) : p for p in existing }
for p in added :
by_key [ ( p . get ( " vendor " ) , p . get ( " name " ) ) ] = p
merged = sorted ( by_key . values ( ) , key = lambda x : ( x . get ( " vendor " , " " ) , x . get ( " name " , " " ) ) )
try :
with open ( path , " w " , encoding = " utf-8 " ) as f :
json . dump ( merged , f , indent = 2 , ensure_ascii = False )
f . write ( " \n " )
except Exception as e :
return self . _json_cors ( { " error " : f " write failed: { e } " } , status = 500 )
self . _invalidate_filaments_cache ( )
return self . _json_cors ( { " result " : " ok " ,
" added " : len ( added ) ,
" skipped " : skipped ,
" total_user " : len ( merged ) } )
async def handle_kx_filament_profiles_user_delete ( self , request ) :
""" DELETE /kx/filament/profiles/user — löscht entweder einen einzelnen
Eintrag (?vendor=…&name=…) oder alle wenn keine Query angegeben. """
vendor = request . rel_url . query . get ( " vendor " , " " ) . strip ( )
name = request . rel_url . query . get ( " name " , " " ) . strip ( )
path = self . _orca_filaments_user_path ( )
if not os . path . isfile ( path ) :
return self . _json_cors ( { " result " : " ok " , " removed " : 0 } )
try :
with open ( path , encoding = " utf-8 " ) as f :
existing = json . load ( f ) or [ ]
except Exception :
existing = [ ]
before = len ( existing )
if vendor and name :
existing = [ p for p in existing
if not ( p . get ( " vendor " ) == vendor and p . get ( " name " ) == name ) ]
else :
existing = [ ]
try :
with open ( path , " w " , encoding = " utf-8 " ) as f :
json . dump ( existing , f , indent = 2 , ensure_ascii = False )
f . write ( " \n " )
except Exception as e :
return self . _json_cors ( { " error " : str ( e ) } , status = 500 )
self . _invalidate_filaments_cache ( )
return self . _json_cors ( { " result " : " ok " ,
" removed " : before - len ( existing ) ,
" total_user " : len ( existing ) } )
def _find_orca_filaments_json ( self ) - > str | None :
""" Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/
— in allen 3 Deployment-Modi:
@@ -2022,21 +2411,45 @@ class KobraXBridge:
" id " : entry . get ( " id " , " " ) } )
def _load_orca_filaments ( self ) - > list [ dict ] :
""" Lädt orca_filaments.json einmalig in den Cache. Bei wiederholt en
Aufrufen wird die Liste aus dem RAM geliefert. """
""" Lädt System- + User-Profile aus dem Cache. System-Profile komm en
aus bridge/data/orca_filaments.json (Image-embedded), User-Profile
aus <KX_DATA_DIR>/orca_filaments.user.json (Volume-persistent —
überlebt Image-Updates). User-Profile bekommen ein `is_user: True`-
Flag damit das Frontend sie markieren kann. """
if getattr ( self , " _orca_filaments_cache " , None ) is not None :
return self . _orca_filaments_cache
self . _orca_filaments_cache = [ ]
data_path = self . _find_orca_filaments_json ( )
if not data_path or not os . path . isfile ( data_path ) :
return self . _orca_filaments_cache
try :
with open ( data _path, encoding = " utf-8 " ) as f :
self . _orca_filaments_cache = json . load ( f ) or [ ]
except Exception as e :
log . warning ( f " orca_filaments.json read error: { e } " )
merged : list [ dict ] = [ ]
# System
sys_path = self . _find_orca_filaments_json ( )
if sys_path and os . path . isfile ( sys_path ) :
try :
with open ( sys _path, encoding = " utf-8 " ) as f :
merged . extend ( json . load ( f ) or [ ] )
except Exception as e :
log . warning ( f " orca_filaments.json read error: { e } " )
# User
usr_path = self . _orca_filaments_user_path ( )
if usr_path and os . path . isfile ( usr_path ) :
try :
with open ( usr_path , encoding = " utf-8 " ) as f :
for p in ( json . load ( f ) or [ ] ) :
p [ " is_user " ] = True
merged . append ( p )
except Exception as e :
log . warning ( f " orca_filaments.user.json read error: { e } " )
self . _orca_filaments_cache = merged
return self . _orca_filaments_cache
def _orca_filaments_user_path ( self ) - > str :
""" Pfad zur User-Profile-JSON. Liegt im Volume-Mount (KX_DATA_DIR),
damit Image-Updates die Daten nicht zerstören. """
data_dir = os . environ . get ( " KX_DATA_DIR " ) or os . path . join ( _WEB_BASE , " data " )
os . makedirs ( data_dir , exist_ok = True )
return os . path . join ( data_dir , " orca_filaments.user.json " )
def _invalidate_filaments_cache ( self ) :
self . _orca_filaments_cache = None
def _lookup_filament_id ( self , vendor : str , name : str ) - > str :
""" Sucht in orca_filaments.json die filament_id zu einem (vendor,name)-
Tupel. Liefert ' ' wenn nicht gefunden. """
@@ -2449,7 +2862,20 @@ class KobraXBridge:
return web . json_response ( { " result " : { " count " : len ( result_jobs ) , " jobs " : result_jobs } } )
async def handle_webcams_list ( self , request ) :
""" Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier. """
""" Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier.
Wenn der Client von einem anderen Host kommt (z.B. moonraker-obico auf
separatem Server), braucht er absolute URLs damit er den Stream erreicht.
Host-Header mit localhost/127.0.0.1 wird durch die echte LAN-IP ersetzt. """
host_hdr = request . headers . get ( " Host " , " " ) if request else " "
host_name = ( host_hdr or " " ) . split ( " : " ) [ 0 ]
port_part = f " : { host_hdr . split ( ' : ' ) [ 1 ] } " if " : " in ( host_hdr or " " ) else f " : { self . _args . port } "
local_ip = getattr ( self , " _local_ip " , None ) or host_name
if host_name in ( " localhost " , " 127.0.0.1 " , " " ) :
host_name = local_ip
base = f " http:// { host_name } { port_part } "
stream_url = f " { base } /api/camera/stream "
snapshot_url = f " { base } /api/camera/snapshot "
return web . json_response ( {
" result " : {
" webcams " : [
@@ -2461,8 +2887,8 @@ class KobraXBridge:
" icon " : " mdiWebcam " ,
" target_fps " : 5 ,
" target_fps_idle " : 2 ,
" stream_url " : " /api/camera/ stream" ,
" snapshot_url " : " /api/camera/ snapshot" ,
" stream_url " : stream_url ,
" snapshot_url " : snapshot_url ,
" flip_horizontal " : False ,
" flip_vertical " : False ,
" rotation " : 0 ,
@@ -2647,7 +3073,7 @@ class KobraXBridge:
log . info ( f " print/start → { filename } url= { url } ams= { len ( ams_box_mapping ) } slots mode= { self . _filament_mode } " )
result = self . client . publish ( " print " , " start " , payload , timeout = 15.0 )
if result :
log . info ( f " Druckstart bestätigt : state={ result . get ( ' state ' ) } " )
log . info ( f " Print start confirmed : state={ result . get ( ' state ' ) } " )
else :
log . warning ( " Druckstart: keine Antwort vom Drucker " )
@@ -2902,7 +3328,7 @@ class KobraXBridge:
return web . json_response ( { " result " : " disconnected " } )
async def handle_api_restart ( self , request ) :
log . info ( " Neu start über API angefordert " )
log . info ( " Re start requested via API " )
response = web . json_response ( { " status " : " restarting " } )
asyncio . get_event_loop ( ) . call_later ( 0.3 , self . _restart_bridge )
return response
@@ -3182,6 +3608,9 @@ class KobraXBridge:
await loop . run_in_executor ( None , lambda : self . client . publish (
" video " , " stopCapture " , None , timeout = 0
) )
# Verhindert dass der Autostart-Guard die Kamera während des
# laufenden Drucks wieder einschaltet (State-Flicker-Problem).
self . _camera_user_stopped = True
return web . json_response ( { " result " : " ok " } )
async def handle_api_camera_snapshot ( self , request ) :
@@ -3241,7 +3670,7 @@ class KobraXBridge:
stderr = asyncio . subprocess . DEVNULL ,
)
except ( FileNotFoundError , OSError ) as e :
log . warning ( " K amera: ffmpeg nicht gef unden – K amerastream nicht verfügbar " )
log . warning ( " C amera: ffmpeg not fo und – c amera stream unavailable " )
return web . Response ( status = 503 , text = " ffmpeg not found " )
except Exception as e :
log . warning ( f " Kamera: ffmpeg konnte nicht gestartet werden: { e } " )
@@ -3336,7 +3765,7 @@ class KobraXBridge:
if not os . path . isfile ( serve_path ) :
return web . Response ( status = 404 , text = " not found " )
size = os . path . getsize ( serve_path )
log . info ( f " Drucker lädt Datei ab : { filename } ( { size } bytes) " )
log . info ( f " Printer downloading file : { filename } ( { size } bytes) " )
return web . FileResponse ( serve_path , headers = {
" Content-Disposition " : f ' attachment; filename= " { filename } " '
} )
@@ -3374,6 +3803,7 @@ class KobraXBridge:
" remain_time " : s [ " remain_time " ] ,
" curr_layer " : s [ " curr_layer " ] ,
" total_layers " : s [ " total_layers " ] ,
" z_mm " : self . _estimate_current_z ( ) ,
" filename " : s [ " filename " ] ,
" slicer_time " : slicer_time ,
" camera_url " : s [ " camera_url " ] ,
@@ -3651,7 +4081,7 @@ class KobraXBridge:
with open ( config_path , " w " , encoding = " utf-8 " ) as f :
f . write ( " # KX-Bridge Konfigurationsdatei \n \n " )
cfg . write ( f )
log . info ( f " Druck er ' { name or creds [ ' model ' ] } ' al s { sec } hinzugefügt (P ort { new_port } ) " )
log . info ( f " Print er ' { name or creds [ ' model ' ] } ' added a s { sec } (p ort { new_port } ) " )
response = self . _json_cors ( { " status " : " restarting " , " section " : sec , " http_port " : new_port } )
asyncio . get_event_loop ( ) . call_later ( 0.5 , self . _restart_bridge )
return response
@@ -3726,13 +4156,13 @@ class KobraXBridge:
# die alten Werte statt der geänderten config.ini.
for _k in ( " PRINTER_IP " , " MQTT_PORT " , " MQTT_USERNAME " , " MQTT_PASSWORD " ,
" MODE_ID " , " DEVICE_ID " , " DEFAULT_AMS_SLOT " , " AUTO_LEVELING " ,
" BRIDGE_PRINTER_NAME " ) :
" CAMERA_ON_PRINT " , " WEB_UPLOAD_WARNING " , " BRIDGE_PRINTER_NAME" ) :
os . environ . pop ( _k , None )
in_docker = os . path . exists ( " /.dockerenv " ) or os . environ . get ( " KX_IN_DOCKER " )
if in_docker :
# Docker/systemd: Prozess beenden reicht – der Supervisor startet neu (frische environ)
log . info ( " Container-Umgebung erkannt – beende Prozess fü r S upervisor-R estart " )
log . info ( " Container environment detected – exiting fo r s upervisor r estart " )
os . _exit ( 0 )
frozen = getattr ( sys , " frozen " , False )
@@ -3767,7 +4197,9 @@ class KobraXBridge:
GITEA_RAW_BASE = " https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag "
def _read_version ( self ) - > str :
for base in ( pathlib . Path ( _BASE ) , pathlib . Path ( _BASE ) . parent ) :
# PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach
# sys._MEIPASS — daher _WEB_BASE statt _BASE benutzen.
for base in ( pathlib . Path ( _WEB_BASE ) , pathlib . Path ( _BASE ) , pathlib . Path ( _BASE ) . parent ) :
p = base / " VERSION "
if p . is_file ( ) :
return p . read_text ( encoding = " utf-8 " ) . strip ( )
@@ -3909,7 +4341,7 @@ class KobraXBridge:
if fname == " kobrax_moonraker_bridge.py " :
return web . json_response (
{ " error " : f " Download { fname } : HTTP { resp . status } " } , status = 502 )
log . warning ( f " Update: { fname } nicht im R elease ( { resp . status } ) – übersprungen " )
log . warning ( f " Update: { fname } not found in r elease ( { resp . status } ) – skipped " )
continue
downloaded . append ( ( app_dir / fname , await resp . read ( ) ) )
# Phase 2: atomar ersetzen (erst nach komplettem, erfolgreichem Download)
@@ -4186,11 +4618,14 @@ class KobraXBridge:
# Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
result = " ok "
elif method == " server.webcams.list " :
# WS-Variante des HTTP-Endpoints
# WS-Variante: absolute URL mit echter LAN-IP statt 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 " : " /api/camera/stream " ,
" snapshot _url " : " /api/camera/snapshot " ,
" 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 " ,
} ] }
@@ -4240,7 +4675,7 @@ class KobraXBridge:
log . debug ( f " Unbekannte RPC-Methode: { method } " )
result = { }
except Exception as e :
log . error ( f " RPC-Fehle r fü r { method } : { e } " )
log . error ( f " RPC erro r fo r { method } : { e } " )
error = { " code " : - 32603 , " message " : str ( e ) }
if rpc_id is not None :
@@ -4303,6 +4738,14 @@ class KobraXBridge:
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 { }
@@ -4439,12 +4882,21 @@ def build_app(bridge: KobraXBridge) -> web.Application:
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 )
# Custom-Profile-Import (Issue #41) — User lädt eigene Orca-Filament-
# Profile als ZIP/JSON hoch (z.B. aus ~/.config/OrcaSlicer/user/<id>/filament/),
# weil die Bridge typischerweise nicht auf demselben Host wie OrcaSlicer läuft.
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)
@@ -4548,7 +5000,7 @@ async def run_bridge(args):
site = web . TCPSite ( runner , args . host , per_args . port )
await site . start ( )
runners . append ( ( runner , client , pid ) )
log . info ( f " [Druck er { pid } ] Bridge läuft auf http:// { args . host } : { per_args . port } " )
log . info ( f " [Print er { pid } ] Bridge running on http:// { args . host } : { per_args . port } " )
import socket as _socket
try :
@@ -4557,6 +5009,9 @@ async def run_bridge(args):
_local_ip = _s . getsockname ( ) [ 0 ]
except Exception :
_local_ip = args . host
# An alle Bridge-Instanzen weitergeben — wird für absolute Webcam-URLs genutzt
for _b in all_bridges . values ( ) :
_b . _local_ip = _local_ip
log . info ( f " OrcaSlicer → Klipper → Host: { _local_ip } Ports: " +
" , " . join ( str ( getattr ( b . _args , ' port ' , 0 ) ) for b in all_bridges . values ( ) ) )
log . info ( " Ctrl-C zum Beenden " )
@@ -4605,6 +5060,10 @@ def main():
parser . add_argument ( " --auto-leveling " , type = int , default = env_loader . AUTO_LEVELING )
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 ( " --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 ( " --host " , default = " 0.0.0.0 " ,
help = " Bind-Adresse für den Bridge-Server " )