@@ -108,11 +108,17 @@ KLIPPER_VERSION = "v0.12.0-1"
def _parse_gcode_estimated_time ( data : bytes ) - > int :
""" Liest ' ; estimated printing time (normal mode) = Xh Ym Zs ' aus GCode-Head er.
Gibt Sekunden zurück, 0 wenn nicht gefunden. Sucht nur in den ersten 8KB. """
""" Liest geschätzte Druckzeit aus GCode (OrcaSlicer + PrusaSlic er) .
Gibt Sekunden zurück, 0 wenn nicht gefunden.
PrusaSlicer schreibt die Zeit ins Header (erste 16KB),
OrcaSlicer schreibt sie ans Ende der Datei (letzte 16KB). """
import re
header = data [ : 8192 ] . decode ( " utf-8 " , errors = " ignore " )
m = re . search ( r " ; \ s*estimated printing time \ (normal mode \ ) \ s*= \ s*(.*) " , header )
# Anfang + Ende der Datei durchsuchen (OrcaSlicer schreibt Zeit am Ende )
search_text = ( data [ : 16384 ] + data [ - 65536 : ] ) . decode ( " utf-8 " , errors = " ignore " )
# OrcaSlicer: ; total estimated time: 9m 20s
# PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s
m = ( re . search ( r " ; \ s*total estimated time: \ s*(.*) " , search_text ) or
re . search ( r " ; \ s*estimated printing time \ (normal mode \ ) \ s*= \ s*(.*) " , search_text ) )
if not m :
return 0
parts = re . findall ( r " ( \ d+) \ s*([hms]) " , m . group ( 1 ) )
@@ -121,6 +127,8 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
if unit == " h " : secs + = int ( val ) * 3600
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 ( ) } ) " )
return secs
@@ -154,6 +162,7 @@ class KobraXBridge:
" taskid " : " -1 " ,
" print_speed_mode " : 2 ,
" connection_error " : " " ,
" file_ready " : " " ,
}
self . _ams_slots : list [ dict ] = [ ]
self . _ams_loaded_slot : int = - 1
@@ -191,6 +200,11 @@ class KobraXBridge:
if kobra_state in ( " stoped " , " canceled " ) :
self . _state [ " progress " ] = 0.0
self . _state [ " filename " ] = " "
self . _state [ " file_ready " ] = " "
self . _state [ " print_duration " ] = 0
self . _state [ " remain_time " ] = 0
self . _state [ " slicer_time " ] = 0
self . _thumbnail_b64 = " "
self . _state [ " filename " ] = d . get ( " filename " , self . _state [ " filename " ] )
if " progress " in d :
self . _state [ " progress " ] = float ( d [ " progress " ] ) / 100.0
@@ -503,6 +517,7 @@ class KobraXBridge:
ct = request . headers . get ( " Content-Type " , " " )
if " multipart " not in ct :
return web . json_response ( { " error " : " expected multipart " } , status = 400 )
auto_print = False
reader = await request . multipart ( )
file_data = None
remote_filename = self . _last_uploaded_file or " upload.gcode "
@@ -516,14 +531,17 @@ class KobraXBridge:
val = ( await part . read ( ) ) . decode ( " utf-8 " , errors = " replace " ) . strip ( )
if val :
remote_filename = val
elif part . name == " print " :
val = ( await part . read ( ) ) . decode ( " utf-8 " , errors = " replace " ) . strip ( ) . lower ( )
auto_print = val == " true "
else :
log . debug ( f " Unbekanntes Multipart-Feld: { part . name } " )
if not file_data :
return web . json_response ( { " error " : " no file received " } , status = 400 )
file_md5 = hashlib . md5 ( file_data ) . hexdigest ( )
file_size = len ( file_data )
file_md5 = hashlib . md5 ( file_data ) . hexdigest ( )
file_size = len ( file_data )
# Slicer-Zeitschätzung aus GCode-Header auslesen
self . _state [ " slicer_time " ] = _parse_gcode_estimated_time ( file_data )
@@ -553,9 +571,24 @@ class KobraXBridge:
# Druck starten mit vollständigem Payload (inkl. serve-URL + md5 + size)
serve_url = f " http:// { request . host } /serve/ { remote_filename } "
log . info ( f " Starte Druck automatisch: { remote_filename } " )
loop = asyncio . get_event_loop ( )
loop . run_in_executor ( None , lambda : self . _start_print ( remote_filename , serve_url , file_md5 , file_size ) )
# print=true im Multipart-Formular (Moonraker) oder Query-String → Druck starten
# print=false oder fehlt → nur hochladen
if not auto_print :
auto_print = request . rel_url . query . get ( " print " , " false " ) . lower ( ) == " true "
# Thumbnail immer anfordern (Drucker antwortet async mit file/report)
self . _thumbnail_b64 = " "
self . client . publish ( " file " , " fileDetails " , { " root " : " local " , " filename " : remote_filename } , timeout = 0 )
if auto_print :
log . info ( f " Upload+Print (print=true): { remote_filename } " )
self . _state [ " file_ready " ] = " "
loop = asyncio . get_event_loop ( )
loop . run_in_executor ( None , lambda : self . _start_print ( remote_filename , serve_url , file_md5 , file_size ) )
else :
log . info ( f " Nur hochgeladen (print=false): { remote_filename } " )
self . _state [ " file_ready " ] = remote_filename
# OctoPrint-kompatibler Response (OrcaSlicer wertet refs aus)
return web . json_response ( {
@@ -578,6 +611,7 @@ class KobraXBridge:
} , status = 201 )
def _start_print ( self , filename : str , url : str = " " , md5 : str = " " , filesize : int = 0 ) :
self . _state [ " file_ready " ] = " "
default_slot = getattr ( self . _args , " default_ams_slot " , " auto " )
all_loaded = [ ( i , s ) for i , s in enumerate ( self . _ams_slots ) if s . get ( " status " ) == 5 ]
if default_slot != " auto " :
@@ -628,11 +662,6 @@ class KobraXBridge:
" model_objects_skip_parts " : [ ] ,
} ,
}
# Thumbnail vorab anfordern (Drucker antwortet async auf file/report)
self . _thumbnail_b64 = " "
self . client . publish ( " file " , " fileDetails " ,
{ " root " : " local " , " filename " : filename } , timeout = 0 )
log . info ( f " print/start → { filename } url= { url } ams= { len ( self . _ams_slots ) } slots " )
result = self . client . publish ( " print " , " start " , payload , timeout = 15.0 )
if result :
@@ -645,7 +674,9 @@ class KobraXBridge:
body = await request . json ( )
except Exception :
body = { }
filename = bod y. get ( " filename " ) or self . _last_uploaded_file
filename = ( request . rel_url . quer y. get ( " filename " )
or body . get ( " filename " )
or self . _last_uploaded_file )
if not filename :
return web . json_response ( { " error " : " no filename " } , status = 400 )
@@ -718,6 +749,12 @@ class KobraXBridge:
await loop . run_in_executor ( None , lambda : self . client . stop_print ( taskid ) )
return web . json_response ( { " result " : " ok " } )
async def handle_api_file_ready_clear ( self , request ) :
self . _state [ " file_ready " ] = " "
self . _thumbnail_b64 = " "
self . _push_status_update ( )
return web . json_response ( { " result " : " ok " } )
async def handle_octoprint_version ( self , request ) :
return web . json_response ( {
" api " : " 0.1 " ,
@@ -845,6 +882,11 @@ main{flex:1;overflow-y:auto;padding:20px}
.spd-bar { height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
.spd-bar-fill { height:100 % ;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
/* ── TIME CARDS ── */
.time-grid { display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
.time-block { background:var(--raised);border-radius:10px;padding:10px 12px}
.time-label { font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px}
.time-val { font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)}
/* ── TEMPS ── */
.temp-pair { display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner { display:grid;grid-template-columns:1fr 1fr;gap:12px}
@@ -1010,6 +1052,13 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<body>
<div id= " conn-error-banner " style= " display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999; " ></div>
<div id= " file-ready-banner " style= " display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px " >
<span>📄 <span id= " file-ready-name " ></span></span>
<button id= " file-ready-btn " onclick= " startReadyFile() "
style= " padding:5px 16px;background:#fff;color:#1a6e3c;border:none;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px " ></button>
<button id= " file-cancel-btn " onclick= " cancelReadyFile() "
style= " padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px " ></button>
</div>
<header>
<div class= " logo " >⬡ KX-Bridge</div>
@@ -1171,14 +1220,26 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class= " card-title " ><span>◉</span> <span id= " d-card-progress " >Fortschritt</span></div>
<img id= " d-thumbnail " src= " " alt= " " style= " display:none;width:100 % ;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px " >
<div class= " pct-big " ><span id= " d-pct " >0</span><small> % </small></div>
<div class= " progress-bar " style= " margin:8px 0 " ><div class= " progress-fill " id= " d-pbar " style= " width:0 % " ></div></div >
<div class= " meta-row " style= " margin-top:6px " >
<span id= " d-elapsed " >– </span >
<span id= " d-remain " style = " color:var(--acc) " >– </span >
<span id= " d-layers " class = " layer-badge " >– </span >
<div style= " display:flex;align-items:center;gap:10px;margin:8px 0 " >
<div class= " progress-bar " style= " flex:1;margin:0 " ><div class= " progress-fill " id= " d-pbar " style= " width:0 % " ></div></div >
<div class= " time-block " style= " padding:6px 10px;min-width:72px;text-align:center;flex-shrink:0 " >
<div class= " time-label " id = " d-lbl-layers " ></div >
<div class= " time-val " style= " font-size:16px " id = " d- layers " >– </div >
</div>
</div>
<div class= " meta-row " style= " margin-top:4px;font-size:0.82em;opacity:0.7 " id= " d-slicer-row " >
<span id= " d-slicer-label " ></span><span id= " d-slicer-time " style= " margin-left:4px " >– </span >
<div class= " time-grid " >
<div class= " time-block " >
<div class= " time-label " id= " d-lbl-elapsed " ></div>
<div class= " time-val " id= " d-elapsed " >– </div>
</div>
<div class= " time-block " id= " d-slicer-row " style= " display:none " >
<div class= " time-label " id= " d-slicer-label " ></div>
<div class= " time-val " id= " d-slicer-time " >– </div>
</div>
<div class= " time-block " style= " color:var(--acc) " >
<div class= " time-label " id= " d-lbl-remain " ></div>
<div class= " time-val " id= " d-remain " style= " color:var(--acc) " >– </div>
</div>
</div>
<div class= " fname " id= " d-fname " title= " " style= " margin-top:6px " >– </div>
<div class= " ctrl-btns " id= " d-ctrl-btns " style= " margin-top:12px " >
@@ -1391,7 +1452,7 @@ var LANG_DE={
header_status_standby: ' Bereit ' ,header_status_printing: ' Druckt ' ,header_status_complete: ' Fertig ' ,header_status_error: ' Fehler ' ,
kobra_free: ' Bereit ' ,kobra_busy: ' Beschäftigt ' ,kobra_printing: ' Druckt ' ,kobra_preheating: ' Aufheizen ' ,kobra_auto_leveling: ' Nivellierung ' ,kobra_checking: ' Prüfung ' ,kobra_updated: ' Aktualisierung ' ,kobra_init: ' Initialisierung ' ,kobra_pausing: ' Pausiert... ' ,kobra_paused: ' Pausiert ' ,kobra_resuming: ' Fortsetzen... ' ,kobra_resumed: ' Fortgesetzt ' ,kobra_stopping: ' Stoppt... ' ,kobra_stoped: ' Gestoppt ' ,kobra_finished: ' Abgeschlossen ' ,kobra_failed: ' Fehler ' ,kobra_canceled: ' Abgebrochen ' ,kobra_offline: ' Offline ' ,
nav_dashboard: ' Dashboard ' ,nav_print: ' Druck ' ,nav_temps: ' Temperaturen ' ,nav_motion: ' Achsen ' ,nav_ams: ' AMS ' ,nav_extras: ' Licht / Lüfter ' ,nav_console: ' Konsole ' ,
card_progress: ' Fortschritt ' ,card_temps: ' Temperaturen ' ,card_light_fan: ' Lüfter ' ,card_speed: ' Druckgeschwindigkeit ' ,card_cam: ' Kamera ' ,lbl_elapsed: ' Verstrichen ' ,lbl_remaining: ' verbleibend ' ,lbl_slicer_time: ' Slicer-Schätzung: ' ,
card_progress: ' Fortschritt ' ,card_temps: ' Temperaturen ' ,card_light_fan: ' Lüfter ' ,card_speed: ' Druckgeschwindigkeit ' ,card_cam: ' Kamera ' ,lbl_elapsed: ' Verstrichen: ' ,lbl_remaining: ' Restzeit: ' ,lbl_slicer_time: ' Slicer-Schätzung: ' ,lbl_layers: ' Layer ' ,
speed_silent: ' 🐢 Leise ' ,speed_normal: ' ⚡ Normal ' ,speed_sport: ' 🚀 Sport ' ,
lbl_light: ' 💡 Licht ' ,lbl_feed: ' Einziehen ' ,lbl_unload: ' Ausziehen ' ,
cam_placeholder: ' 📷 Kamera nicht gestartet ' ,btn_cam_start: ' ▶ Kamera ' ,btn_cam_stop: ' ◼ Kamera ' ,
@@ -1417,13 +1478,15 @@ var LANG_DE={
slot_edit_title: ' Slot bearbeiten ' ,slot_edit_color: ' Farbe ' ,slot_edit_material: ' Material ' ,
slot_edit_save: ' 💾 Speichern ' ,slot_edit_custom: ' z.B. PLA, PETG, ABS… ' ,
slot_edit_ok: ' AMS Slot ' ,
log_dir_all: ' Alle '
log_dir_all: ' Alle ' ,
file_ready_btn: ' ▶ Druck starten ' ,
file_cancel_btn: ' ✕ Abbrechen '
};
var LANG_EN= {
header_status_standby: ' Ready ' ,header_status_printing: ' Printing ' ,header_status_complete: ' Complete ' ,header_status_error: ' Error ' ,
kobra_free: ' Ready ' ,kobra_busy: ' Busy ' ,kobra_printing: ' Printing ' ,kobra_preheating: ' Preheating ' ,kobra_auto_leveling: ' Auto Leveling ' ,kobra_checking: ' Checking ' ,kobra_updated: ' Updating ' ,kobra_init: ' Initializing ' ,kobra_pausing: ' Pausing... ' ,kobra_paused: ' Paused ' ,kobra_resuming: ' Resuming... ' ,kobra_resumed: ' Resumed ' ,kobra_stopping: ' Stopping... ' ,kobra_stoped: ' Stopped ' ,kobra_finished: ' Finished ' ,kobra_failed: ' Error ' ,kobra_canceled: ' Cancelled ' ,kobra_offline: ' Offline ' ,
nav_dashboard: ' Dashboard ' ,nav_print: ' Print ' ,nav_temps: ' Temperatures ' ,nav_motion: ' Motion ' ,nav_ams: ' AMS ' ,nav_extras: ' Light / Fan ' ,nav_console: ' Console ' ,
card_progress: ' Progress ' ,card_temps: ' Temperatures ' ,card_light_fan: ' Fan ' ,card_speed: ' Print Speed ' ,card_cam: ' Camera ' ,lbl_elapsed: ' Elapsed ' ,lbl_remaining: ' r emaining' ,lbl_slicer_time: ' Slicer estimate: ' ,
card_progress: ' Progress ' ,card_temps: ' Temperatures ' ,card_light_fan: ' Fan ' ,card_speed: ' Print Speed ' ,card_cam: ' Camera ' ,lbl_elapsed: ' Elapsed: ' ,lbl_remaining: ' R emaining: ' ,lbl_slicer_time: ' Slicer estimate: ' ,lbl_layers: ' Layer ' ,
speed_silent: ' 🐢 Silent ' ,speed_normal: ' ⚡ Normal ' ,speed_sport: ' 🚀 Sport ' ,
lbl_light: ' 💡 Light ' ,lbl_feed: ' Load ' ,lbl_unload: ' Unload ' ,
cam_placeholder: ' 📷 Camera not started ' ,btn_cam_start: ' ▶ Camera ' ,btn_cam_stop: ' ◼ Camera ' ,
@@ -1449,7 +1512,9 @@ var LANG_EN={
slot_edit_title: ' Edit Slot ' ,slot_edit_color: ' Color ' ,slot_edit_material: ' Material ' ,
slot_edit_save: ' 💾 Save ' ,slot_edit_custom: ' e.g. PLA, PETG, ABS… ' ,
slot_edit_ok: ' AMS Slot ' ,
log_dir_all: ' All '
log_dir_all: ' All ' ,
file_ready_btn: ' ▶ Start Print ' ,
file_cancel_btn: ' ✕ Cancel '
};
var currentLang= ' de ' ;
var T=LANG_DE;
@@ -1475,6 +1540,10 @@ function applyLang(){
setText( ' d-card-speed ' ,T.card_speed);
setText( ' d-card-cam ' ,T.card_cam);
setText( ' d-card-ams ' ,T.panel_ams_title);
setText( ' d-lbl-elapsed ' ,T.lbl_elapsed);
setText( ' d-lbl-remain ' ,T.lbl_remaining);
setText( ' d-slicer-label ' ,T.lbl_slicer_time);
setText( ' d-lbl-layers ' ,T.lbl_layers);
setText( ' d-lbl-light ' ,T.lbl_light);
setText( ' d-lbl-bed ' ,T.label_bed);
// Dashboard buttons
@@ -1532,6 +1601,8 @@ function applyLang(){
setText( ' btn-slot-edit-save ' ,T.slot_edit_save);
var mi=document.getElementById( ' slot-edit-mat ' );if(mi)mi.setAttribute( ' placeholder ' ,T.slot_edit_custom);
setText( ' logdir-all ' ,T.log_dir_all);
setText( ' file-ready-btn ' ,T.file_ready_btn);
setText( ' file-cancel-btn ' ,T.file_cancel_btn);
}
function setText(id,txt) { var el=document.getElementById(id);if(el)el.textContent=txt;}
(function() {
@@ -1621,8 +1692,10 @@ function renderLog(){
if(fl&&!m.toLowerCase().includes(fl))return false;
return true;
});
var savedScroll=logAutoScroll?null:el.scrollTop;
el.innerHTML=rows.map(l=>`<div><span class= " ts " >$ {l.ts} </span><span class= " $ {l.cls} " >$ { escHtml(l.msg)}</span></div>`).join( ' ' );
if(logAutoScroll)el.scrollTop=el.scrollHeight;
else if(savedScroll!==null)el.scrollTop=savedScroll;
}
function onLogScroll() {
var el=document.getElementById( ' console-log ' );
@@ -1665,6 +1738,13 @@ function applyState(){
// connection error banner
var banner=document.getElementById( ' conn-error-banner ' );
if(banner) { if(s.connection_error) { banner.textContent= ' ⚠ ' +(T.lbl_conn_error|| ' Connection error: ' )+ ' ' +s.connection_error;banner.style.display= ' block ' ;}else { banner.style.display= ' none ' ;}}
var frb=document.getElementById( ' file-ready-banner ' );
if(frb) {
if(s.file_ready&&s.print_state=== ' standby ' ) {
document.getElementById( ' file-ready-name ' ).textContent=s.file_ready;
frb.style.display= ' flex ' ;
}else { frb.style.display= ' none ' ;}
}
// header
var b=document.getElementById( ' h-badge ' );
b.className= ' hbadge ' +s.print_state;
@@ -1691,16 +1771,12 @@ function applyState(){
var layers=s.curr_layer&&s.total_layers? ' L ' +s.curr_layer+ ' / ' +s.total_layers: ' – ' ;
var dlayers=document.getElementById( ' d-layers ' );if(dlayers)dlayers.textContent=layers;
var elapsed=fmtTime(s.print_duration);
var delapsed =document.getElementById( ' d-elapsed ' );if(delapsed)delapsed.textContent=elapsed ;
var remain=s.remain_time>0? ' ≈ ' +fmtTime(s.remain_time)+ ' ' +T.lbl_remaining: ' ' ;
var dremain=document.getElementById( ' d-remain ' );if(dremain)dremain.textContent=remain;
var d elapsed=document.getElementById( ' d-elapsed ' );if(delapsed)delapsed.textContent= fmtTime(s.print_duration);
var dremain =document.getElementById( ' d-remain ' );if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time): ' – ' ;
var dslrow=document.getElementById( ' d-slicer-row ' );
var dsltime=document.getElementById( ' d-slicer-time ' );
var dsllbl=document.getElementById( ' d-slicer-label ' );
if(dslrow&&dsltime) {
if(s.slicer_time>0) { dslrow.style.display= ' ' ;if(dsllbl)dsllbl.textContent=T.lbl_slicer_time; dsltime.textContent=fmtTime(s.slicer_time);}
if(s.slicer_time>0) { dslrow.style.display= ' ' ;dsltime.textContent=fmtTime(s.slicer_time);}
else { dslrow.style.display= ' none ' ;}
}
@@ -1877,6 +1953,24 @@ function openSlotEdit(i){
function closeSlotEdit() {
document.getElementById( ' slot-edit-modal ' ).classList.remove( ' open ' );
}
function startReadyFile() {
var btn=document.getElementById( ' file-ready-btn ' );
if(btn) { btn.disabled=true;btn.textContent= ' … ' ;}
post( ' /printer/print/start ' , { filename:S.file_ready})
.then(function(r) { return r.json();})
.then(function(r) {
document.getElementById( ' file-ready-banner ' ).style.display= ' none ' ;
if(btn) { btn.disabled=false;setText( ' file-ready-btn ' ,T.file_ready_btn);}
})
.catch(function(e) {
clog((T.log_error|| ' Error: ' )+ ' ' +e, ' msg-err ' );
if(btn) { btn.disabled=false;setText( ' file-ready-btn ' ,T.file_ready_btn);}
});
}
function cancelReadyFile() {
post( ' /api/file_ready/clear ' , {} )
.then(function() { document.getElementById( ' file-ready-banner ' ).style.display= ' none ' ;});
}
function selectMatPreset(m) {
document.getElementById( ' slot-edit-mat ' ).value=m;
highlightMatBtn(m);
@@ -2502,6 +2596,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
" ams_loaded_slot " : self . _ams_loaded_slot ,
" thumbnail " : self . _thumbnail_b64 ,
" connection_error " : s [ " connection_error " ] ,
" file_ready " : s [ " file_ready " ] ,
" version " : self . _read_version ( ) ,
} )
@@ -3029,6 +3124,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
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 )