Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen, toter Code entfernt, Requirements gepinnt.
333 lines
11 KiB
Python
333 lines
11 KiB
Python
#!/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())
|