From fc681316fc54475894357066d216f417eea243d3 Mon Sep 17 00:00:00 2001 From: viewit Date: Sun, 26 Apr 2026 14:58:20 +0200 Subject: [PATCH] release: v0.9.1-beta14 --- CHANGELOG.md | 25 ++++++ VERSION | 2 +- extract_credentials.py | 156 ++++++++++++++++++------------------- kobrax_client.py | 8 +- kobrax_moonraker_bridge.py | 135 +++++++++++++++++++++----------- requirements.txt | 1 + start.sh | 12 +-- 7 files changed, 208 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 145c535..6cff8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [0.9.1-beta14] – 2026-04-26 + +### Fixes +- Z-Achse: ▲ fährt jetzt aufwärts (Z+), ▼ abwärts (Z−) – Pfeile waren vertauscht (Issue #4) +- Home All: korrekter axis-Code 5 – homed alle Achsen XYZ (Issue #4) +- Neuer Button „Home XY" (axis=4) in der UI +- Neuer Button „Motors Off" (axis turnOff) in der UI + +--- + +## [0.9.1-beta13] – 2026-04-26 + +### Fixes (Windows) +- Self-Update / Settings-Neustart: `os.execv` funktioniert jetzt korrekt in der PyInstaller-Binary (kein doppelter Pfad als Argument mehr) +- Kamera: `ffmpeg nicht gefunden` crasht nicht mehr – saubere 503-Antwort wenn ffmpeg nicht installiert ist +- Reconnect-Loop: Kurzeitige leere TCP-Reads unter Windows führen nicht mehr sofort zu Reconnects + +### Struktur +- `bridge/`: Bridge-Dateien aus `05_scripts/` herausgelöst +- `tools/`: `extract_credentials.py` als eigenständiges Tool mit eigenem README +- `_archive/`: RE-Forschungsordner, Analyse-Tools und alte Release-Checksums archiviert +- README komplett neu: klarer 3-Schritte-Schnellstart + +--- + ## [0.9.1-beta12] – 2026-04-25 ### Fixes diff --git a/VERSION b/VERSION index 6c79dc9..e2472f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.1-beta12 +0.9.1-beta14 diff --git a/extract_credentials.py b/extract_credentials.py index 5c47ed2..c8bddb3 100644 --- a/extract_credentials.py +++ b/extract_credentials.py @@ -1,23 +1,23 @@ """ -extract_credentials.py – Extrahiert Anycubic LAN-MQTT-Credentials aus dem RAM -des laufenden AnycubicSlicerNext-Prozesses. +extract_credentials.py – Extracts Anycubic LAN-MQTT credentials from the RAM +of the running AnycubicSlicerNext process. -Voraussetzungen: - - AnycubicSlicerNext läuft und ist mit dem Drucker verbunden - - Gleiches Benutzerkonto wie der Slicer-Prozess (kein Admin nötig) +Requirements: + - AnycubicSlicerNext is running and connected to the printer + - Same user account as the slicer process (no admin required) -Verwendung: +Usage: python3 extract_credentials.py [--write-env] [--env-file ../.env] -Funktionsweise: - 1. Prozess "AnycubicSlicer.exe" (Windows) bzw. "AnycubicSlicer" (Linux) finden - 2. Speicherseiten des Prozesses durchsuchen (nur r/rw, keine Exec-Pages) - 3. Nach MQTT-Credential-Patterns suchen: +How it works: + 1. Find process "AnycubicSlicer.exe" (Windows) or "AnycubicSlicer" (Linux) + 2. Scan memory pages of the process (only r/rw, no exec pages) + 3. Search for MQTT credential patterns: Username: user[A-Za-z0-9]{8,12} Password: [A-Za-z0-9+/]{13,18} - Drucker-IP: d{1,3}.d{1,3}.d{1,3}.d{1,3} - 4. Kandidaten nach Plausibilität filtern und ausgeben - 5. Optional: .env-Datei schreiben + Printer IP: d{1,3}.d{1,3}.d{1,3}.d{1,3} + 4. Filter candidates by plausibility and print results + 5. Optionally write .env file """ import argparse @@ -28,48 +28,48 @@ import sys import platform # --------------------------------------------------------------------------- -# Plattform-Erkennung +# Platform detection # --------------------------------------------------------------------------- IS_WINDOWS = platform.system() == "Windows" IS_LINUX = platform.system() == "Linux" # --------------------------------------------------------------------------- -# Pattern +# Patterns # --------------------------------------------------------------------------- -# Username: "user" + 8–12 alphanumerische Zeichen (drucker-generiert) +# Username: "user" + 8–12 alphanumeric characters (printer-generated) RE_USERNAME = re.compile(rb'user[A-Za-z0-9]{8,12}(?=[^A-Za-z0-9]|$)') -# Password: 13–20 alphanumerische Zeichen (kein / da kein RTSP-Pfad) -# Anycubic-Passwörter: gemischte Groß/Klein/Ziffern, kein Slash +# Password: 13–20 alphanumeric characters (no / since no RTSP path) +# Anycubic passwords: mixed upper/lower/digits, no slash RE_PASSWORD = re.compile(rb'[A-Za-z0-9]{13,20}(?=[^A-Za-z0-9]|$)') -# Kontext-Pattern: sucht Passwort das direkt nach "password" im Speicher steht +# Context pattern: password directly following "password" in memory RE_PASSWORD_CTX = re.compile(rb'(?:password|passwd|Password)\x00{0,4}([A-Za-z0-9]{10,25})(?=[^A-Za-z0-9]|$)', re.IGNORECASE) -# Proximity-Pattern: Username gefolgt von Passwort in naher Umgebung (<512 Bytes) +# Proximity pattern: username followed by password within close range (<512 bytes) RE_USER_PASS_PROXIMITY = re.compile( rb'(user[A-Za-z0-9]{8,12}).{1,512}?([A-Za-z0-9]{13,20})(?=[^A-Za-z0-9]|$)', re.DOTALL ) -# IPv4-Adresse (kein localhost, kein Broadcast) +# IPv4 address (no localhost, no broadcast) RE_IP = re.compile(rb'(? "int | None": - """Findet die PID eines Prozesses anhand des Namens (case-insensitive).""" + """Find the PID of a process by name (case-insensitive).""" import ctypes import ctypes.wintypes @@ -110,15 +110,15 @@ def _win_find_pid(name: str) -> "int | None": def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": - """Liest alle lesbaren Speicherseiten eines Windows-Prozesses.""" + """Read all readable memory pages of a Windows process.""" import ctypes import ctypes.wintypes - PROCESS_VM_READ = 0x0010 + PROCESS_VM_READ = 0x0010 PROCESS_QUERY_INFORMATION = 0x0400 - MEM_COMMIT = 0x1000 - PAGE_NOACCESS = 0x01 - PAGE_GUARD = 0x100 + MEM_COMMIT = 0x1000 + PAGE_NOACCESS = 0x01 + PAGE_GUARD = 0x100 class MEMORY_BASIC_INFORMATION(ctypes.Structure): _fields_ = [ @@ -134,7 +134,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": k32 = ctypes.windll.kernel32 handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid) if not handle: - raise PermissionError(f"OpenProcess fehlgeschlagen (PID {pid}): {ctypes.GetLastError()}") + raise PermissionError(f"OpenProcess failed (PID {pid}): {ctypes.GetLastError()}") chunks = [] addr = 0 @@ -147,7 +147,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": mbi.State != MEM_COMMIT or mbi.Protect & PAGE_NOACCESS or mbi.Protect & PAGE_GUARD or - mbi.RegionSize > 256 * 1024 * 1024 # >256 MB überspringen + mbi.RegionSize > 256 * 1024 * 1024 # skip >256 MB regions ) if not skip: buf = ctypes.create_string_buffer(mbi.RegionSize) @@ -156,7 +156,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": buf, mbi.RegionSize, ctypes.byref(read)): chunks.append(bytes(buf[:read.value])) addr += mbi.RegionSize - if addr >= 0x7FFFFFFFFFFF: # Ende des User-Space (64-bit) + if addr >= 0x7FFFFFFFFFFF: # end of user space (64-bit) break finally: k32.CloseHandle(handle) @@ -165,11 +165,11 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": # --------------------------------------------------------------------------- -# Linux – Speicher lesen via /proc/{pid}/mem +# Linux – read memory via /proc/{pid}/mem # --------------------------------------------------------------------------- def _linux_find_pid(name: str) -> "int | None": - """Findet PID anhand des Prozessnamens in /proc.""" + """Find PID by process name in /proc.""" for entry in os.listdir("/proc"): if not entry.isdigit(): continue @@ -183,7 +183,7 @@ def _linux_find_pid(name: str) -> "int | None": def _linux_read_memory(pid: int) -> "list[bytes]": - """Liest lesbare Speichersegmente aus /proc/{pid}/mem.""" + """Read readable memory segments from /proc/{pid}/mem.""" chunks = [] maps_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" @@ -193,8 +193,8 @@ def _linux_read_memory(pid: int) -> "list[bytes]": mem = open(mem_path, "rb") except PermissionError: raise PermissionError( - f"Kein Zugriff auf /proc/{pid}/mem — " - "Script als gleicher Benutzer wie der Slicer starten." + f"No access to /proc/{pid}/mem — " + "run the script as the same user as the slicer process." ) for line in maps: @@ -202,16 +202,16 @@ def _linux_read_memory(pid: int) -> "list[bytes]": if len(parts) < 2: continue perms = parts[1] - if "r" not in perms: # nur lesbare Seiten + if "r" not in perms: # readable pages only continue - if "x" in perms: # Code-Seiten überspringen (keine Strings) + if "x" in perms: # skip code pages (no strings) continue try: start, end = [int(x, 16) for x in parts[0].split("-")] except ValueError: continue size = end - start - if size > 256 * 1024 * 1024: # >256 MB überspringen + if size > 256 * 1024 * 1024: # skip >256 MB regions continue try: mem.seek(start) @@ -226,7 +226,7 @@ def _linux_read_memory(pid: int) -> "list[bytes]": # --------------------------------------------------------------------------- -# Pattern-Suche +# Pattern search # --------------------------------------------------------------------------- def _is_valid_ip(ip_bytes: bytes) -> bool: @@ -245,7 +245,7 @@ def _is_valid_ip(ip_bytes: bytes) -> bool: def search_chunks(chunks: "list[bytes]") -> dict: - """Durchsucht Speicher-Chunks nach Credential-Patterns.""" + """Search memory chunks for credential patterns.""" usernames = {} # value → count passwords = {} ips = {} @@ -256,12 +256,12 @@ def search_chunks(chunks: "list[bytes]") -> dict: if i % 50 == 0 or i == total - 1: pct = (i + 1) * 100 // total mb_done = sum(len(c) for c in chunks[:i+1]) / 1024 / 1024 - print(f"\r[*] Analysiere ... {pct:3d}% ({mb_done:.0f} MB)", end="", flush=True) + print(f"\r[*] Scanning ... {pct:3d}% ({mb_done:.0f} MB)", end="", flush=True) for m in RE_USERNAME.finditer(chunk): v = m.group().decode("ascii", errors="replace") usernames[v] = usernames.get(v, 0) + 1 - # Proximity: Passwort das innerhalb von 512 Bytes nach einem Username steht + # Proximity: password within 512 bytes after a username for m in RE_USER_PASS_PROXIMITY.finditer(chunk): pw = m.group(2).decode("ascii", errors="replace") has_upper = any(c.isupper() for c in pw) @@ -272,7 +272,7 @@ def search_chunks(chunks: "list[bytes]") -> dict: for m in RE_PASSWORD.finditer(chunk): v = m.group().decode("ascii", errors="replace") - # Filter: mindestens 2 Großbuchstaben + 2 Kleinbuchstaben + 1 Ziffer + # Filter: at least 2 uppercase + 2 lowercase + 1 digit has_upper = sum(1 for c in v if c.isupper()) >= 2 has_lower = sum(1 for c in v if c.islower()) >= 2 has_digit = sum(1 for c in v if c.isdigit()) >= 1 @@ -289,9 +289,9 @@ def search_chunks(chunks: "list[bytes]") -> dict: v = m.group().decode("ascii") device_ids[v] = device_ids.get(v, 0) + 1 - print() # Zeilenumbruch nach Fortschrittszeile + print() # newline after progress line - # Nach Häufigkeit sortieren, häufigste zuerst + # Sort by frequency, most frequent first def top(d, n=10): return sorted(d.items(), key=lambda x: -x[1])[:n] @@ -304,7 +304,7 @@ def search_chunks(chunks: "list[bytes]") -> dict: # --------------------------------------------------------------------------- -# Hauptprogramm +# Main # --------------------------------------------------------------------------- SLICER_NAMES = [ @@ -359,95 +359,95 @@ def write_env(results: dict, env_path: str, with open(env_path, "w") as f: f.writelines(lines) - print(f"\n✓ Credentials in '{env_path}' gespeichert.") + print(f"\n✓ Credentials saved to '{env_path}'.") def main(): parser = argparse.ArgumentParser( - description="Extrahiert MQTT-Credentials aus dem RAM des AnycubicSlicer-Prozesses" + description="Extract MQTT credentials from the AnycubicSlicer process RAM" ) parser.add_argument("--write-env", action="store_true", - help="Gefundene Credentials in .env schreiben") + help="Write found credentials to .env file") parser.add_argument("--env-file", default=None, - help="Pfad zur .env-Datei (Standard: ../. env relativ zu diesem Script)") + help="Path to .env file (default: ../.env relative to this script)") parser.add_argument("--pid", type=int, default=None, - help="Prozess-PID direkt angeben (überspringt Auto-Erkennung)") + help="Specify process PID directly (skips auto-detection)") parser.add_argument("--verbose", action="store_true", - help="Alle Kandidaten ausgeben, nicht nur den besten") + help="Show all candidates, not just the best match") args = parser.parse_args() - # .env-Pfad bestimmen + # Determine .env path if args.env_file: env_path = args.env_file else: env_path = os.path.join(os.path.dirname(__file__), "..", ".env") env_path = os.path.normpath(env_path) - # Prozess finden + # Find process if args.pid: pid, proc_name = args.pid, f"PID {args.pid}" else: - print("[*] Suche AnycubicSlicer-Prozess ...") + print("[*] Searching for AnycubicSlicer process ...") pid, proc_name = find_slicer_pid() if not pid: - print("✗ AnycubicSlicer nicht gefunden. Bitte den Slicer starten und " - "mit dem Drucker verbinden, dann erneut ausführen.") + print("✗ AnycubicSlicer not found. Please start the slicer, connect it " + "to the printer, then run this script again.") sys.exit(1) - print(f"[*] Prozess gefunden: {proc_name} (PID {pid})") - print(f"[*] Lese Prozess-Speicher ...") + print(f"[*] Process found: {proc_name} (PID {pid})") + print(f"[*] Reading process memory ...") try: chunks = read_process(pid) except PermissionError as e: - print(f"✗ Zugriffsfehler: {e}") + print(f"✗ Permission error: {e}") sys.exit(1) total_mb = sum(len(c) for c in chunks) / 1024 / 1024 - print(f"[*] {len(chunks)} Speichersegmente gelesen ({total_mb:.1f} MB)") - print(f"[*] Durchsuche nach Credentials ...") + print(f"[*] {len(chunks)} memory segments read ({total_mb:.1f} MB)") + print(f"[*] Searching for credentials ...") results = search_chunks(chunks) - # Ausgabe + # Output print("\n" + "="*55) - print(" ERGEBNISSE") + print(" RESULTS") print("="*55) def show(label, items, verbose): if not items: - print(f" {label:12s} — nicht gefunden") + print(f" {label:12s} — not found") return items[0][0] if items else "" best = items[0][0] - print(f" {label:12s} {best} (Treffer: {items[0][1]})") + print(f" {label:12s} {best} (matches: {items[0][1]})") if verbose and len(items) > 1: for val, cnt in items[1:]: - print(f" {'':12s} {val} (Treffer: {cnt})") + print(f" {'':12s} {val} (matches: {cnt})") return best - best_user = show("Username", results["usernames"], args.verbose) - best_pass = show("Password", results["passwords"], args.verbose) - best_device = show("Device-ID", results["device_ids"], args.verbose) + best_user = show("Username", results["usernames"], args.verbose) + best_pass = show("Password", results["passwords"], args.verbose) + best_device = show("Device-ID", results["device_ids"], args.verbose) - # IP: 192.168.x.x bevorzugen + # IP: prefer 192.168.x.x lan_ips = [(ip, cnt) for ip, cnt in results["ips"] if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")] if not lan_ips: lan_ips = results["ips"] - best_ip = show("Drucker-IP", lan_ips, args.verbose) + best_ip = show("Printer IP", lan_ips, args.verbose) print("="*55) if not best_user or not best_pass: - print("\n⚠ Keine vollständigen Credentials gefunden.") - print(" Stelle sicher dass der Slicer MIT dem Drucker verbunden ist.") + print("\n⚠ No complete credentials found.") + print(" Make sure the slicer is connected to the printer.") sys.exit(1) if args.write_env: write_env(results, env_path, best_user, best_pass, best_ip, device_id=best_device) else: - print(f"\nHinweis: --write-env übergeben um Credentials in '{env_path}' zu speichern.") + print(f"\nTip: pass --write-env to save credentials to '{env_path}'.") if __name__ == "__main__": diff --git a/kobrax_client.py b/kobrax_client.py index b801194..2aea55a 100644 --- a/kobrax_client.py +++ b/kobrax_client.py @@ -198,6 +198,7 @@ class KobraXClient: def _read_loop(self): last_ping = time.time() + _empty_count = 0 while self._running: if time.time() - last_ping > 30: with self._lock: @@ -212,7 +213,12 @@ class KobraXClient: try: data = self._sock.recv(65536) if not data: - raise ConnectionResetError("EOF") + # Windows SSL kann kurzzeitig b"" liefern ohne echten EOF + _empty_count += 1 + if _empty_count >= 5: + raise ConnectionResetError("EOF") + continue + _empty_count = 0 self._buf += data self._drain() except ssl.SSLWantReadError: diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index ce9c4f9..1c2755f 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -29,6 +29,19 @@ _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os sys.path.insert(0, _BASE) from kobrax_client import KobraXClient + +try: + import imageio_ffmpeg + def _find_ffmpeg() -> str: + return imageio_ffmpeg.get_ffmpeg_exe() +except ImportError: + def _find_ffmpeg() -> str: + exe_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg" + local = os.path.join(_BASE, exe_name) + if os.path.isfile(local): + return local + return "ffmpeg" + try: from aiohttp import web import aiohttp @@ -1031,17 +1044,17 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
- - - + + +
Z-Achse
- - + +
Schrittweite: 1 mm
@@ -1155,7 +1168,7 @@ var LANG_DE={ panel_print_title:'Drucksteuerung',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Fortsetzen',panel_print_btn_cancel:'✕ Abbrechen',panel_print_temps_live:'Temperaturen (Live)', label_set:'Setzen',label_off:'Aus', panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heizbett',panel_temps_chart:'Verlauf (letzte 60 Messungen)',label_target_c:'Ziel:', - panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_x:'Home X',btn_home_y:'Home Y',btn_home_z:'Home Z',btn_home_all:'Home All', + panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motoren aus', panel_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot', panel_extras_light:'Licht',panel_extras_fan:'Lüfter',panel_extras_camera:'Kamera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop', panel_console_title:'Ereignis-Log', @@ -1181,7 +1194,7 @@ var LANG_EN={ panel_print_title:'Print Control',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Resume',panel_print_btn_cancel:'✕ Cancel',panel_print_temps_live:'Temperatures (Live)', label_set:'Set',label_off:'Off', panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heated Bed',panel_temps_chart:'History (last 60 readings)',label_target_c:'Target:', - panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_x:'Home X',btn_home_y:'Home Y',btn_home_z:'Home Z',btn_home_all:'Home All', + panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motors Off', panel_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot', panel_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop', panel_console_title:'Event Log', @@ -1233,10 +1246,10 @@ function applyLang(){ // Axis labels setText('ptitle-motion-xy',T.panel_motion_xy); setText('ptitle-motion-z',T.panel_motion_z); - document.querySelectorAll('.lbl-home-x').forEach(e=>e.textContent=T.btn_home_x); - document.querySelectorAll('.lbl-home-y').forEach(e=>e.textContent=T.btn_home_y); document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z); + document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy); document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all); + document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors); document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step); document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':',''))); // Console @@ -1591,16 +1604,25 @@ function move(axis,dir,dist){ .catch(function(e){clog('Achse-Fehler: '+e,'msg-err')}); } function homeAll(){ - post('/api/axis',{axis:4,move_type:2,distance:0}) + post('/api/axis',{axis:5,move_type:2,distance:0}) .then(function(){clog('Home All','msg-ok')}) .catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); } -function homeAxis(ax){ - var m={X:1,Y:2,Z:3}; - post('/api/axis',{axis:m[ax],move_type:2,distance:0}) - .then(function(){clog('Home '+ax,'msg-ok')}) +function homeXY(){ + post('/api/axis',{axis:4,move_type:2,distance:0}) + .then(function(){clog('Home XY','msg-ok')}) .catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); } +function homeZ(){ + post('/api/axis',{axis:3,move_type:2,distance:0}) + .then(function(){clog('Home Z','msg-ok')}) + .catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); +} +function disableMotors(){ + post('/api/axis',{action:'turnOff'}) + .then(function(){clog('Motors Off','msg-ok')}) + .catch(function(e){clog('Motors-Fehler: '+e,'msg-err')}); +} // ── Temperature ── function setNozzle(){ @@ -1808,15 +1830,21 @@ function toggleCam(){if(camOn)camStop();else camStart()} body = await request.json() except Exception: body = {} - axis = int(body.get("axis", 4)) - move_type = int(body.get("move_type", 2)) - distance = float(body.get("distance", 0)) + action = body.get("action", "move") loop = asyncio.get_event_loop() - await loop.run_in_executor(None, lambda: self.client.publish( - "axis", "move", - {"axis": axis, "move_type": move_type, "distance": distance}, - timeout=0 - )) + if action == "turnOff": + await loop.run_in_executor(None, lambda: self.client.publish( + "axis", "turnOff", None, timeout=0 + )) + else: + axis = int(body.get("axis", 4)) + move_type = int(body.get("move_type", 2)) + distance = float(body.get("distance", 0)) + await loop.run_in_executor(None, lambda: self.client.publish( + "axis", "move", + {"axis": axis, "move_type": move_type, "distance": distance}, + timeout=0 + )) return web.json_response({"result": "ok"}) async def handle_api_temperature(self, request): @@ -1880,14 +1908,6 @@ function toggleCam(){if(camOn)camStop();else camStart()} if not url: return web.Response(status=503, text="Keine Kamera-URL bekannt") - boundary = "kobraxframe" - resp = web.StreamResponse(headers={ - "Content-Type": f"multipart/x-mixed-replace;boundary={boundary}", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }) - await resp.prepare(request) - is_rtsp = url.lower().startswith("rtsp://") ffmpeg_input_args = [ "-fflags", "nobuffer", @@ -1896,22 +1916,38 @@ function toggleCam(){if(camOn)camStop();else camStart()} if is_rtsp: ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: - # HTTP-FLV/HLS: braucht mehr Probe-Puffer für Container-Erkennung ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] - proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-loglevel", "quiet", - *ffmpeg_input_args, - "-i", url, - "-vf", "fps=15,scale=640:-1", - "-f", "image2pipe", - "-vcodec", "mjpeg", - "-q:v", "3", - "-flush_packets", "1", - "pipe:1", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) + # ffmpeg erst starten BEVOR der StreamResponse geöffnet wird + # (damit wir bei Fehler noch eine normale HTTP-Response senden können) + try: + proc = await asyncio.create_subprocess_exec( + _find_ffmpeg(), "-loglevel", "quiet", + *ffmpeg_input_args, + "-i", url, + "-vf", "fps=15,scale=640:-1", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "-q:v", "3", + "-flush_packets", "1", + "pipe:1", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except (FileNotFoundError, OSError) as e: + log.warning("Kamera: ffmpeg nicht gefunden – Kamerastream nicht verfügbar") + return web.Response(status=503, text="ffmpeg not found") + except Exception as e: + log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}") + return web.Response(status=503, text=str(e)) + + boundary = "kobraxframe" + resp = web.StreamResponse(headers={ + "Content-Type": f"multipart/x-mixed-replace;boundary={boundary}", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }) + await resp.prepare(request) buf = b"" try: @@ -2107,7 +2143,12 @@ function toggleCam(){if(camOn)camStop();else camStart()} def _restart_bridge(self): log.info("Bridge wird neu gestartet …") - os.execv(sys.executable, [sys.executable] + sys.argv) + exe = sys.executable + # PyInstaller frozen binary: sys.argv[0] == sys.executable → nicht doppelt übergeben + if getattr(sys, "frozen", False): + os.execv(exe, [exe]) + else: + os.execv(exe, [exe] + sys.argv) # ─── Update ────────────────────────────────────────────────────────────── @@ -2544,6 +2585,10 @@ def main(): if args.printer_ip and ":" in args.printer_ip: args.printer_ip = args.printer_ip.split(":")[0] + # Windows braucht ProactorEventLoop für asyncio.create_subprocess_exec + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.run(run_bridge(args)) diff --git a/requirements.txt b/requirements.txt index 6a37f7b..f8380f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ aiohttp>=3.9 +imageio-ffmpeg>=0.4.9 diff --git a/start.sh b/start.sh index ea64fa9..54172f9 100755 --- a/start.sh +++ b/start.sh @@ -34,12 +34,12 @@ else print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0) for f in Dockerfile \ - 05_scripts/kobrax_moonraker_bridge.py \ - 05_scripts/kobrax_client.py \ - 05_scripts/env_loader.py \ - 05_scripts/requirements.txt \ - 05_scripts/anycubic_slicer.crt \ - 05_scripts/anycubic_slicer.key; do + bridge/kobrax_moonraker_bridge.py \ + bridge/kobrax_client.py \ + bridge/env_loader.py \ + bridge/requirements.txt \ + bridge/anycubic_slicer.crt \ + bridge/anycubic_slicer.key; do if [[ -f "$f" ]]; then FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0) if [[ $FILE_TS -gt $IMAGE_TS ]]; then