#!/usr/bin/env python3 """Orca/Anycubic-Nachbearbeitung: G-code nach Grotti laden und Senden-Seite öffnen. Wenn SLIC3R_PP_OUTPUT_NAME unter dem Slice-Ordner liegt (auch Unterordner), wird die Datei dorthin geschrieben und der Watch neu gelesen — nicht als Upload. Sonst: Upload über die API. Config: data/orca-senden.json mit url und token (grt_…). """ from __future__ import annotations import json import mimetypes import os import shutil import sys import uuid import webbrowser from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen REPO_ROOT = Path(__file__).resolve().parent.parent CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json" DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log" ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp") def show_error(message: str) -> None: text = str(message or "Unbekannter Fehler.") if sys.platform == "win32": try: import ctypes ctypes.windll.user32.MessageBoxW(0, text, "Grotti · Orca senden", 0x10) return except Exception: pass print(text, file=sys.stderr) def write_debug(argv: list[str], extra: str = "") -> None: try: DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True) lines = [ "argv:", *[repr(item) for item in argv], "", "env:", "SLIC3R_PP_HOST=%s" % (os.environ.get("SLIC3R_PP_HOST") or ""), "SLIC3R_PP_OUTPUT_NAME=%s" % (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or ""), "", extra, ] DEBUG_LOG.write_text("\n".join(lines), encoding="utf-8") except OSError: pass def load_config(path: Path = CONFIG_PATH) -> dict[str, str]: if not path.is_file(): raise FileNotFoundError( "Config fehlt: %s\nLege url und token (Einstellungen → API-Token) an." % path ) try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError("Config ungültig: %s" % exc) from exc if not isinstance(data, dict): raise ValueError("Config muss ein JSON-Objekt sein.") url = str(data.get("url") or "").strip().rstrip("/") token = str(data.get("token") or "").strip() if not url: raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).") if not token: raise ValueError("Config: token fehlt (grt_… aus Einstellungen).") return {"url": url, "token": token} def is_gcode_path(path: Path) -> bool: name = path.name.lower() return any(name.endswith(suffix) for suffix in ALLOWED_SUFFIXES) def output_target() -> Path | None: env_name = (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or "").strip().strip('"') if not env_name: return None return Path(env_name) def upload_name_for(path: Path) -> str: """Schöner Dateiname: SLIC3R_PP_OUTPUT_NAME, sonst Pfad ohne .pp.""" target = output_target() if target is not None and target.name: base = target.name if not is_gcode_path(Path(base)) and path.name.lower().endswith(".pp"): if not base.lower().endswith((".gcode", ".3mf")): base = base + ".gcode" return base name = path.name if name.lower().endswith(".pp"): name = name[:-3] if not name.lower().endswith((".gcode", ".3mf")): name = name + ".gcode" return name def find_gcode_arg(argv: list[str]) -> Path: """Pfad aus argv oder SLIC3R_PP_OUTPUT_NAME; Temp-Dateien enden oft auf .pp.""" candidates: list[Path] = [] for raw in argv[1:]: text = (raw or "").strip().strip('"') if not text or text.startswith("-"): continue candidates.append(Path(text)) for candidate in reversed(candidates): if not is_gcode_path(candidate): continue if candidate.is_file(): return candidate.resolve() raise FileNotFoundError("Datei fehlt: %s" % candidate) for candidate in reversed(candidates): if candidate.is_file(): return candidate.resolve() env_path = output_target() if env_path is not None and env_path.is_file(): return env_path.resolve() detail = "argv=%s" % (argv[1:] or ["(leer)"]) raise ValueError( "Kein G-code-Pfad in den Argumenten.\n" "G-code exportieren (nicht nur slicen).\n" "%s\nDetails: data/orca-senden-last.log" % detail ) def send_page_url(base_url: str, file_id: int) -> str: return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id)) def _api_json( base_url: str, token: str, method: str, path: str, *, data: bytes | None = None, headers: dict[str, str] | None = None, timeout: float = 120.0, ) -> Any: hdrs = { "Authorization": "Bearer %s" % token, "Accept": "application/json", } if headers: hdrs.update(headers) request = Request( "%s/api/v1%s" % (base_url.rstrip("/"), path), data=data, method=method, headers=hdrs, ) try: with urlopen(request, timeout=timeout) as response: raw = response.read() if not raw: return None return json.loads(raw.decode("utf-8")) except HTTPError as exc: detail = "" try: detail = json.loads(exc.read().decode("utf-8")).get("error") or "" except Exception: detail = str(exc.reason or exc) raise RuntimeError("API %s fehlgeschlagen (%s): %s" % (path, exc.code, detail or exc)) from exc except URLError as exc: raise RuntimeError( "Grotti nicht erreichbar (%s). Läuft der Server?" % (exc.reason or exc) ) from exc def fetch_watch_dir(base_url: str, token: str) -> Path | None: payload = _api_json(base_url, token, "GET", "/einstellungen") if not isinstance(payload, dict): return None text = str(payload.get("watch_dir") or "").strip() if not text: return None path = Path(text) return path if path.is_dir() else None def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None: """(folder_relativ, dateiname) wenn target unter watch_dir liegt.""" try: rel = target.resolve().relative_to(watch_dir.resolve()) except ValueError: return None parts = rel.parts if not parts: return None name = parts[-1] folder = "/".join(parts[:-1]) if len(parts) > 1 else "" return folder, name def place_in_watch(src: Path, dest: Path) -> Path: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(str(src), str(dest)) return dest.resolve() def rescan(base_url: str, token: str) -> None: _api_json(base_url, token, "POST", "/dateien/neu-lesen", data=b"{}", headers={"Content-Type": "application/json"}) def find_library_file(base_url: str, token: str, *, name: str, folder: str) -> dict[str, Any] | None: rows = _api_json(base_url, token, "GET", "/dateien") if not isinstance(rows, list): return None folder_key = folder or "" matches = [] for row in rows: if not isinstance(row, dict): continue if (row.get("name") or "") != name: continue row_folder = row.get("folder") or "" if row.get("source") == "watch": if row_folder == folder_key or (not folder_key and row_folder in ("", None)): matches.append(row) elif not folder_key and row_folder in ("", "Upload"): matches.append(row) if not matches: # Name reicht, neueste ID matches = [row for row in rows if isinstance(row, dict) and (row.get("name") or "") == name] if not matches: return None return max(matches, key=lambda row: int(row.get("id") or 0)) def _multipart_body(filename: str, content: bytes, field: str = "file") -> tuple[bytes, str]: boundary = "----GrottiOrca%s" % uuid.uuid4().hex guessed = mimetypes.guess_type(filename)[0] or "application/octet-stream" head = ( "--%s\r\n" 'Content-Disposition: form-data; name="%s"; filename="%s"\r\n' "Content-Type: %s\r\n" "\r\n" ) % (boundary, field, filename.replace('"', ""), guessed) tail = "\r\n--%s--\r\n" % boundary body = head.encode("utf-8") + content + tail.encode("ascii") return body, "multipart/form-data; boundary=%s" % boundary def upload_file( base_url: str, token: str, path: Path, filename: str | None = None, timeout: float = 120.0, ) -> dict[str, Any]: content = path.read_bytes() name = filename or upload_name_for(path) body, content_type = _multipart_body(name, content) payload = _api_json( base_url, token, "POST", "/dateien", data=body, headers={"Content-Type": content_type}, timeout=timeout, ) if not isinstance(payload, dict) or "id" not in payload: raise RuntimeError("Unerwartete Antwort vom Upload.") return payload def open_browser(url: str) -> None: webbrowser.open(url) def deliver(base_url: str, token: str, src: Path) -> int: """Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück.""" watch = fetch_watch_dir(base_url, token) target = output_target() if watch is not None and target is not None: placed = under_watch(watch, target) if placed is not None: folder, name = placed dest = place_in_watch(src, target if target.suffix else target.with_suffix(".gcode")) # Name aus Zielpfad, falls ohne Endung if dest.name: name = dest.name rescan(base_url, token) found = find_library_file(base_url, token, name=name, folder=folder) if not found: raise RuntimeError( "Datei liegt unter %s, aber Grotti kennt sie noch nicht. " "Unter Dateien „Ordner neu lesen“." % dest ) return int(found["id"]) saved = upload_file(base_url, token, src, filename=upload_name_for(src)) return int(saved["id"]) def main(argv: list[str] | None = None) -> int: argv = list(sys.argv if argv is None else argv) write_debug(argv) try: config = load_config() path = find_gcode_arg(argv) write_debug( argv, "chosen=%s\nupload_as=%s\noutput=%s" % (path, upload_name_for(path), output_target() or ""), ) file_id = deliver(config["url"], config["token"], path) write_debug(argv, "file_id=%s" % file_id) open_browser(send_page_url(config["url"], file_id)) except Exception as exc: write_debug(argv, "error=%s" % exc) show_error(str(exc)) return 1 return 0 if __name__ == "__main__": sys.exit(main())