feat: GCode Web-Upload + Download + Verify-Dialog (PR #32)

Übernommen mit Anpassungen aus PR #32 von @gangoke:
- Drag&Drop GCode-Upload
- Download-Button pro Datei
- Web-Upload-Verify-Dialog (persistent Flag, global abschaltbar)

Review-Fixes:
- Content-Disposition mit RFC5987 filename*= + ASCII-Fallback
- DE-Strings übersetzt

Dev-Repo: viewit/KX-Bridge@7c834bc
Co-authored-by: gangoke <gangoke@noreply.localhost>
Co-committed-by: gangoke <gangoke@noreply.localhost>
This commit is contained in:
2026-05-27 23:37:41 +02:00
committed by viewit
parent 6c5dd14dbd
commit 42898c385c
6 changed files with 290 additions and 12 deletions

View File

@@ -362,7 +362,8 @@ class GCodeStore:
layer_count INTEGER,
gcode_filaments TEXT,
objects_skip_parts TEXT,
svg_image TEXT
svg_image TEXT,
web_unverified INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS print_jobs (
id TEXT PRIMARY KEY,
@@ -389,10 +390,17 @@ class GCodeStore:
self._conn.commit()
except Exception:
pass
# Migration: Flag für Web-Uploads (Warnhinweis vor Druck)
try:
self._conn.execute("ALTER TABLE gcode_files ADD COLUMN web_unverified INTEGER NOT NULL DEFAULT 0")
self._conn.commit()
except Exception:
pass
def save_file(self, file_id: str, filename: str, data: bytes,
est_time_sec: int = 0, thumbnail_b64: str = "",
gcode_filaments: list | None = None) -> str:
gcode_filaments: list | None = None,
web_unverified: bool = False) -> str:
"""Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück."""
safe_name = os.path.basename(filename)
path = os.path.join(self._gcode_dir, safe_name)
@@ -403,9 +411,9 @@ class GCodeStore:
filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
self._conn.execute(
"""INSERT OR REPLACE INTO gcode_files
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments)
VALUES (?,?,?,?,?,?,?,?)""",
(file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json)
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified)
VALUES (?,?,?,?,?,?,?,?,?)""",
(file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0)
)
self._conn.commit()
return path
@@ -453,6 +461,15 @@ class GCodeStore:
)
self._conn.commit()
def clear_web_unverified(self, file_id: str) -> bool:
with self._lock:
cur = self._conn.execute(
"UPDATE gcode_files SET web_unverified=0 WHERE id=?",
(file_id,),
)
self._conn.commit()
return cur.rowcount > 0
def delete_file(self, file_id: str) -> bool:
row = self.get_file(file_id)
if not row:
@@ -1484,6 +1501,7 @@ class KobraXBridge:
for j in reversed(jobs):
last_job[j["gcode_file_id"]] = j
for f in files:
f["web_unverified"] = bool(f.get("web_unverified"))
lj = last_job.get(f["id"])
f["last_print_status"] = lj["status"] if lj else None
f["last_print_duration"] = lj["duration_sec"] if lj else None
@@ -1496,6 +1514,25 @@ class KobraXBridge:
return self._json_cors({"result": "ok"})
return self._json_cors({"error": "not found"}, status=404)
async def handle_kx_file_download(self, request):
file_id = request.match_info["file_id"]
f = self._store.get_file(file_id)
if not f:
return self._json_cors({"error": "not found"}, status=404)
path = f.get("path") or ""
if not path or not os.path.isfile(path):
return self._json_cors({"error": "not found"}, status=404)
filename = os.path.basename(f.get("filename") or path)
return web.FileResponse(path, headers={
"Content-Disposition": f'attachment; filename="{filename}"'
})
async def handle_kx_file_verify(self, request):
file_id = request.match_info["file_id"]
if self._store.clear_web_unverified(file_id):
return self._json_cors({"result": "ok"})
return self._json_cors({"error": "not found"}, status=404)
async def handle_kx_filament_slots(self, request):
slots = []
for i, s in enumerate(self._ams_slots):
@@ -1811,6 +1848,7 @@ class KobraXBridge:
if "multipart" not in ct:
return web.json_response({"error": "expected multipart"}, status=400)
auto_print = False
web_upload = False
reader = await request.multipart()
file_data = None
remote_filename = self._last_uploaded_file or "upload.gcode"
@@ -1827,6 +1865,9 @@ class KobraXBridge:
elif part.name == "print":
val = (await part.read()).decode("utf-8", errors="replace").strip().lower()
auto_print = val == "true"
elif part.name == "web_upload":
val = (await part.read()).decode("utf-8", errors="replace").strip().lower()
web_upload = val == "true"
else:
log.debug(f"Unbekanntes Multipart-Feld: {part.name}")
@@ -1850,6 +1891,7 @@ class KobraXBridge:
est_time_sec=est_time,
thumbnail_b64=thumbnail_b64,
gcode_filaments=gcode_filaments or None,
web_unverified=web_upload,
)
serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename))
del file_data # RAM freigeben
@@ -2651,6 +2693,7 @@ class KobraXBridge:
"camera_url": s["camera_url"],
"fan_speed": s["fan_speed"],
"print_speed_mode": s["print_speed_mode"],
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"light_on": s["light_on"],
"light_brightness": s["light_brightness"],
"ams_slots": self._ams_slots,
@@ -2743,7 +2786,8 @@ class KobraXBridge:
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"ace_dry_presets": self._ace_dry_presets,
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"ace_dry_presets": self._ace_dry_presets,
})
async def handle_api_settings_post(self, request):
@@ -2772,6 +2816,7 @@ class KobraXBridge:
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
if not cfg.has_option("bridge", "poll_interval"):
cfg.set("bridge", "poll_interval", "3")
printer_name = str(data.get("printer_name", "")).strip()
@@ -3451,6 +3496,8 @@ def build_app(bridge: KobraXBridge) -> web.Application:
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/filament/slots", bridge.handle_kx_filament_slots)
r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/ui/{name}", bridge.handle_kx_ui_asset)
@@ -3617,6 +3664,7 @@ def main():
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("--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("--host", default="0.0.0.0",
help="Bind-Adresse für den Bridge-Server")