Compare commits

..

3 Commits

Author SHA1 Message Date
c3a62a13c5 release: v0.9.1-beta15 2026-04-26 15:52:27 +02:00
4f1eaf7e93 fix: apt ffmpeg entfernt, imageio-ffmpeg übernimmt 2026-04-26 15:18:38 +02:00
fc681316fc release: v0.9.1-beta14 2026-04-26 14:58:20 +02:00
8 changed files with 236 additions and 145 deletions

View File

@@ -1,5 +1,39 @@
# Changelog # Changelog
## [0.9.1-beta15] 2026-04-26
### Fixes
- AMS: Leere Slots werden beim Druckstart übersprungen kein `filament runout` mehr bei unbelegten Kanälen (Issue #5)
- AMS: Material-Typ wird jetzt korrekt aus dem Drucker-Protokoll gelesen (Feld `type` statt `material_type`)
- AMS UI: Leere Slots werden grau und transparent dargestellt mit „Leer"-Label
---
## [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 ## [0.9.1-beta12] 2026-04-25
### Fixes ### Fixes

View File

@@ -3,7 +3,6 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY kobrax_moonraker_bridge.py . COPY kobrax_moonraker_bridge.py .

View File

@@ -1 +1 @@
0.9.1-beta12 0.9.1-beta15

View File

@@ -1,23 +1,23 @@
""" """
extract_credentials.py Extrahiert Anycubic LAN-MQTT-Credentials aus dem RAM extract_credentials.py Extracts Anycubic LAN-MQTT credentials from the RAM
des laufenden AnycubicSlicerNext-Prozesses. of the running AnycubicSlicerNext process.
Voraussetzungen: Requirements:
- AnycubicSlicerNext läuft und ist mit dem Drucker verbunden - AnycubicSlicerNext is running and connected to the printer
- Gleiches Benutzerkonto wie der Slicer-Prozess (kein Admin nötig) - Same user account as the slicer process (no admin required)
Verwendung: Usage:
python3 extract_credentials.py [--write-env] [--env-file ../.env] python3 extract_credentials.py [--write-env] [--env-file ../.env]
Funktionsweise: How it works:
1. Prozess "AnycubicSlicer.exe" (Windows) bzw. "AnycubicSlicer" (Linux) finden 1. Find process "AnycubicSlicer.exe" (Windows) or "AnycubicSlicer" (Linux)
2. Speicherseiten des Prozesses durchsuchen (nur r/rw, keine Exec-Pages) 2. Scan memory pages of the process (only r/rw, no exec pages)
3. Nach MQTT-Credential-Patterns suchen: 3. Search for MQTT credential patterns:
Username: user[A-Za-z0-9]{8,12} Username: user[A-Za-z0-9]{8,12}
Password: [A-Za-z0-9+/]{13,18} Password: [A-Za-z0-9+/]{13,18}
Drucker-IP: d{1,3}.d{1,3}.d{1,3}.d{1,3} Printer IP: d{1,3}.d{1,3}.d{1,3}.d{1,3}
4. Kandidaten nach Plausibilität filtern und ausgeben 4. Filter candidates by plausibility and print results
5. Optional: .env-Datei schreiben 5. Optionally write .env file
""" """
import argparse import argparse
@@ -28,48 +28,48 @@ import sys
import platform import platform
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plattform-Erkennung # Platform detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
IS_WINDOWS = platform.system() == "Windows" IS_WINDOWS = platform.system() == "Windows"
IS_LINUX = platform.system() == "Linux" IS_LINUX = platform.system() == "Linux"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pattern # Patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Username: "user" + 812 alphanumerische Zeichen (drucker-generiert) # Username: "user" + 812 alphanumeric characters (printer-generated)
RE_USERNAME = re.compile(rb'user[A-Za-z0-9]{8,12}(?=[^A-Za-z0-9]|$)') RE_USERNAME = re.compile(rb'user[A-Za-z0-9]{8,12}(?=[^A-Za-z0-9]|$)')
# Password: 1320 alphanumerische Zeichen (kein / da kein RTSP-Pfad) # Password: 1320 alphanumeric characters (no / since no RTSP path)
# Anycubic-Passwörter: gemischte Groß/Klein/Ziffern, kein Slash # Anycubic passwords: mixed upper/lower/digits, no slash
RE_PASSWORD = re.compile(rb'[A-Za-z0-9]{13,20}(?=[^A-Za-z0-9]|$)') 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) 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( 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]|$)', rb'(user[A-Za-z0-9]{8,12}).{1,512}?([A-Za-z0-9]{13,20})(?=[^A-Za-z0-9]|$)',
re.DOTALL re.DOTALL
) )
# IPv4-Adresse (kein localhost, kein Broadcast) # IPv4 address (no localhost, no broadcast)
RE_IP = re.compile(rb'(?<![.\d])(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?![.\d])') RE_IP = re.compile(rb'(?<![.\d])(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?![.\d])')
# mode_id: 5-stellige Zahl (z.B. 20030) # mode_id: 5-digit number (e.g. 20030)
RE_MODE_ID = re.compile(rb'(?<!\d)(2\d{4})(?!\d)') RE_MODE_ID = re.compile(rb'(?<!\d)(2\d{4})(?!\d)')
# device_id: 32 Hex-Zeichen (MD5-Format) # device_id: 32 hex characters (MD5 format)
RE_DEVICE_ID = re.compile(rb'[0-9a-f]{32}(?=[^0-9a-f]|$)') RE_DEVICE_ID = re.compile(rb'[0-9a-f]{32}(?=[^0-9a-f]|$)')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Windows Speicher lesen via ctypes / ReadProcessMemory # Windows read memory via ctypes / ReadProcessMemory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _win_find_pid(name: str) -> "int | None": def _win_find_pid(name: str) -> "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
import ctypes.wintypes import ctypes.wintypes
@@ -110,7 +110,7 @@ def _win_find_pid(name: str) -> "int | None":
def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": 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
import ctypes.wintypes import ctypes.wintypes
@@ -134,7 +134,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
k32 = ctypes.windll.kernel32 k32 = ctypes.windll.kernel32
handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid) handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not handle: if not handle:
raise PermissionError(f"OpenProcess fehlgeschlagen (PID {pid}): {ctypes.GetLastError()}") raise PermissionError(f"OpenProcess failed (PID {pid}): {ctypes.GetLastError()}")
chunks = [] chunks = []
addr = 0 addr = 0
@@ -147,7 +147,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
mbi.State != MEM_COMMIT or mbi.State != MEM_COMMIT or
mbi.Protect & PAGE_NOACCESS or mbi.Protect & PAGE_NOACCESS or
mbi.Protect & PAGE_GUARD 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: if not skip:
buf = ctypes.create_string_buffer(mbi.RegionSize) 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)): buf, mbi.RegionSize, ctypes.byref(read)):
chunks.append(bytes(buf[:read.value])) chunks.append(bytes(buf[:read.value]))
addr += mbi.RegionSize addr += mbi.RegionSize
if addr >= 0x7FFFFFFFFFFF: # Ende des User-Space (64-bit) if addr >= 0x7FFFFFFFFFFF: # end of user space (64-bit)
break break
finally: finally:
k32.CloseHandle(handle) 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": 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"): for entry in os.listdir("/proc"):
if not entry.isdigit(): if not entry.isdigit():
continue continue
@@ -183,7 +183,7 @@ def _linux_find_pid(name: str) -> "int | None":
def _linux_read_memory(pid: int) -> "list[bytes]": def _linux_read_memory(pid: int) -> "list[bytes]":
"""Liest lesbare Speichersegmente aus /proc/{pid}/mem.""" """Read readable memory segments from /proc/{pid}/mem."""
chunks = [] chunks = []
maps_path = f"/proc/{pid}/maps" maps_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem" mem_path = f"/proc/{pid}/mem"
@@ -193,8 +193,8 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
mem = open(mem_path, "rb") mem = open(mem_path, "rb")
except PermissionError: except PermissionError:
raise PermissionError( raise PermissionError(
f"Kein Zugriff auf /proc/{pid}/mem — " f"No access to /proc/{pid}/mem — "
"Script als gleicher Benutzer wie der Slicer starten." "run the script as the same user as the slicer process."
) )
for line in maps: for line in maps:
@@ -202,16 +202,16 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
if len(parts) < 2: if len(parts) < 2:
continue continue
perms = parts[1] perms = parts[1]
if "r" not in perms: # nur lesbare Seiten if "r" not in perms: # readable pages only
continue continue
if "x" in perms: # Code-Seiten überspringen (keine Strings) if "x" in perms: # skip code pages (no strings)
continue continue
try: try:
start, end = [int(x, 16) for x in parts[0].split("-")] start, end = [int(x, 16) for x in parts[0].split("-")]
except ValueError: except ValueError:
continue continue
size = end - start size = end - start
if size > 256 * 1024 * 1024: # >256 MB überspringen if size > 256 * 1024 * 1024: # skip >256 MB regions
continue continue
try: try:
mem.seek(start) 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: 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: def search_chunks(chunks: "list[bytes]") -> dict:
"""Durchsucht Speicher-Chunks nach Credential-Patterns.""" """Search memory chunks for credential patterns."""
usernames = {} # value → count usernames = {} # value → count
passwords = {} passwords = {}
ips = {} ips = {}
@@ -256,12 +256,12 @@ def search_chunks(chunks: "list[bytes]") -> dict:
if i % 50 == 0 or i == total - 1: if i % 50 == 0 or i == total - 1:
pct = (i + 1) * 100 // total pct = (i + 1) * 100 // total
mb_done = sum(len(c) for c in chunks[:i+1]) / 1024 / 1024 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): for m in RE_USERNAME.finditer(chunk):
v = m.group().decode("ascii", errors="replace") v = m.group().decode("ascii", errors="replace")
usernames[v] = usernames.get(v, 0) + 1 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): for m in RE_USER_PASS_PROXIMITY.finditer(chunk):
pw = m.group(2).decode("ascii", errors="replace") pw = m.group(2).decode("ascii", errors="replace")
has_upper = any(c.isupper() for c in pw) 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): for m in RE_PASSWORD.finditer(chunk):
v = m.group().decode("ascii", errors="replace") 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_upper = sum(1 for c in v if c.isupper()) >= 2
has_lower = sum(1 for c in v if c.islower()) >= 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 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") v = m.group().decode("ascii")
device_ids[v] = device_ids.get(v, 0) + 1 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): def top(d, n=10):
return sorted(d.items(), key=lambda x: -x[1])[:n] 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 = [ SLICER_NAMES = [
@@ -359,95 +359,95 @@ def write_env(results: dict, env_path: str,
with open(env_path, "w") as f: with open(env_path, "w") as f:
f.writelines(lines) f.writelines(lines)
print(f"\n✓ Credentials in '{env_path}' gespeichert.") print(f"\n✓ Credentials saved to '{env_path}'.")
def main(): def main():
parser = argparse.ArgumentParser( 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", 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, 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, 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", 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() args = parser.parse_args()
# .env-Pfad bestimmen # Determine .env path
if args.env_file: if args.env_file:
env_path = args.env_file env_path = args.env_file
else: else:
env_path = os.path.join(os.path.dirname(__file__), "..", ".env") env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
env_path = os.path.normpath(env_path) env_path = os.path.normpath(env_path)
# Prozess finden # Find process
if args.pid: if args.pid:
pid, proc_name = args.pid, f"PID {args.pid}" pid, proc_name = args.pid, f"PID {args.pid}"
else: else:
print("[*] Suche AnycubicSlicer-Prozess ...") print("[*] Searching for AnycubicSlicer process ...")
pid, proc_name = find_slicer_pid() pid, proc_name = find_slicer_pid()
if not pid: if not pid:
print("✗ AnycubicSlicer nicht gefunden. Bitte den Slicer starten und " print("✗ AnycubicSlicer not found. Please start the slicer, connect it "
"mit dem Drucker verbinden, dann erneut ausführen.") "to the printer, then run this script again.")
sys.exit(1) sys.exit(1)
print(f"[*] Prozess gefunden: {proc_name} (PID {pid})") print(f"[*] Process found: {proc_name} (PID {pid})")
print(f"[*] Lese Prozess-Speicher ...") print(f"[*] Reading process memory ...")
try: try:
chunks = read_process(pid) chunks = read_process(pid)
except PermissionError as e: except PermissionError as e:
print(f"Zugriffsfehler: {e}") print(f"Permission error: {e}")
sys.exit(1) sys.exit(1)
total_mb = sum(len(c) for c in chunks) / 1024 / 1024 total_mb = sum(len(c) for c in chunks) / 1024 / 1024
print(f"[*] {len(chunks)} Speichersegmente gelesen ({total_mb:.1f} MB)") print(f"[*] {len(chunks)} memory segments read ({total_mb:.1f} MB)")
print(f"[*] Durchsuche nach Credentials ...") print(f"[*] Searching for credentials ...")
results = search_chunks(chunks) results = search_chunks(chunks)
# Ausgabe # Output
print("\n" + "="*55) print("\n" + "="*55)
print(" ERGEBNISSE") print(" RESULTS")
print("="*55) print("="*55)
def show(label, items, verbose): def show(label, items, verbose):
if not items: if not items:
print(f" {label:12s} — nicht gefunden") print(f" {label:12s} — not found")
return items[0][0] if items else "" return items[0][0] if items else ""
best = items[0][0] 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: if verbose and len(items) > 1:
for val, cnt in items[1:]: for val, cnt in items[1:]:
print(f" {'':12s} {val} (Treffer: {cnt})") print(f" {'':12s} {val} (matches: {cnt})")
return best return best
best_user = show("Username", results["usernames"], args.verbose) best_user = show("Username", results["usernames"], args.verbose)
best_pass = show("Password", results["passwords"], args.verbose) best_pass = show("Password", results["passwords"], args.verbose)
best_device = show("Device-ID", results["device_ids"], 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"] lan_ips = [(ip, cnt) for ip, cnt in results["ips"]
if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")] if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")]
if not lan_ips: if not lan_ips:
lan_ips = results["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) print("="*55)
if not best_user or not best_pass: if not best_user or not best_pass:
print("\nKeine vollständigen Credentials gefunden.") print("\nNo complete credentials found.")
print(" Stelle sicher dass der Slicer MIT dem Drucker verbunden ist.") print(" Make sure the slicer is connected to the printer.")
sys.exit(1) sys.exit(1)
if args.write_env: if args.write_env:
write_env(results, env_path, best_user, best_pass, best_ip, write_env(results, env_path, best_user, best_pass, best_ip,
device_id=best_device) device_id=best_device)
else: 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__": if __name__ == "__main__":

View File

@@ -198,6 +198,7 @@ class KobraXClient:
def _read_loop(self): def _read_loop(self):
last_ping = time.time() last_ping = time.time()
_empty_count = 0
while self._running: while self._running:
if time.time() - last_ping > 30: if time.time() - last_ping > 30:
with self._lock: with self._lock:
@@ -212,7 +213,12 @@ class KobraXClient:
try: try:
data = self._sock.recv(65536) data = self._sock.recv(65536)
if not data: if not data:
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
_empty_count += 1
if _empty_count >= 5:
raise ConnectionResetError("EOF") raise ConnectionResetError("EOF")
continue
_empty_count = 0
self._buf += data self._buf += data
self._drain() self._drain()
except ssl.SSLWantReadError: except ssl.SSLWantReadError:

View File

@@ -29,6 +29,19 @@ _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os
sys.path.insert(0, _BASE) sys.path.insert(0, _BASE)
from kobrax_client import KobraXClient 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: try:
from aiohttp import web from aiohttp import web
import aiohttp import aiohttp
@@ -445,17 +458,19 @@ class KobraXBridge:
}, status=201) }, status=201)
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0): def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0):
use_ams = len(self._ams_slots) > 0 loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
use_ams = len(loaded) > 0
ams_box_mapping = [ ams_box_mapping = [
{ {
"paint_index": i, "paint_index": i,
"ams_index": i, "ams_index": i,
"paint_color": [255, 255, 255, 255], "paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255], "ams_color": [255, 255, 255, 255],
"material_type": s.get("material_type", "PLA"), "material_type": s.get("type", "PLA"),
} }
for i, s in enumerate(self._ams_slots) for i, s in loaded
] ]
log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}")
payload = { payload = {
"taskid": "-1", "taskid": "-1",
"url": url, "url": url,
@@ -503,16 +518,19 @@ class KobraXBridge:
log.info(f"Druck starten: {filename}") log.info(f"Druck starten: {filename}")
# AMS-Mapping aus gecachtem State # AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen
ams_box_mapping = [] ams_box_mapping = []
for i, slot in enumerate(self._ams_slots): for i, slot in enumerate(self._ams_slots):
if slot.get("status") != 5:
log.info(f"AMS-Slot {i} leer (status={slot.get('status')}) übersprungen")
continue
ams_box_mapping.append({ ams_box_mapping.append({
"slot_index": i, "slot_index": i,
"material_type": slot.get("material_type", "PLA"), "material_type": slot.get("type", "PLA"),
"color": slot.get("color", "FFFFFF"), "color": slot.get("color", [255, 255, 255]),
}) })
use_ams = len(self._ams_slots) > 0 use_ams = len(ams_box_mapping) > 0
payload = { payload = {
"filename": filename, "filename": filename,
@@ -1031,17 +1049,17 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<button class="step-btn" onclick="setStep(this,10)">10 mm</button> <button class="step-btn" onclick="setStep(this,10)">10 mm</button>
</div> </div>
<div class="home-btns"> <div class="home-btns">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('X')"><span class="lbl-home-x">Home X</span></button> <button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('Y')"><span class="lbl-home-y">Home Y</span></button> <button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('Z')"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button> <button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div> <div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div>
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)"> <div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
<button class="joy" onclick="move(2,-1,getStep())" title="Z">▲</button> <button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▼</button> <button class="joy" onclick="move(2,-1,getStep())" title="Z">▼</button>
</div> </div>
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div> <div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
</div> </div>
@@ -1155,8 +1173,8 @@ 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)', 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', 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_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_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot',ams_empty:'Leer',
panel_extras_light:'Licht',panel_extras_fan:'Lüfter',panel_extras_camera:'Kamera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop', 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', panel_console_title:'Ereignis-Log',
log_light_on:'Licht an',log_light_off:'Licht aus',log_fan:'Lüfter →',log_nozzle:'Nozzle →',log_bed:'Bett →',log_axis:'Achse',log_home:'Home',log_home_all:'Home All',log_cam_start:'Kamera gestartet:',log_cam_stop:'Kamera gestoppt',log_poll_error:'Poll-Fehler:',log_error:'Fehler:', log_light_on:'Licht an',log_light_off:'Licht aus',log_fan:'Lüfter →',log_nozzle:'Nozzle →',log_bed:'Bett →',log_axis:'Achse',log_home:'Home',log_home_all:'Home All',log_cam_start:'Kamera gestartet:',log_cam_stop:'Kamera gestoppt',log_poll_error:'Poll-Fehler:',log_error:'Fehler:',
@@ -1181,8 +1199,8 @@ 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)', 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', 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_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_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot',ams_empty:'Empty',
panel_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop', 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', panel_console_title:'Event Log',
log_light_on:'Light on',log_light_off:'Light off',log_fan:'Fan →',log_nozzle:'Nozzle →',log_bed:'Bed →',log_axis:'Axis',log_home:'Home',log_home_all:'Home All',log_cam_start:'Camera started:',log_cam_stop:'Camera stopped',log_poll_error:'Poll error:',log_error:'Error:', log_light_on:'Light on',log_light_off:'Light off',log_fan:'Fan →',log_nozzle:'Nozzle →',log_bed:'Bed →',log_axis:'Axis',log_home:'Home',log_home_all:'Home All',log_cam_start:'Camera started:',log_cam_stop:'Camera stopped',log_poll_error:'Poll error:',log_error:'Error:',
@@ -1233,10 +1251,10 @@ function applyLang(){
// Axis labels // Axis labels
setText('ptitle-motion-xy',T.panel_motion_xy); setText('ptitle-motion-xy',T.panel_motion_xy);
setText('ptitle-motion-z',T.panel_motion_z); 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-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-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('.lbl-step').forEach(e=>e.textContent=T.label_step);
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':',''))); document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
// Console // Console
@@ -1375,13 +1393,14 @@ function applyState(){
if(s.ams_slots&&s.ams_slots.length){ if(s.ams_slots&&s.ams_slots.length){
var html=''; var html='';
s.ams_slots.forEach(function(slot,i){ s.ams_slots.forEach(function(slot,i){
var rgb=Array.isArray(slot.color)?slot.color:[128,128,128]; var empty=slot.status!==5;
var rgb=empty?[80,80,80]:(Array.isArray(slot.color)?slot.color:[128,128,128]);
var col='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')'; var col='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';
var active=slot.status===1||slot.active; var active=slot.status===1||slot.active;
var pct=slot.consumables_percent!=null?slot.consumables_percent+'%':''; var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'');
html+='<div class="ams-slot'+(active?' active':'')+ '" style="--slot-color:'+col+'">' html+='<div class="ams-slot'+(active?' active':'')+(empty?' empty':'')+ '" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+'">'
+'<div class="slot-circle" style="background:'+col+'"></div>' +'<div class="slot-circle" style="background:'+col+'"></div>'
+'<div class="slot-material">'+(slot.type||slot.material_type||'')+'</div>' +'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+'<div class="slot-label">Slot '+(slot.index!=null?slot.index+1:i+1)+'</div>' +'<div class="slot-label">Slot '+(slot.index!=null?slot.index+1:i+1)+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>' +'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+'</div>'; +'</div>';
@@ -1591,16 +1610,25 @@ function move(axis,dir,dist){
.catch(function(e){clog('Achse-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Achse-Fehler: '+e,'msg-err')});
} }
function homeAll(){ 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')}) .then(function(){clog('Home All','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
} }
function homeAxis(ax){ function homeXY(){
var m={X:1,Y:2,Z:3}; post('/api/axis',{axis:4,move_type:2,distance:0})
post('/api/axis',{axis:m[ax],move_type:2,distance:0}) .then(function(){clog('Home XY','msg-ok')})
.then(function(){clog('Home '+ax,'msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .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 ── // ── Temperature ──
function setNozzle(){ function setNozzle(){
@@ -1808,10 +1836,16 @@ function toggleCam(){if(camOn)camStop();else camStart()}
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
action = body.get("action", "move")
loop = asyncio.get_event_loop()
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)) axis = int(body.get("axis", 4))
move_type = int(body.get("move_type", 2)) move_type = int(body.get("move_type", 2))
distance = float(body.get("distance", 0)) distance = float(body.get("distance", 0))
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.publish( await loop.run_in_executor(None, lambda: self.client.publish(
"axis", "move", "axis", "move",
{"axis": axis, "move_type": move_type, "distance": distance}, {"axis": axis, "move_type": move_type, "distance": distance},
@@ -1880,14 +1914,6 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if not url: if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt") 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://") is_rtsp = url.lower().startswith("rtsp://")
ffmpeg_input_args = [ ffmpeg_input_args = [
"-fflags", "nobuffer", "-fflags", "nobuffer",
@@ -1896,11 +1922,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if is_rtsp: if is_rtsp:
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else: else:
# HTTP-FLV/HLS: braucht mehr Probe-Puffer für Container-Erkennung
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
# 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( proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-loglevel", "quiet", _find_ffmpeg(), "-loglevel", "quiet",
*ffmpeg_input_args, *ffmpeg_input_args,
"-i", url, "-i", url,
"-vf", "fps=15,scale=640:-1", "-vf", "fps=15,scale=640:-1",
@@ -1912,6 +1940,20 @@ function toggleCam(){if(camOn)camStop();else camStart()}
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL, 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"" buf = b""
try: try:
@@ -2107,7 +2149,12 @@ function toggleCam(){if(camOn)camStop();else camStart()}
def _restart_bridge(self): def _restart_bridge(self):
log.info("Bridge wird neu gestartet …") 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 ────────────────────────────────────────────────────────────── # ─── Update ──────────────────────────────────────────────────────────────
@@ -2544,6 +2591,10 @@ def main():
if args.printer_ip and ":" in args.printer_ip: if args.printer_ip and ":" in args.printer_ip:
args.printer_ip = args.printer_ip.split(":")[0] 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)) asyncio.run(run_bridge(args))

View File

@@ -1 +1,2 @@
aiohttp>=3.9 aiohttp>=3.9
imageio-ffmpeg>=0.4.9

View File

@@ -34,12 +34,12 @@ else
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0) print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
for f in Dockerfile \ for f in Dockerfile \
05_scripts/kobrax_moonraker_bridge.py \ bridge/kobrax_moonraker_bridge.py \
05_scripts/kobrax_client.py \ bridge/kobrax_client.py \
05_scripts/env_loader.py \ bridge/env_loader.py \
05_scripts/requirements.txt \ bridge/requirements.txt \
05_scripts/anycubic_slicer.crt \ bridge/anycubic_slicer.crt \
05_scripts/anycubic_slicer.key; do bridge/anycubic_slicer.key; do
if [[ -f "$f" ]]; then if [[ -f "$f" ]]; then
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0) FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
if [[ $FILE_TS -gt $IMAGE_TS ]]; then if [[ $FILE_TS -gt $IMAGE_TS ]]; then