Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.

Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
This commit is contained in:
Dominic Gundel committed 2026-11-02 14:03:57 +01:00
1 parent 5081212212
commit db6a85a703
54 files changed
+3379 -384

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Empfänger für Grotti-Meldungen → Issue auf Forgejo/Gitea.
Läuft getrennt von den Installationen. Der Token bleibt hier.
export FORGEJO_URL=https://git.gundel.dev
export FORGEJO_TOKEN=...
export FORGEJO_REPO=dg/grotti
python scripts/meldung_relay.py
Port über RELAY_PORT (Standard 8780). Vor Forgejo mit Reverse-Proxy unter
/hooks/grotti-meldung legen.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def env(name, default=""):
return (os.environ.get(name) or default).strip()
def create_issue(title, body):
base = env("FORGEJO_URL", "https://git.gundel.dev").rstrip("/")
repo = env("FORGEJO_REPO", "dg/grotti")
token = env("FORGEJO_TOKEN")
if not token:
raise RuntimeError("FORGEJO_TOKEN fehlt.")
url = "%s/api/v1/repos/%s/issues" % (base, repo)
payload = json.dumps({"title": title[:120], "body": body}).encode("utf-8")
request = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": "token %s" % token,
},
method="POST",
)
with urllib.request.urlopen(request, timeout=20) as response:
data = json.loads(response.read().decode("utf-8"))
return data.get("html_url") or data.get("url") or ""
def issue_body(data):
lines = [
data.get("text") or "",
"",
"---",
"Version: %s" % (data.get("version") or "—"),
"Seite: %s" % (data.get("page") or "—"),
"Drucker: %s" % (data.get("printer_host") or "—"),
]
err = (data.get("error_text") or "").strip()
if err:
lines.extend(["", "Fehlertext:", "```", err[:2000], "```"])
return "\n".join(lines)
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def do_GET(self):
if self.path.split("?", 1)[0] in ("/", "/gesundheit", "/hooks/grotti-meldung"):
self._json(200, {"ok": True})
return
self._json(404, {"message": "Unbekannt."})
def do_POST(self):
path = self.path.split("?", 1)[0]
if path not in ("/", "/hooks/grotti-meldung"):
self._json(404, {"message": "Unbekannt."})
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError):
self._json(400, {"message": "Kein JSON."})
return
if not isinstance(data, dict) or not (data.get("text") or "").strip():
self._json(400, {"message": "text fehlt."})
return
title = "Grotti: %s" % ((data.get("text") or "").strip().splitlines()[0][:80])
try:
url = create_issue(title, issue_body(data))
except Exception as exc:
self._json(502, {"message": str(exc)})
return
self._json(201, {"ok": True, "url": url})
def _json(self, code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
port = int(env("RELAY_PORT", "8780") or 8780)
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
httpd.serve_forever()
if __name__ == "__main__":
main()
+25 -6
View File
@@ -94,7 +94,14 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
browser = str(data.get("browser") or "app").strip().lower() or "app"
if browser not in ("app", "tab"):
browser = "app"
return {"url": url, "token": token, "browser": browser}
# Windows-Pfad des Slice-Ordners, wenn Grotti auf dem Pi läuft (dort anderer Pfad)
watch_local = str(data.get("watch_dir_local") or data.get("watch_dir") or "").strip()
return {
"url": url,
"token": token,
"browser": browser,
"watch_dir_local": watch_local,
}
def is_gcode_path(path: Path) -> bool:
@@ -287,6 +294,16 @@ def fetch_watch_dir(base_url: str, token: str) -> Path | None:
return path if path.is_dir() else None
def resolve_watch_dir(config: dict) -> Path | None:
"""Lokaler Slice-Ordner: Config zuerst, sonst der Pfad von Grotti (gleiche Maschine)."""
local = str(config.get("watch_dir_local") or "").strip()
if local:
path = Path(local)
if path.is_dir():
return path
return fetch_watch_dir(config["url"], config["token"])
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
try:
@@ -450,9 +467,11 @@ def open_browser(
webbrowser.open(url)
def deliver(base_url: str, token: str, src: Path) -> int:
def deliver(config: dict, src: Path) -> int:
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
watch = fetch_watch_dir(base_url, token)
base_url = config["url"]
token = config["token"]
watch = resolve_watch_dir(config)
target = output_target()
if watch is not None and target is not None:
placed = under_watch(watch, target)
@@ -482,10 +501,10 @@ def main(argv: list[str] | None = None) -> int:
path = find_gcode_arg(argv)
write_debug(
argv,
"chosen=%s\nupload_as=%s\noutput=%s"
% (path, upload_name_for(path), output_target() or ""),
"chosen=%s\nupload_as=%s\noutput=%s\nwatch_local=%s"
% (path, upload_name_for(path), output_target() or "", config.get("watch_dir_local") or ""),
)
file_id = deliver(config["url"], config["token"], path)
file_id = deliver(config, path)
name = upload_name_for(path)
write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
mode = str(config.get("browser") or "app").strip().lower()