diff --git a/agent.py b/agent.py index 0e6fd6a..9809588 100644 --- a/agent.py +++ b/agent.py @@ -197,8 +197,10 @@ def main(): # „Printing" (Job war sehr kurz), gilt er als fertig – verhindert Deadlock. start_grace = max(60, poll * 6) - # Kontext des aktuell an die Maschine gesendeten Jobs (oder None). Der - # nächste Job wird ERST gesendet, wenn die Maschine wieder „Idle" ist – + # Kontext des aktuell an die Maschine gesendeten Batches (oder None): + # {"contexts": [ctx, …], "gestartet": bool, "ts": monotonic}. Ein Batch fasst + # ALLE queued-Jobs gleichen Typs+Templates zu EINEM Durchlauf zusammen. Der + # nächste Batch wird ERST gesendet, wenn die Maschine wieder „Idle" ist – # sonst würde ein neuer write_svg (clear=1) den laufenden Druck verwerfen. laufend = None @@ -254,20 +256,24 @@ def main(): # das „Printing" nie zu Gesicht bekommen. if laufend is not None: if laufend.get("gestartet") or (time.monotonic() - laufend["ts"] > start_grace): - _abschluss(dx, laufend) + for ctx in laufend["contexts"]: + _abschluss(dx, ctx) laufend = None else: time.sleep(poll) continue - # Nächsten Job nur senden, wenn nichts (mehr) läuft. + # Nichts läuft → alle queued-Jobs des aktuellen Formats holen und ALLE + # vom selben Typ (gleiches Template) als EINEN Durchlauf drucken. Danach + # holt die nächste Runde automatisch den Rest desselben Typs (falls noch + # queued) bzw. den nächsten Typ. if laufend is None: current_format = (maschine.get("current_format") or "").strip() # Format-Warteschlange: nur passendes Format, kein Auto-Anschluss. if current_format: - job = _naechster_job(dx, machine_id, current_format) - if job: - laufend = _sende(dx, plotter, job, KIND) + jobs = _naechste_jobs(dx, machine_id, current_format) + if jobs: + laufend = _sende_batch(dx, plotter, _batch_gleicher_typ(jobs), KIND) time.sleep(poll) @@ -286,8 +292,10 @@ def _heartbeat(dx, machine_id, status): pass -def _naechster_job(dx, machine_id, current_format): - jobs = dx.get( +def _naechste_jobs(dx, machine_id, current_format): + """ALLE queued-Jobs der Maschine im aktuellen Format (sortiert nach Priorität, + dann Alter). Die Auswahl fürs gemeinsame Drucken trifft _batch_gleicher_typ.""" + return dx.get( "/items/jobs", **{ "filter[machine][_eq]": machine_id, @@ -295,80 +303,133 @@ def _naechster_job(dx, machine_id, current_format): # Nur Jobs des aktuell eingelegten Formats. "filter[format][_eq]": current_format, "sort": "-priority,date_created", - "limit": 1, + # Praktisch „alle" – nur eine Obergrenze, damit die Abfrage begrenzt bleibt. + "limit": 500, "fields": "id,type,output_ref,params,numbers," "order.id,order.order_number,order.needs_envelope," "template.width,template.height," "template.xpos,template.ypos,template.scale,template.rotation", }, - ) - return jobs[0] if jobs else None + ) or [] -def _sende(dx, plotter, job, KIND): - """Sendet einen Job an die Maschine und markiert ihn als „printing". - - Gibt einen Kontext zum späteren Abschluss zurück (oder None bei Fehler). - Es wird NICHT auf das Druckende gewartet – das übernimmt die Hauptschleife - (erst wenn die Maschine wieder „Idle" ist, wird der nächste Job gesendet). - """ - job_id = job["id"] - order = job.get("order") or {} - order_id = order.get("id") - # Ordner: entweder Auftragsnummer oder ein output_ref (z. B. Testdruck). - order_nr = job.get("output_ref") or order.get("order_number") +def _effektives_template(job): + """Template des Jobs inkl. Pro-Druck-Übersteuerung (params) aus dem Popup.""" tpl = dict(job.get("template") or {}) - # Pro-Druck-Übersteuerung der Maße (aus dem Druck-Popup) hat Vorrang. params = job.get("params") if isinstance(params, dict): for k in ("width", "height", "xpos", "ypos", "scale", "rotation"): if params.get(k) is not None: tpl[k] = params[k] - nummern = parse_nummern(job.get("numbers")) + return tpl + + +def _template_sig(job): + t = _effektives_template(job) + return tuple(t.get(k) for k in ("width", "height", "xpos", "ypos", "scale", "rotation")) + + +def _batch_gleicher_typ(jobs): + """Aus der sortierten queued-Liste den Kopf-Job nehmen und ALLE queued-Jobs + gleichen Typs UND gleichen Templates dazu sammeln – ein Format hat i. d. R. + genau ein Template, also faktisch „alles von einem Typ auf einmal". Jobs mit + abweichendem Template laufen im nächsten Durchgang. Reihenfolge (Priorität/ + Alter) bleibt erhalten.""" + kopf = jobs[0] + typ = kopf.get("type") or "brief" + sig = _template_sig(kopf) + return [j for j in jobs + if (j.get("type") or "brief") == typ and _template_sig(j) == sig] + + +def _job_dateien(dx, job, KIND): + """Lädt die zu druckenden SVGs eines Jobs in Stapel-Reihenfolge → [(name, bytes)].""" + order = job.get("order") or {} + # Ordner: entweder Auftragsnummer oder ein output_ref (z. B. Testdruck). + order_nr = job.get("output_ref") or order.get("order_number") + if not order_nr: + raise RuntimeError("Auftragsnummer fehlt am Job.") typ = job.get("type") or "brief" - print(f"[Agent] Job {job_id}: {typ} für {order_nr} → an Maschine senden") + liste = dx.get_raw(f"/skrift-orders/files/{order_nr}") or {} + gewuenscht = KIND.get(typ, "schriftstueck") + passende = sorted( + [f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht], + key=lambda f: f.get("name", ""), + ) + # Nur bestimmte Briefnummern drucken, falls im Job angegeben. + # Positionsbasiert (1 = erstes Dokument), NICHT über die Zahl im + # Dateinamen: Alt-Aufträge sind 0-basiert (letter_000.svg), neue + # 1-basiert (letter_001.svg). Die Position stimmt in beiden Fällen. + nummern = parse_nummern(job.get("numbers")) + if nummern is not None: + passende = [f for i, f in enumerate(passende, start=1) if i in nummern] + if not passende: + raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.") - dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None}) - # Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck". - if order_id: - try: dx.patch(f"/items/orders/{order_id}", {"production_status": "im_druck"}) - except Exception: pass - try: - if not order_nr: - raise RuntimeError("Auftragsnummer fehlt am Job.") - liste = dx.get_raw(f"/skrift-orders/files/{order_nr}") or {} - gewuenscht = KIND.get(typ, "schriftstueck") - passende = sorted( - [f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht], - key=lambda f: f.get("name", ""), - ) - # Nur bestimmte Briefnummern drucken, falls im Job angegeben. - # Positionsbasiert (1 = erstes Dokument), NICHT über die Zahl im - # Dateinamen: Alt-Aufträge sind 0-basiert (letter_000.svg), neue - # 1-basiert (letter_001.svg). Die Position stimmt in beiden Fällen. - if nummern is not None: - passende = [f for i, f in enumerate(passende, start=1) if i in nummern] - if not passende: - raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.") + # Mehrseitige Briefe: Seiten je Brief absteigend an den Plotter (Stapel- + # Reihenfolge, damit Seite 1 am Ende oben liegt). Einseitige unverändert. + passende = seiten_reihenfolge(passende) + dateien = [] + for f in passende: + inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]}) + dateien.append((f["name"], inhalt)) + return dateien - # Mehrseitige Briefe: Seiten je Brief absteigend an den Plotter (Stapel- - # Reihenfolge, damit Seite 1 am Ende oben liegt). Einseitige unverändert. - passende = seiten_reihenfolge(passende) - dateien = [] - for f in passende: - inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]}) - dateien.append((f["name"], inhalt)) +def _sende_batch(dx, plotter, jobs, KIND): + """Sendet mehrere gleichartige Jobs als EINEN Druckdurchlauf an die Maschine. - plotter.login() - plotter.write(dateien, tpl) - print(f"[Agent] Job {job_id} an Maschine übergeben ({len(dateien)} Dateien) – warte auf Fertigstellung.") - return {"job_id": job_id, "order_id": order_id, "order_nr": order_nr, - "typ": typ, "gestartet": False, "ts": time.monotonic()} - except Exception as e: - dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]}) - print(f"[Agent] Job {job_id} fehlgeschlagen: {e}") + Alle Jobs teilen Typ und Template (siehe _batch_gleicher_typ). Ihre Dokumente + werden nacheinander in EINEM Lauf gedruckt (clear nur am Anfang, start am Ende) – + so hält die Maschine zwischen den Aufträgen nicht an. Ein einzelner Job, dessen + Dateien fehlen/fehlerhaft sind, wird als „failed" markiert und übersprungen; der + Rest wird trotzdem gedruckt. Gibt den Batch-Kontext zurück (oder None, wenn nichts + Druckbares übrig bleibt). Es wird NICHT auf das Druckende gewartet – das übernimmt + die Hauptschleife (nächster Batch erst bei „Idle"). + """ + contexts = [] + dateien = [] + tpl_final = None + for job in jobs: + job_id = job["id"] + order = job.get("order") or {} + order_id = order.get("id") + order_nr = job.get("output_ref") or order.get("order_number") + typ = job.get("type") or "brief" + dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None}) + # Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck". + if order_id: + try: dx.patch(f"/items/orders/{order_id}", {"production_status": "im_druck"}) + except Exception: pass + try: + job_dateien = _job_dateien(dx, job, KIND) + except Exception as e: + dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]}) + print(f"[Agent] Job {job_id} fehlgeschlagen: {e}") + continue + if tpl_final is None: + tpl_final = _effektives_template(job) + dateien.extend(job_dateien) + contexts.append({"job_id": job_id, "order_id": order_id, + "order_nr": order_nr, "typ": typ}) + print(f"[Agent] Job {job_id}: {typ} für {order_nr} ({len(job_dateien)} Dateien) → Batch") + + if not dateien: return None + try: + plotter.login() + plotter.write(dateien, tpl_final) + except Exception as e: + # Ganzer Durchlauf fehlgeschlagen → alle beteiligten Jobs auf „failed". + for ctx in contexts: + try: dx.patch(f"/items/jobs/{ctx['job_id']}", {"status": "failed", "error": str(e)[:1000]}) + except Exception: pass + print(f"[Agent] Batch-Druck fehlgeschlagen: {e}") + return None + + print(f"[Agent] Batch an Maschine übergeben: {len(contexts)} Job(s), " + f"{len(dateien)} Dateien – warte auf Fertigstellung.") + return {"contexts": contexts, "gestartet": False, "ts": time.monotonic()} def _abschluss(dx, ctx): diff --git a/neustart_agent.bat b/neustart_agent.bat new file mode 100644 index 0000000..900ef42 --- /dev/null +++ b/neustart_agent.bat @@ -0,0 +1,26 @@ +@echo off +rem =================================================================== +rem Skrift-Agent neu starten (zum Doppelklicken). +rem 1) laufende agent.py-Instanz(en) beenden +rem 2) fensterlos frisch starten (ueber run_agent.vbs) +rem =================================================================== +cd /d "%~dp0" + +echo Beende laufende Agent-Instanz(en)... +powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*agent.py*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" + +rem kurz warten, damit der Prozess wirklich weg ist (ping statt timeout: braucht kein stdin) +"%SystemRoot%\System32\ping.exe" -n 2 127.0.0.1 >nul + +echo ##### NEUSTART #####>> "logs\agent.log" + +echo Starte Agent neu... +wscript.exe "%~dp0run_agent.vbs" + +rem kurz warten und pruefen, ob wieder eine Instanz laeuft +"%SystemRoot%\System32\ping.exe" -n 5 127.0.0.1 >nul +powershell -NoProfile -Command "$n = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*agent.py*' }).Count; if ($n -gt 0) { Write-Host 'OK: Agent laeuft wieder.' -ForegroundColor Green } else { Write-Host 'WARNUNG: Keine Instanz gefunden - Log pruefen.' -ForegroundColor Red }" + +echo. +echo Fenster schliesst sich in 3 Sekunden... +"%SystemRoot%\System32\ping.exe" -n 4 127.0.0.1 >nul