diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index d9c97a3..68f255e 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -658,178 +658,279 @@ }, "calibration": { "ace_unit": "ACE-Einheit", - "allow_load": "Zuschlag Laden", - "allow_splitter": "Zuschlag Verteiler", - "anchor_head": "Anker Kopf", - "anchor_splitter": "Anker Verteiler", - "anchors_none": "Für diese Bahn sind noch keine Ankerwerte gemessen.", - "apply_config": "In die Config übernehmen", - "available": "Verfügbar", - "baseline_ok": "Sauberer Ausgangszustand bestätigt", - "baseline_wait": "Sauberer Ausgangszustand noch nicht bestätigt", + "anchor_head": "Kopf", + "anchor_splitter": "Freigabepunkt Verteiler", + "anchors_none": "Dieser Weg wurde noch nicht gemessen.", + "apply_config": "In die Konfiguration speichern", + "at_park_hint": "Zurück in der Parkposition, die Spitze ist also weit vor dem Verteiler. Fahr Richtung Kopf und stoppe mit der Spitze kurz vor dem Verteiler auf der ACE-Seite, nicht darin, dann markieren.", "blk_ace_off": "Die gewählte ACE ist nicht verbunden.", "blk_busy": "Warten, bis der laufende Filament-Vorgang fertig ist.", - "blk_head_loaded": "Den zugeordneten Kopf zuerst entladen.", - "blk_no_values": "Für diese Bahn zuerst eine Messung speichern.", + "blk_head_loaded": "Entlade zuerst den Kopf.", + "blk_no_values": "Miss diesen Weg zuerst.", "blk_normal_mode": "multiACE aus dem Normal-Modus holen.", "blk_not_ace_head": "Einen ACE-geführten Kopf wählen.", + "blk_occupied": "Es steckt noch Kalibrier-Filament im Schlauch. Hol es zuerst zum ACE zurück.", "blk_printing": "Warten, bis der Drucker im Leerlauf ist.", "blk_sensor_na": "Der Sensor des zugeordneten Kopfs ist nicht verfügbar.", - "blk_session": "Die laufende Messung zuerst beenden oder abbrechen.", - "blk_slot_ready": "Den gewählten Slot auf seine Einzugsposition zurückbringen.", - "calibrate_route": "Diese Bahn messen", - "chk_ace_conn": "Gewählte ACE ist verbunden", - "chk_idle": "Drucker im Leerlauf", - "chk_no_bg": "Kein Wechsel und kein Hintergrund-Vorgang", - "chk_no_move": "Keine andere Bahn in Bewegung", - "chk_queue_idle": "Drucker und Filament-Warteschlange im Leerlauf", - "chk_route_ok": "Einzugsposition und physische Bahn bestätigt", - "chk_slot_ready": "Gewählter Slot steht auf seiner Einzugsposition", - "col_config": "Config-Wert", + "blk_session": "Schließe zuerst den offenen Lauf ab.", + "blk_slot_ready": "Der gewählte Slot hält kein Filament an der Parkposition.", + "calibrate_route": "Diesen Weg messen", + "cancel_run": "Diesen Lauf abbrechen", + "cap_text": "Sie kann nicht länger sein als der volle Rückzug. Auf diesem Weg sitzt der Verteiler nah am ACE, ein Wechsel zieht das Filament also fast ganz zurück.", + "cap_title": "Verteiler-Freigabe begrenzt.", + "chk_cold": "Es wird nichts geheizt oder extrudiert", + "chk_head_clear": "Kopfsensor T{head} ist frei", + "chk_head_loaded": "T{head} hält noch Filament", + "chk_idle": "Drucker ist im Leerlauf", + "chk_no_move": "Kein anderes Filament in Bewegung", + "chk_queue_idle": "Drucker im Leerlauf und kein anderes Filament in Bewegung", + "chk_slot_ready": "Slot {slot} hält Filament an der Parkposition", + "chk_tube_empty": "Kein Kalibrier-Filament mehr im Schlauch", + "clear": "Gespeicherte Werte löschen", + "clear_affected_many": "Die Slots {slots} nutzen diese Werte und werden Nicht kalibriert.", + "clear_affected_none": "Jeder Slot von ACE {ace} hat eigene Werte, kein Weg ändert sich.", + "clear_affected_one": "Slot {slots} nutzt diese Werte und wird Nicht kalibriert.", + "clear_after": "Nach dem Löschen", + "clear_blk_moving": "ACE {ace} bewegt sich. Löschen geht, sobald er steht.", + "clear_blk_none_ace": "Für ACE {ace} sind keine gemeinsamen Werte gespeichert.", + "clear_blk_none_slot": "Für diesen Slot sind keine Werte gespeichert.", + "clear_confirm": "Werte löschen", + "clear_done_ace": "Die gemeinsamen Werte von ACE {ace} wurden gelöscht.", + "clear_done_slot": "Die eigenen Werte von Slot {slot} wurden gelöscht.", + "clear_falls_ace": "Der Weg nutzt dann die gemeinsamen Werte von ACE {ace} und bleibt Kalibriert.", + "clear_falls_default": "Der Weg nutzt dann den globalen Standard und wird Nicht kalibriert.", + "clear_filament_out": "Das Filament ist etwa {mm} mm aus dem ACE heraus.", + "clear_filament_out_hint": "Es bleibt im Schlauch. Filament zum ACE zurückholen wird für diesen Weg danach weiter angeboten.", + "clear_keeps_own": "Slots mit eigenen Werten behalten sie: {slots}.", + "clear_list_and": "{rest} und {last}", + "clear_restart": "Wirkt nach dem nächsten Neustart des Druckers, wie beim Speichern. Die alte Konfiguration bleibt als Sicherung erhalten.", + "clear_run_reset": "Der offene Kalibrierlauf für diesen Weg wird verworfen.", + "clear_run_reset_results": "Die Ergebnisse dieses Laufs werden ebenfalls verworfen.", + "clear_scope_label": "Werte löschen von", + "clear_src_ace": "ACE {ace} gemeinsam", + "clear_src_default": "globaler Standard", + "clear_title_ace": "Gemeinsame Werte von ACE {ace} löschen?", + "clear_title_slot": "Eigene Werte von Slot {slot} löschen?", + "clear_undo": "Rückgängig", + "clear_undo_done": "Vorherige Werte wiederhergestellt.", + "clear_undo_done_hint": "Sie wirken nach dem nächsten Neustart des Druckers.", + "clear_undo_hint": "Wirkt nach dem nächsten Neustart des Druckers. Rückgängig speichert die alten Werte zurück.", + "col_config": "Wert", + "col_margin": "Zuschlag", "col_measured": "Gemessen", - "col_prev": "Vorher", - "col_proposed": "Vorschlag", - "commanded": "Kommandiert", - "conf_encoder": "Vom Geber gemessen", - "conf_encoder_hint": "Die Geberbewegung dient der Diagnose; die eingetragenen Millimeter stammen aus der begrenzten kommandierten Strecke.", - "conf_estimate": "Geschätzte Strecke", - "conf_estimate_hint": "Die ACE Pro liefert keine Geberdaten. Vor dem Übernehmen wiederholen und mit dem Auge prüfen.", - "confirm_route": "Einzugsposition übernehmen und Bahn bestätigen", - "continue_active": "Aktiven Ablauf fortsetzen", - "cum_retract": "Rückzug gesamt", - "decoder_span": "Geberweg", - "discard": "Verwerfen / neue Sitzung", - "done_busy": "Ein anderer Ablauf ist aktiv", - "done_busy_hint": "Mach im aktiven Ablauf weiter, das ist der sichere Weg.", - "done_ready": "Messwerte liegen vor", - "done_ready_hint": "Sieh dir die Werte unter Ergebnisse an oder prüfe die Bahn erneut.", - "err_head_not_clear": "Der Sensor des gewählten Kopfs meldet nicht mehr frei. Entlade den Kopf, bevor du den Ausgangszustand bestätigst.", + "col_prev": "Jetzt gespeichert", + "col_proposed": "Wird gespeichert", + "continue_active": "Zu Prüfen", + "dir_ace": "ACE", + "dir_head": "Kopf", + "discard": "Ergebnis verwerfen", + "discard_run": "Diesen Lauf verwerfen", + "done_busy": "Eine Prüfung läuft", + "done_busy_hint": "Mach im Reiter Prüfen weiter.", + "done_ready": "Messung abgeschlossen", + "done_ready_hint": "Sieh dir die Werte unter Ergebnis an oder prüfe den Weg.", + "drag_hint": "Zieh die Markierung, um das Filament zu bewegen; es fährt, wenn du loslässt. Für kleine Korrekturen die mm-Schritte nutzen.", + "drag_moving": "Fährt {mm} mm Richtung {dir} · noch {left} mm", + "drag_release": "Loslassen fährt {mm} mm Richtung {dir}", + "drag_small": "Zu klein für eine Bewegung", "err_incomplete": "Zuerst alle Messpunkte der Kalibrierung abschließen.", "err_no_section": "Im Config-Formular gibt es keinen passenden ACE-Abschnitt.", "err_prep_cancelled": "Das Entladen der Vorbereitung wurde abgebrochen. Prüfe die Lage des Filaments, bevor du einen neuen Ausgangszustand festlegst.", "err_prep_unclear": "Die Vorbereitung hat keinen eindeutig leeren Kopf ergeben. Kläre den belegten oder unbekannten Sensor und versuche es erneut.", - "err_retract_small": "Der volle Rückzug muss mindestens dem Wechsel-Punkt plus Zuschlag entsprechen.", + "err_retract_small": "Der volle Rückzug muss länger sein als die Verteiler-Freigabe.", "err_save": "Speichern der Config fehlgeschlagen.", "err_stale_klipper": "Klipper führt noch den alten Kalibrier-Code aus. Starte Klipper oder den Drucker neu und prüfe dann erneut.", - "final_pos": "Endposition", - "go_calibrate": "Weiter zur Messung", - "go_verify": "Weiter zur Prüfung", - "have_values": "Für diese Bahn liegen bereits Messwerte vor", - "have_values_hint": "Ein erneutes Messen ist freiwillig. Prüfe zuerst die gespeicherte Bahn, außer es hat sich am Schlauch, am Verteiler oder am Kopf etwas geändert.", - "head_label": "Verbundener Kopf", + "fed": "Bisher gefördert", + "g_clearance_text": "Wie weit vom Kopf zurückgezogen wird, damit die Spitze aus dem Verteiler ist und ein anderer Slot durchkann.", + "g_hub_text": "Die Stelle, an der die Schläuche aller Slots in den einen Schlauch zum Kopf münden.", + "g_margin": "Zuschlag", + "g_margin_text": "Zusätzliche Strecke, die beim Speichern auf einen Messwert aufgeschlagen wird.", + "g_park": "Parkposition", + "g_park_text": "Dort hält der ACE das Filament, nachdem er es selbst eingezogen hat. Alle Strecken zählen von hier.", + "g_toolhead_text": "Parkposition bis Kopfsensor. Gilt auch als voller Rückzug.", + "glossary": "Was bedeuten diese Begriffe?", + "go_route": "Mit diesem Weg weiter", + "have_values": "Dieser Weg ist bereits kalibriert", + "have_values_hint": "Prüfe ihn zuerst. Miss nur neu, wenn sich ein Schlauch, der Verteiler oder der Kopf geändert hat.", + "head_ace": "vom ACE versorgt", + "head_feeder": "Feeder · nicht am ACE", + "head_label": "Kopf", + "head_manual": "manuell · nicht am ACE", + "hub_added": "Zur Verteiler-Freigabe addiert", + "hub_more": "Weiter zurückziehen", + "hub_no": "Nicht frei — weiter zurückziehen", + "hub_saved": "Verteiler (gespeichert)", + "hub_yes": "Spitze ist frei — zurück zur Parkposition", + "hub_yes_now": "Jetzt frei — zurück zur Parkposition", "keep_saved": "Gespeicherte Werte behalten", - "load_distance": "Ladestrecke", - "m_park": "Rückweg zur Parkstellung", - "m_splitter": "Rückzug bis Verteiler", - "m_toolhead": "Strecke zum Kopf", - "manual_correction": "Endpunkt von Hand korrigieren", - "mark_splitter": "Verteiler als frei markieren", - "marked_hint": "Der Verteiler-Punkt ist gespeichert. Fahre die restliche gemessene Strecke zurück zur Einzugsposition.", + "m_splitter": "Verteiler-Freigabe", + "m_splitter_sub": "Rückzug vom Kopf, Zuschlag eingerechnet", + "m_toolhead": "Kopfstrecke", + "m_toolhead_sub": "Parkposition bis Kopfsensor", + "manual_correction": "Kleine Schritte bis zum Kopfsensor", + "margin_included": "enthalten", + "margin_none": "keiner", + "mark_splitter": "Hier markieren und parken", + "mark_splitter_hint": "Markieren, wenn die Spitze aus dem Verteiler ist, im eigenen Bowdenschlauch zur ACE.", + "marked_at": "Verteiler-Freigabe bei {mm} mm markiert – gespeichert wird unter Ergebnis.", + "measure_head": "Diesen Weg messen", + "measure_hint": "Das Filament wird kalt bis zum Kopfsensor gefördert, du ziehst es zurück, bis es aus dem Verteiler ist, und es fährt von selbst in den ACE zurück.", + "next_route": "Nächsten Slot oder ACE kalibrieren", "no_ace": "Keine verbundene ACE verfügbar.", - "no_heat_hint": "Die Messung heizt nicht und extrudiert nicht. Entferne loses Filament aus Verteiler und Ausgangsschlauch und stelle sicher, dass nur der gewählte Slot in diese Bahn führt.", - "none_yet": "Für diese Bahn gibt es weder gespeicherte Werte noch Ergebnisse aus dieser Sitzung.", - "r_hint": "Sieh dir die Werte der aktiven Bahn an und entscheide, ob du prüfst, neu misst oder speicherst.", - "r_none_session": "In dieser Sitzung gibt es weder ein neues Mess- noch ein Prüfergebnis. Die hinterlegte Bahn kann weiterhin geprüft werden.", - "r_passed": "Prüfung bestanden", - "r_retract_note": "Der volle Rückzug ergibt sich daraus, dass dieselbe gemessene Strecke zurückgefahren wird, die bis zum Kopfsensor nötig war. Wiederhole die Messung, wenn du die Wiederholgenauigkeit des Einzugs vorher vergleichen willst.", - "r_title": "Messergebnisse", - "r_verified_note": "Durchgehende Prüfrunde abgeschlossen und zur Einzugsposition zurückgekehrt.", - "recalibrate": "Bahn neu messen", - "reset_session": "Sitzung zurücksetzen", - "retract_hint": "In Schritten zurückziehen, bis die Spitze den Verteiler vollständig verlassen hat, dann markieren.", - "return_hint": "Automatische Rückkehr auf die gemessene Einzugsposition. In die ACE muss dafür niemand hineinsehen.", - "return_preload": "Zurück zur Einzugsposition", - "review_results": "Ergebnisse ansehen", - "review_route": "Bahn ansehen", - "route_known": "Bahn bekannt", - "route_needs": "Messung nötig", - "safety_limit": "Sicherheitsgrenze", - "saved_note": "Mit Sicherung gespeichert. Für die neuen Werte den Drucker neu starten.", - "saved_title": "Gespeicherte Messung", - "saved_values": "Gespeicherte Werte", - "scope_ace": "Die ganze ACE", - "scope_ace_hint": "Gemeinsame Vorgabe für alle Slots dieser ACE.", - "scope_ace_short": "Vorgaben der ganzen ACE", - "scope_label": "Messung gilt für", - "scope_slot": "Einzelner Bowden", - "scope_slot_hint": "Gilt nur für die Bahn des gewählten Slots.", - "scope_slot_short": "Nur dieser Slot", + "none_yet": "Für diesen Weg wurde noch nichts gemessen oder gespeichert.", + "override_note": "Slots mit eigenen Werten behalten sie: {slots}. Speichern für den ganzen ACE ändert diese nicht.", + "park_sub": "Parkposition", + "pulled_back": "Zurückgezogen", + "pulled_back_sub": "ab Kopfsensor", + "r_confirmed": "Weg geprüft und geparkt. Kopfsensor {mm} mm neben der gespeicherten Strecke erreicht; Verteiler-Freigabe bestätigt.", + "r_head_changed": "Die Prüfung hat die Werte geändert", + "r_head_measured": "Messung abgeschlossen", + "r_head_none": "Nichts Neues zu speichern", + "r_head_passed": "Prüfung bestanden", + "r_hub_extra": "Verteiler-Freigabe um {mm} mm verlängert.", + "r_lead_changed": "Der Weg funktioniert, aber nicht mit den gespeicherten Zahlen. Speichere die korrigierten.", + "r_lead_measured": "Sieh dir die Werte an, dann speichere sie oder prüfe zuerst.", + "r_lead_none": "Starte eine Messung oder Prüfung, um ein Ergebnis für diesen Weg zu bekommen.", + "r_lead_passed": "Die gespeicherten Werte passen für diesen Weg. Es muss nichts gespeichert werden.", + "r_measured": "Weg gemessen und geparkt. Kopfstrecke {load} mm ({src}), Verteiler-Freigabe {swap} mm.", + "r_remeasured": "Kopfsensor {mm} mm neben der gespeicherten Strecke gefunden.", + "rec_ended": "Der Lauf ist beendet, aber das Filament steckt noch im Schlauch. Hol es zurück, bevor etwas anderes geladen wird.", + "rec_forget": "Ich habe es von Hand herausgezogen", + "rec_home": "Filament zum ACE zurückholen", + "rec_homing": "Filament wird zum ACE zurückgeholt", + "rec_homing_text": "Noch {mm} mm.", + "rec_paused": "Nichts ist verloren. Mach dort weiter, wo du angehalten hast, oder hol das Filament zurück und fang neu an.", + "rec_resume": "Weitermachen", + "rec_title": "Angehalten, Spitze {mm} mm ab Parkposition", + "recalibrate": "Neu messen", + "reinsert_a": "Zieh es aus dem ACE-Einlass, schieb es wieder hinein und warte, bis der ACE es eingezogen hat. Mach das, wenn du das Filament von Hand bewegt hast.", + "reinsert_q": "Nicht sicher, ob das Filament wirklich an der Parkposition ist?", + "return_preload": "Zur Parkposition zurück", + "review_results": "Ergebnis ansehen", + "review_route": "Weg ändern", + "route_known": "Kalibriert", + "route_locked": "Der Weg lässt sich nicht ändern, solange sein Filament außerhalb des ACE ist oder ein Lauf offen ist.", + "route_needs": "Nicht kalibriert", + "row_load": "Lade-Budget", + "row_load_sub": "der Vorschub stoppt ohnehin am Kopfsensor", + "row_retract": "Voller Rückzug", + "row_retract_sub": "wird genau wie gemessen gespeichert", + "row_swap_sub": "zu lang kostet Sekunden, zu kurz blockiert den Verteiler", + "saved_note": "Laden und Wechsel nutzen die neuen Werte nach dem nächsten Neustart des Druckers. Du kannst jetzt mit dem nächsten Slot oder ACE weitermachen und am Ende einmal neu starten.", + "saved_title": "Gespeichert, mit Sicherung der alten Konfiguration.", + "saving": "Speichert…", + "scope_ace": "Alle Slots von ACE {ace}", + "scope_ace_hint": "Sie teilen sich den Schlauch vom Verteiler zum Kopf.", + "scope_label": "Speichern für", + "scope_slot": "Nur Slot {slot}", + "scope_slot_hint": "Dieser Slot bekommt eigene Werte.", + "sensor_clear": "Sensor frei", "sensor_na": "Kopfsensor nicht verfügbar", - "session": "Sitzung", - "setup_head": "Eine physische Bahn wählen", - "setup_hint": "Die gewählte ACE, ihr Bowden und der zugeordnete Kopf bleiben während Messung und Prüfung sichtbar.", - "shared_via": "Gemeinsame ACE-Werte, gemessen über", - "slot_measure": "Mess-Slot", + "sensor_on": "Filament erkannt", + "setup_head": "Weg wählen", + "setup_hint": "Wähle den ACE, den Slot, über den gemessen wird, und den Kopf, den er versorgt. Hierher kommst du nur zurück, um eines davon zu ändern.", + "slot_measure": "ACE-Mess-Slot", + "slot_none": "bereit · nicht kalibriert", + "slot_own": "bereit · eigene Werte", + "slot_shared": "bereit · nutzt ACE-Werte", "splitter": "Verteiler", - "splitter_sub": "gemeinsamer Knoten", - "st_choose_route": "Bahn wählen", + "splitter_sub": "hier laufen die Schläuche zusammen", + "splitter_unknown": "Lage noch nicht gemessen", + "src_encoder": "Spur-Encoder", + "src_measured": "Gemessen", + "src_saved": "Gespeichert", + "src_time": "Zeitschätzung", + "st_available": "Möglich", "st_needs_values": "Werte fehlen", - "st_no_result": "Kein neues Ergebnis", + "st_no_result": "Nichts Neues", "st_passed": "Bestanden", - "st_required": "Erforderlich", - "st_review": "Werte ansehen", - "st_route_ready": "Bahn bereit", + "st_required": "Nötig", + "st_review": "Bereit zum Speichern", + "st_route_ready": "Weg gewählt", "st_running": "Läuft", - "st_values": "Werte vorhanden", - "start_calibration": "Messung starten", + "st_saved": "Gespeichert", + "st_values": "Werte gespeichert", + "start_calibration": "Diesen Weg messen", "start_measure": "Messung starten", - "step1": "1 · Sauberen Ausgangszustand herstellen", - "step1_hint": "Beginne mit einem nachweislich leeren Zustand. Sonst verfälschen ein früherer Ladevorgang, eine veraltete Bahn oder ein belegter Kopf die Messung.", - "step2": "2 · Gewählte Bahn vorbereiten", - "step2_hint": "Filament am gewählten ACE-Eingang herausnehmen und neu einlegen, dann den automatischen Einzug abwarten. Diese frische Einzugsposition ist der Bezugspunkt aller Messungen.", - "step3": "3 · Ladestrecke messen", - "step3_hint": "Bahn vorbereitet. Es beginnt mit einem begrenzten Vorschub von 500 mm Richtung Kopfsensor…", - "step3_running": "3 · Ladestrecke wird gemessen", - "step3_running_hint": "Die ACE beginnt mit 500 mm und schiebt dann in Schritten von 100 mm nach, während der Kopfsensor alle 50 ms abgefragt wird.", - "stop_cancel": "Anhalten & abbrechen", + "starting": "Startet…", + "status_moving": "In Bewegung", + "status_stopped": "Angehalten", + "status_waiting": "Wartet auf dich", + "step1_head": "Fördert zum Kopf", + "step1_hint": "Der Vorschub stoppt von selbst, sobald der Kopfsensor das Filament sieht.", + "step2_head": "Zurückziehen, bis die Spitze aus dem Verteiler ist", + "step2_hint": "Beobachte den Verteiler und stoppe, sobald die Spitze ihn auf der ACE-Seite verlassen hat. Das linke Ende der Leiste ist die Parkposition, nicht der Verteiler. Jede Bewegung ist ein begrenzter Befehl, und du kannst Richtung Kopf zurück, wenn du zu weit warst.", + "step3_head": "Fährt zur Parkposition zurück", + "step3_hint": "Ein Blick in den ACE ist nicht nötig: Es wird dieselbe Strecke zurückgezogen, die gefördert wurde.", + "step_feed": "Zum Kopf fördern", + "step_hub": "Verteiler freimachen", + "step_of": "Schritt {n} von 3", + "step_return": "Zurück zur Parkposition", + "stop": "Stopp", "stop_unload": "Entladen jetzt stoppen", - "subtitle": "Eine ACE-Bahn nach der anderen einrichten, messen und prüfen.", - "swap_point": "Wechsel-Punkt", + "stopping": "Stoppt…", + "stops_at": "Stoppt selbsttätig am", + "stops_limit": "oder nach {mm} mm ohne ihn", + "stops_sensor": "Kopfsensor", + "subtitle": "Den Filamentweg jeweils eines ACE-Slots messen und prüfen.", "tab_calibrate": "Messen", - "tab_results": "Ergebnisse", - "tab_setup": "Einrichten", + "tab_results": "Ergebnis", + "tab_setup": "Auswahl", "tab_verify": "Prüfen", - "tip_adjust": "Endpunkt am Kopf korrigieren · {mm} mm", - "tip_at_head": "Am Kopfsensor gehalten", - "tip_at_splitter": "Am gemessenen Verteiler-Punkt gehalten", - "tip_estimate": "{source} · Spitze geschätzt {mm} mm ab Einzugsposition", - "tip_fine": "Feinpositionierung · geschätzt {mm} mm ab Einzugsposition", - "tip_from_ace": "Spitze ab ACE", - "tip_moving": "In Bewegung · geschätzt {mm} mm ab Einzugsposition", - "tip_no_anchors": "Für diese Bahn sind keine Ankerwerte hinterlegt", - "tip_parked": "Auf der Einzugsposition geparkt", - "tip_paused": "Angehalten · geschätzt {mm} mm ab Einzugsposition", - "tip_pos_na": "Bahn bekannt · aktuelle Position der Spitze unbekannt", - "tip_src_config": "Hinterlegte Bahn", - "tip_src_measured": "Gemessene Bahn", - "tip_verified": "Prüfung abgeschlossen · auf Einzugsposition geparkt", - "title": "Filamentweg", + "tip_at": "Spitze {mm} mm ab Parkposition", + "tip_at_head": "Spitze am Kopfsensor", + "tip_at_splitter": "Spitze am Freigabepunkt des Verteilers", + "tip_homing": "Zurück zum ACE · noch {mm} mm", + "tip_moving": "In Bewegung · etwa {mm} mm ab Parkposition (Zeitschätzung, Markierung wird zurückgehalten)", + "tip_moving_enc": "In Bewegung · {mm} mm ab Parkposition (Spur-Encoder)", + "tip_no_anchors": "Dieser Weg wurde noch nicht gemessen", + "tip_now": "Spitze steht bei", + "tip_now_sub": "ab Parkposition", + "tip_parked": "Filament an der Parkposition", + "tip_pos_na": "Position der Spitze unbekannt", + "tip_stopped": "Angehalten, Spitze {mm} mm ab Parkposition", + "title": "Kalibrierung", "toward_ace": "Richtung ACE", "toward_head": "Richtung Kopf", - "unknown": "Unbekannt", - "v_adjust": "Der gespeicherte Endpunkt ist erreicht, der Kopfsensor meldet aber weiterhin frei. Nutze die begrenzten Schaltflächen unten; die Bewegung stoppt selbsttätig, sobald der Sensor anspricht.", - "v_adjusting": "Feinpositionierung der Spitze. Der Kopfsensor wird während dieser begrenzten Bewegung durchgehend beobachtet.", + "track_head": "T{head} · Kopfsensor", + "track_park": "ACE · Parkposition", + "undo_mark": "Rückgängig — zu früh markiert", + "unit_v1": "ACE Pro · verbunden", + "unit_v2": "ACE 2 Pro · verbunden", + "unload_first": "Zuerst T{head} entladen", + "unloading": "Entlädt…", + "v_adjust": "Der Kopfsensor hat auch hinter der gespeicherten Strecke nicht angesprochen. Nutze die kleinen Schritte unten; die Bewegung stoppt von selbst, sobald der Sensor anspricht.", + "v_adjusting": "Die Spitze wird einen kleinen Schritt bewegt. Der Kopfsensor wird dabei beobachtet.", "v_again": "Erneut prüfen", - "v_at_head": "Am Kopfsensor angehalten. Endpunkt prüfen, dann von Hand weiter.", - "v_at_splitter": "Am Verteiler-Punkt angehalten. Bestätige, dass die Spitze den Knoten verlassen hat, dann von Hand weiter.", - "v_details": "Erweiterte Details zur Prüfung", - "v_feed": "Vorschub von der Einzugsposition zum Kopf in einer durchgehenden Bewegung. Der Sensor wird dabei durchgehend beobachtet.", - "v_go_ace": "Verteiler bestätigt · zurück zur ACE", + "v_at_head": "Am Kopfsensor angehalten.", + "v_at_splitter": "Sieh auf den Verteiler. Das Filament wurde {mm} mm zurückgezogen - die Verteiler-Freigabe, die gespeichert wird, mit Zuschlag - wie bei einem Wechsel. Ist die Spitze aus dem Verteiler?", + "v_confirmed": "Kopfsensor {mm} mm neben der gespeicherten Strecke erreicht, innerhalb der Toleranz.", + "v_feed": "Fördert zum Kopfsensor. Das Lade-Budget ist {budget} mm, wie bei einem echten Laden; der Vorschub stoppt am Sensor.", "v_go_splitter": "Weiter zum Verteiler", - "v_pause": "Prüfung anhalten", - "v_paused": "Die Prüfung ist angehalten. Die Markierung ist eine Schätzung aus dem bereits gefahrenen Teil der Bewegung.", - "v_resume": "Prüfung fortsetzen", - "v_returning": "Rückweg vom Verteiler zur Einzugsposition in einer durchgehenden Bewegung.", + "v_hub_adjust": "Zieh zurück, bis die Spitze frei ist. Jeder Schritt wird zur Verteiler-Freigabe dieses Wegs addiert.", + "v_paused": "Angehalten. Die Position bleibt erhalten.", + "v_remeasured": "Kopfsensor {mm} mm neben der gespeicherten Strecke gefunden, die Strecke wird neu gemessen: {load} mm.", + "v_returning": "Fährt zur Parkposition zurück.", "v_start": "Prüfung starten", - "v_to_splitter": "Rückzug vom Kopf zum gemessenen Verteiler-Punkt in einer durchgehenden Bewegung.", - "v_values_missing": "Für diese Bahn gibt es noch keine gültigen Anker für Kopf und Verteiler.", - "v_values_ok": "Die gespeicherten Werte der Bahn sind gültig", + "v_to_splitter": "Kopf erreicht. Zieht zum Freigabepunkt des Verteilers zurück.", + "v_values": "Die Werte, die gespeichert werden, mit Zuschlag: Lade-Budget {load} mm, Verteiler-Freigabe {swap} mm, voller Rückzug {retract} mm.", + "v_values_missing": "Für diesen Weg sind noch keine Werte gespeichert.", + "v_values_title": "Geprüfte Werte", "v_what": "Was passiert", - "v_what_hint": "Drei durchgehende Bewegungen mit Halt von Hand am Kopf und am Verteiler. Die Prüfung lässt sich während der Fahrt anhalten oder abbrechen.", - "verify_head": "Gespeicherte Bahn testen", - "verify_hint": "Zum Kopf fahren, am Verteiler halten, dann zurück zur Einzugsposition.", + "v_what_hint": "Ein Vorschub zum Kopfsensor, ein Rückzug zum Freigabepunkt des Verteilers, ein Halt für deine Bestätigung, dann die Rückfahrt. Es wird nichts geheizt.", + "values_head": "Für diesen Weg gespeichert", + "values_in_use": "in Verwendung", + "values_level_ace": "ACE {ace} · gemeinsam", + "values_level_default": "Globaler Standard", + "values_level_slot": "Slot {slot} · eigen", + "verify_first": "Zuerst prüfen", + "verify_head": "Diesen Weg prüfen", + "verify_hint": "Das Filament fährt zum Kopf, kommt bis zum Freigabepunkt des Verteilers zurück, wartet dort auf deinen Blick und fährt dann zur Parkposition.", "verify_running": "Prüfung läuft", - "verify_saved": "Gespeicherte Messung prüfen", + "verify_saved": "Diesen Weg prüfen", + "vstep_head": "Kopf", + "vstep_hub": "Verteiler-Freigabe", + "vstep_return": "Zurück zur Parkposition", "workflow_aria": "Ablauf der Kalibrierung" }, "acefw": { diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index bab13d1..e0d1530 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -658,178 +658,279 @@ }, "calibration": { "ace_unit": "ACE unit", - "allow_load": "Load allowance", - "allow_splitter": "Splitter clearance", - "anchor_head": "Toolhead anchor", - "anchor_splitter": "Splitter anchor", - "anchors_none": "Route anchors are not calibrated yet.", - "apply_config": "Apply to config", - "available": "Available", - "baseline_ok": "Clean starting point confirmed", - "baseline_wait": "Clean starting point awaits confirmation", + "anchor_head": "toolhead", + "anchor_splitter": "hub clearance point", + "anchors_none": "This route has not been measured yet.", + "apply_config": "Save to config", + "at_park_hint": "Back at the park position, so the tip is well before the hub. Move toward the toolhead and stop with the tip just in front of the hub on the ACE side, not inside it, then mark.", "blk_ace_off": "The selected ACE is not connected.", "blk_busy": "Wait for the active filament operation to finish.", - "blk_head_loaded": "Unload the mapped toolhead first.", - "blk_no_values": "Save a calibration for this route first.", + "blk_head_loaded": "Unload the toolhead first.", + "blk_no_values": "Measure this route first.", "blk_normal_mode": "Switch multiACE out of normal mode.", "blk_not_ace_head": "Select an ACE-driven toolhead.", + "blk_occupied": "Calibration filament is still in the tube. Return it to the ACE first.", "blk_printing": "Wait until the printer is idle.", "blk_sensor_na": "The mapped toolhead sensor is unavailable.", - "blk_session": "Finish or cancel the current calibration first.", - "blk_slot_ready": "Return the selected slot to its automatic-preload position.", - "calibrate_route": "Calibrate this route", - "chk_ace_conn": "Selected ACE is connected", + "blk_session": "Finish the open run first.", + "blk_slot_ready": "The selected slot holds no filament at the park position.", + "calibrate_route": "Measure this route", + "cancel_run": "Cancel this run", + "cap_text": "It cannot be longer than the full retract. On this route the hub sits close to the ACE, so a swap pulls the filament almost all the way back.", + "cap_title": "Hub clearance limited.", + "chk_cold": "Nothing is heated or extruded", + "chk_head_clear": "T{head} toolhead sensor is clear", + "chk_head_loaded": "T{head} still holds filament", "chk_idle": "Printer is idle", - "chk_no_bg": "No swap/background operation", - "chk_no_move": "No other route is moving", - "chk_queue_idle": "Printer and filament queue are idle", - "chk_route_ok": "Preload reference and physical route confirmed", - "chk_slot_ready": "Selected slot is at its automatic preload reference", - "col_config": "Config value", + "chk_no_move": "No other filament is moving", + "chk_queue_idle": "Printer is idle and no other filament is moving", + "chk_slot_ready": "Slot {slot} holds filament at the park position", + "chk_tube_empty": "No calibration filament left in the tube", + "clear": "Clear saved values", + "clear_affected_many": "Slots {slots} use these values and become Not calibrated.", + "clear_affected_none": "Every slot of ACE {ace} has its own values, so no path changes.", + "clear_affected_one": "Slot {slots} uses these values and becomes Not calibrated.", + "clear_after": "After clearing", + "clear_blk_moving": "ACE {ace} is moving. You can clear once it has stopped.", + "clear_blk_none_ace": "No shared values saved for ACE {ace}.", + "clear_blk_none_slot": "No values saved for this slot.", + "clear_confirm": "Clear values", + "clear_done_ace": "Cleared the shared values of ACE {ace}.", + "clear_done_slot": "Cleared the own values of Slot {slot}.", + "clear_falls_ace": "The path then uses the shared values of ACE {ace} and stays Calibrated.", + "clear_falls_default": "The path then uses the global default and becomes Not calibrated.", + "clear_filament_out": "Filament is about {mm} mm out of the ACE.", + "clear_filament_out_hint": "It stays in the tube. Return filament to the ACE stays offered for this route afterwards.", + "clear_keeps_own": "Slots with their own values keep them: {slots}.", + "clear_list_and": "{rest} and {last}", + "clear_restart": "Takes effect after the next printer restart, like Save. The old config is kept as a backup.", + "clear_run_reset": "The open calibration run for this route is discarded.", + "clear_run_reset_results": "The results of this run are discarded too.", + "clear_scope_label": "Clear the values of", + "clear_src_ace": "ACE {ace} shared", + "clear_src_default": "global default", + "clear_title_ace": "Clear the shared values of ACE {ace}?", + "clear_title_slot": "Clear the own values of Slot {slot}?", + "clear_undo": "Undo", + "clear_undo_done": "Previous values restored.", + "clear_undo_done_hint": "They take effect after the next printer restart.", + "clear_undo_hint": "Takes effect after the next printer restart. Undo saves the old values back.", + "col_config": "Value", + "col_margin": "Margin", "col_measured": "Measured", - "col_prev": "Previous", - "col_proposed": "Proposed", - "commanded": "Commanded", - "conf_encoder": "Encoder observed", - "conf_encoder_hint": "Decoder movement is diagnostic; configured millimetres use the bounded commanded distance.", - "conf_estimate": "Estimated distance", - "conf_estimate_hint": "ACE v1 has no decoder telemetry. Repeat and visually verify before applying.", - "confirm_route": "Use ACE preload position and confirm route", - "continue_active": "Continue active workflow", - "cum_retract": "Cumulative retract", - "decoder_span": "Decoder span", - "discard": "Discard / new session", - "done_busy": "Another workflow is active", - "done_busy_hint": "Use the active workflow tab to continue safely.", - "done_ready": "Measurements are ready", - "done_ready_hint": "Review the current values in Results or verify the route again.", - "err_head_not_clear": "The selected toolhead sensor is no longer clear. Unload the selected toolhead before confirming the starting point.", + "col_prev": "Saved now", + "col_proposed": "Will save", + "continue_active": "Go to Verify", + "dir_ace": "ACE", + "dir_head": "toolhead", + "discard": "Discard this result", + "discard_run": "Discard this run", + "done_busy": "A verification is running", + "done_busy_hint": "Carry on in the Verify tab.", + "done_ready": "Measurement finished", + "done_ready_hint": "Review the values in Results, or verify the route.", + "drag_hint": "Drag the marker to move the filament; it moves when you let go. Use the mm steps for small corrections.", + "drag_moving": "Moving {mm} mm toward the {dir} · {left} mm to go", + "drag_release": "Release to move {mm} mm toward the {dir}", + "drag_small": "Too small to move", "err_incomplete": "Complete all calibration checkpoints first.", "err_no_section": "Config form has no matching ACE section.", "err_prep_cancelled": "Preparation unload cancelled. Verify the filament position before establishing a new baseline.", "err_prep_unclear": "Preparation did not produce a clear selected-toolhead baseline. Resolve the loaded or unknown sensor, then try again.", - "err_retract_small": "Full retract must be at least the swap checkpoint plus its allowance.", + "err_retract_small": "The full retract must be longer than the hub clearance.", "err_save": "Config save failed.", "err_stale_klipper": "Klipper is still running the previous calibration code. Restart Klipper or reboot the printer, then retry verification.", - "final_pos": "Final position", - "go_calibrate": "Continue to calibration", - "go_verify": "Continue to verification", - "have_values": "This route already has measurements", - "have_values_hint": "Recalibration is optional. Verify the saved path first unless a tube, splitter, or toolhead route changed.", - "head_label": "Connected toolhead", - "keep_saved": "Keep saved values", - "load_distance": "Load distance", - "m_park": "Park return", - "m_splitter": "Splitter retract", + "fed": "Fed so far", + "g_clearance_text": "How far to pull back from the toolhead so the tip is out of the hub and another slot can pass.", + "g_hub_text": "The junction where the tubes of all slots join into the one tube to the toolhead.", + "g_margin": "Margin", + "g_margin_text": "Extra distance added on top of a measured value when saving.", + "g_park": "Park position", + "g_park_text": "Where the ACE holds the filament after it has pulled it in by itself. Every distance is counted from here.", + "g_toolhead_text": "Park position to the toolhead sensor. Also used as the full retract.", + "glossary": "What do these words mean?", + "go_route": "Continue with this route", + "have_values": "This route is already calibrated", + "have_values_hint": "Check it with Verify first. Measure again only if a tube, the hub, or the toolhead changed.", + "head_ace": "fed by the ACE", + "head_feeder": "feeder · not ACE-fed", + "head_label": "Toolhead", + "head_manual": "manual · not ACE-fed", + "hub_added": "Added to the hub clearance", + "hub_more": "Pull back further", + "hub_no": "Not clear — pull back more", + "hub_saved": "hub (saved)", + "hub_yes": "Tip is clear — return to park", + "hub_yes_now": "Clear now — return to park", + "keep_saved": "Keep the saved values", + "m_splitter": "Hub clearance", + "m_splitter_sub": "pull-back from the toolhead, margin included", "m_toolhead": "Toolhead distance", - "manual_correction": "Manual endpoint correction", - "mark_splitter": "Mark splitter cleared", - "marked_hint": "The splitter checkpoint is saved. Return the remaining measured distance to the ACE automatic-preload reference.", + "m_toolhead_sub": "park position to toolhead sensor", + "manual_correction": "Small steps to the toolhead sensor", + "margin_included": "included", + "margin_none": "none", + "mark_splitter": "Mark here and go to park", + "mark_splitter_hint": "Mark when the tip is out of the hub, in its own Bowden tube to the ACE.", + "marked_at": "Hub clearance marked at {mm} mm – saved on Results.", + "measure_head": "Measure this route", + "measure_hint": "The filament is fed cold to the toolhead sensor, you pull it back until it is clear of the hub, and it returns to the ACE by itself.", + "next_route": "Calibrate the next slot or ACE", "no_ace": "No connected ACE units are available.", - "no_heat_hint": "Calibration does not heat or extrude. Remove loose filament from the splitter/output tube and make sure only the selected slot enters this route.", - "none_yet": "No saved values or session results exist for this route.", - "r_hint": "Review the active route values and decide whether to verify, recalibrate, or save them.", - "r_none_session": "No new calibration or verification result exists in this session. The configured route remains available for verification.", - "r_passed": "Verification passed", - "r_retract_note": "The full retract is inferred by returning the same measured distance used to reach the toolhead sensor. Repeat the calibration if you want to compare preload repeatability before applying.", - "r_title": "Calibration results", - "r_verified_note": "Continuous verification round trip completed and returned to the ACE preload reference.", - "recalibrate": "Recalibrate this route", - "reset_session": "Reset session", - "retract_hint": "Retract in steps until the filament tip has fully cleared the Y/4-way splitter, then mark it.", - "return_hint": "Returning automatically to the measured automatic-preload position. No visual inspection inside the ACE is required.", - "return_preload": "Return to preload reference", + "none_yet": "Nothing has been measured or saved for this route yet.", + "override_note": "Slots with their own values keep them: {slots}. Saving for the whole ACE does not change those.", + "park_sub": "park position", + "pulled_back": "Pulled back", + "pulled_back_sub": "from the toolhead sensor", + "r_confirmed": "Route checked and parked. Toolhead sensor reached {mm} mm from the saved distance; hub clearance confirmed.", + "r_head_changed": "Verification changed the values", + "r_head_measured": "Measurement finished", + "r_head_none": "Nothing new to save", + "r_head_passed": "Verification passed", + "r_hub_extra": "Hub clearance extended by {mm} mm.", + "r_lead_changed": "The route works, but not with the saved numbers. Save the corrected ones.", + "r_lead_measured": "Check the values, then save them or verify first.", + "r_lead_none": "Run a measurement or a verification to get a result for this route.", + "r_lead_passed": "The saved values work for this route. Nothing needs saving.", + "r_measured": "Route measured and parked. Toolhead distance {load} mm ({src}), hub clearance {swap} mm.", + "r_remeasured": "Toolhead sensor found {mm} mm from the saved distance.", + "rec_ended": "The run has ended, but the filament is still in the tube. Bring it back before anything else is loaded.", + "rec_forget": "I pulled it out by hand", + "rec_home": "Return filament to the ACE", + "rec_homing": "Returning the filament to the ACE", + "rec_homing_text": "{mm} mm to go.", + "rec_paused": "Nothing is lost. Carry on where you stopped, or bring the filament back and start over.", + "rec_resume": "Carry on", + "rec_title": "Stopped with the tip {mm} mm from the park position", + "recalibrate": "Measure again", + "reinsert_a": "Pull it out of the ACE inlet, push it back in and wait until the ACE has drawn it in. Do this if you moved the filament by hand.", + "reinsert_q": "Not sure the filament is really at the park position?", + "return_preload": "Return to the park position", "review_results": "Review results", - "review_route": "Review route", - "route_known": "Route known", - "route_needs": "Needs calibration", - "safety_limit": "Safety limit", - "saved_note": "Saved with a backup. Restart the printer to apply the new values.", - "saved_title": "Saved calibration", - "saved_values": "Saved values", - "scope_ace": "Entire ACE unit", - "scope_ace_hint": "Use shared defaults for every slot in this ACE.", - "scope_ace_short": "Entire ACE defaults", - "scope_label": "Apply calibration to", - "scope_slot": "Single Bowden tube", - "scope_slot_hint": "Override only the selected slot path.", - "scope_slot_short": "Per-slot override", + "review_route": "Change route", + "route_known": "Calibrated", + "route_locked": "The route cannot be changed while its filament is out of the ACE or a run is open.", + "route_needs": "Not calibrated", + "row_load": "Load budget", + "row_load_sub": "the feed stops at the toolhead sensor anyway", + "row_retract": "Full retract", + "row_retract_sub": "saved exactly as measured", + "row_swap_sub": "too long costs seconds, too short blocks the hub", + "saved_note": "Loads and swaps use the new values after the next printer restart. You can go on with the next slot or ACE now and restart once at the end.", + "saved_title": "Saved, with a backup of the old config.", + "saving": "Saving…", + "scope_ace": "All slots of ACE {ace}", + "scope_ace_hint": "They share the tube from the hub to the toolhead.", + "scope_label": "Save for", + "scope_slot": "Only Slot {slot}", + "scope_slot_hint": "This slot gets its own values.", + "sensor_clear": "sensor clear", "sensor_na": "Toolhead sensor unavailable", - "session": "Session", - "setup_head": "Choose one physical route", - "setup_hint": "The selected ACE, Bowden path, and mapped toolhead stay visible throughout calibration and verification.", - "shared_via": "Shared ACE values measured through", - "slot_measure": "Measurement slot", - "splitter": "Splitter", - "splitter_sub": "shared junction", - "st_choose_route": "Choose route", + "sensor_on": "filament detected", + "setup_head": "Choose the route", + "setup_hint": "Pick the ACE, the slot to measure through, and the toolhead it feeds. You only come back here to change one of them.", + "slot_measure": "ACE measurement slot", + "slot_none": "ready · not calibrated", + "slot_own": "ready · own values", + "slot_shared": "ready · uses ACE values", + "splitter": "Hub", + "splitter_sub": "where the tubes join", + "splitter_unknown": "position not measured yet", + "src_encoder": "lane encoder", + "src_measured": "Measured", + "src_saved": "Saved", + "src_time": "time estimate", + "st_available": "Available", "st_needs_values": "Needs values", - "st_no_result": "No new result", + "st_no_result": "Nothing new", "st_passed": "Passed", - "st_required": "Required", - "st_review": "Review values", - "st_route_ready": "Route ready", + "st_required": "Needed", + "st_review": "Ready to save", + "st_route_ready": "Route chosen", "st_running": "In progress", - "st_values": "Values available", - "start_calibration": "Start calibration", - "start_measure": "Start and measure load", - "step1": "1 · Establish a clean slate", - "step1_hint": "Start from a known empty baseline. This prevents a previous load, stale route, or occupied toolhead from contaminating the measurement.", - "step2": "2 · Prepare the selected route", - "step2_hint": "Remove and reinsert filament in the selected ACE inlet, then wait for automatic preload to finish. That fresh preload position becomes the parked reference for every measurement.", - "step3": "3 · Measure load", - "step3_hint": "Route prepared. Starting with a bounded 500 mm feed toward the toolhead sensor…", - "step3_running": "3 · Measuring load distance", - "step3_running_hint": "The ACE starts with 500 mm, then continues in 100 mm increments while polling the toolhead sensor every 50 ms.", - "stop_cancel": "Stop & cancel", + "st_saved": "Saved", + "st_values": "Values saved", + "start_calibration": "Measure this route", + "start_measure": "Start measurement", + "starting": "Starting…", + "status_moving": "Moving", + "status_stopped": "Stopped", + "status_waiting": "Waiting for you", + "step1_head": "Feeding to the toolhead", + "step1_hint": "The feed stops by itself when the toolhead sensor sees the filament.", + "step2_head": "Pull back until the tip is clear of the hub", + "step2_hint": "Watch the hub and stop as soon as the tip has left it on the ACE side. The left end of the track is the park position, not the hub. Each move is one bounded command, and you can go back toward the toolhead if you overshoot.", + "step3_head": "Returning to the park position", + "step3_hint": "No need to look inside the ACE: the same distance that was fed is pulled back.", + "step_feed": "Feed to the toolhead", + "step_hub": "Clear the hub", + "step_of": "Step {n} of 3", + "step_return": "Return to park", + "stop": "Stop", "stop_unload": "Stop unload now", - "subtitle": "Set up, calibrate, and verify one ACE route at a time.", - "swap_point": "Swap checkpoint", + "stopping": "Stopping…", + "stops_at": "Stops automatically at", + "stops_limit": "or after {mm} mm without it", + "stops_sensor": "Toolhead sensor", + "subtitle": "Measure and check the filament path of one ACE slot at a time.", "tab_calibrate": "Calibrate", "tab_results": "Results", "tab_setup": "Setup", "tab_verify": "Verify", - "tip_adjust": "Toolhead endpoint adjustment · {mm} mm", - "tip_at_head": "Held at the toolhead sensor", - "tip_at_splitter": "Held at the calibrated splitter checkpoint", - "tip_estimate": "{source} · estimated tip {mm} mm from ACE preload", - "tip_fine": "Fine positioning · estimated {mm} mm from ACE preload", - "tip_from_ace": "Tip from ACE", - "tip_moving": "Moving · estimated {mm} mm from ACE preload", - "tip_no_anchors": "No configured calibration anchors for this route", - "tip_parked": "Parked at the ACE preload reference", - "tip_paused": "Paused · estimated {mm} mm from ACE preload", - "tip_pos_na": "Configured route known · current tip position unavailable", - "tip_src_config": "Configured route", - "tip_src_measured": "Measured route", - "tip_verified": "Verification complete · parked at ACE preload", - "title": "Filament path", + "tip_at": "Tip {mm} mm from the park position", + "tip_at_head": "Tip at the toolhead sensor", + "tip_at_splitter": "Tip at the hub clearance point", + "tip_homing": "Returning to the ACE · {mm} mm to go", + "tip_moving": "Moving · roughly {mm} mm from the park position (time estimate, the marker is held back)", + "tip_moving_enc": "Moving · {mm} mm from the park position (lane encoder)", + "tip_no_anchors": "This route has not been measured yet", + "tip_now": "Tip is now", + "tip_now_sub": "from the park position", + "tip_parked": "Filament at the park position", + "tip_pos_na": "Tip position unknown", + "tip_stopped": "Stopped with the tip {mm} mm from the park position", + "title": "Calibration", "toward_ace": "Toward ACE", "toward_head": "Toward toolhead", - "unknown": "Unknown", - "v_adjust": "The saved endpoint was reached but the toolhead sensor is still clear. Use the bounded controls below; movement stops automatically when the sensor trips.", - "v_adjusting": "Fine-positioning the filament tip. The toolhead sensor is monitored throughout this bounded move.", + "track_head": "T{head} · toolhead sensor", + "track_park": "ACE · park position", + "undo_mark": "Undo — I marked too early", + "unit_v1": "ACE Pro · connected", + "unit_v2": "ACE 2 Pro · connected", + "unload_first": "Unload T{head} first", + "unloading": "Unloading…", + "v_adjust": "The toolhead sensor has not tripped, even past the saved distance. Use the small steps below; movement stops by itself when the sensor trips.", + "v_adjusting": "Moving the tip a small step. The toolhead sensor is watched throughout.", "v_again": "Verify again", - "v_at_head": "Stopped at the toolhead sensor. Inspect the endpoint, then continue manually.", - "v_at_splitter": "Stopped at the splitter checkpoint. Confirm the tip is clear of the junction, then continue manually.", - "v_details": "Advanced verification details", - "v_feed": "Feeding from the ACE preload reference to the toolhead in one continuous move. The sensor is monitored throughout.", - "v_go_ace": "Splitter confirmed · return to ACE", - "v_go_splitter": "Continue to splitter", - "v_pause": "Pause verification", - "v_paused": "Verification is paused. The marker is an estimate based on the completed portion of the commanded move.", - "v_resume": "Resume verification", - "v_returning": "Returning from the splitter to the ACE automatic-preload reference in one continuous move.", + "v_at_head": "Stopped at the toolhead sensor.", + "v_at_splitter": "Look at the hub. The filament was pulled back {mm} mm - the hub clearance that will be saved, margin included - as a swap would do. Is the tip out of the hub?", + "v_confirmed": "Toolhead sensor reached {mm} mm from the saved distance, within tolerance.", + "v_feed": "Feeding to the toolhead sensor. The load budget is {budget} mm, as in a real load; the feed stops at the sensor.", + "v_go_splitter": "Continue to the hub", + "v_hub_adjust": "Pull back until the tip is clear. Every step is added to the hub clearance of this route.", + "v_paused": "Stopped. The position is kept.", + "v_remeasured": "Toolhead sensor found {mm} mm from the saved distance, so the distance is re-measured: {load} mm.", + "v_returning": "Returning to the park position.", "v_start": "Start verification", - "v_to_splitter": "Retracting from the toolhead to the measured splitter checkpoint in one continuous move.", - "v_values_missing": "This route has no valid saved toolhead and splitter anchors yet.", - "v_values_ok": "Saved route values are valid", + "v_to_splitter": "Toolhead reached. Pulling back to the hub clearance point.", + "v_values": "The ones that will be saved, margins included: load budget {load} mm, hub clearance {swap} mm, full retract {retract} mm.", + "v_values_missing": "This route has no saved values yet.", + "v_values_title": "Values under test", "v_what": "What will happen", - "v_what_hint": "Three continuous moves with manual stops at the toolhead and splitter. Verification can be paused or cancelled while moving.", - "verify_head": "Test the saved route", - "verify_hint": "Travel to the toolhead, stop at the splitter, then return to the ACE preload reference.", + "v_what_hint": "One feed to the toolhead sensor, one pull-back to the hub clearance point, one stop for you to confirm, then the return. Nothing is heated.", + "values_head": "Saved for this route", + "values_in_use": "in use", + "values_level_ace": "ACE {ace} · shared", + "values_level_default": "Global default", + "values_level_slot": "Slot {slot} · own", + "verify_first": "Verify first", + "verify_head": "Check this route", + "verify_hint": "The filament runs to the toolhead, comes back to the hub clearance point, waits for you to look, then returns to the park position.", "verify_running": "Verification in progress", - "verify_saved": "Verify saved calibration", + "verify_saved": "Verify this route", + "vstep_head": "Toolhead", + "vstep_hub": "Hub clearance", + "vstep_return": "Return to park", "workflow_aria": "Calibration workflow" }, "acefw": { diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index eb2de48..4534d8b 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -229,6 +229,64 @@ class AceException(Exception): # ratio can false-fire while a standstill cannot be invented. CAL_DECODER_MIN_LEN = 30.0 CAL_DECODER_MIN_MOVE = 2.0 +# Verification feed: ACE transport is not repeatable to the millimetre, so the +# verify run does not stop dead at the calibrated distance. It runs one long +# move to just short of it, then short chunks (so the timing estimate at the +# sensor only spans one chunk) and keeps going up to SEARCH past the endpoint +# until the toolhead sensor trips. A sensor position further than DEADBAND +# from the calibrated one replaces the calibrated distance. +CAL_VERIFY_SEARCH_MM = 200 +CAL_VERIFY_FINE_ZONE_MM = 150 +CAL_VERIFY_CHUNK_MM = 50 +CAL_VERIFY_DEADBAND_MM = 10 +# ...or this share of the distance, whichever is larger: the ACE moves about +# 1-2% more filament per mm pulling back than feeding, and a V1 position is +# only timed. +CAL_VERIFY_TOLERANCE = 0.02 +# A pull-back this long gives the lane's pull-back ratio for the return to +# park (shorter ones read low from the start of the move). +CAL_VERIFY_RATIO_MIN_MM = 300 +# V2 lane encoder as the feed-direction position source. The counter restarts +# with every motion command and reads in ~mm, so |decoder| is the real travel +# of the current move. A reading requested right behind the motion command +# can still carry the PREVIOUS move's final value, hence the hold-off, and +# nothing can have travelled further than the commanded speed allows (slack). +CAL_DECODER_HOLDOFF_S = 0.3 +CAL_DECODER_SLACK_MM = 30.0 +# The lane encoder does not count exactly one per commanded mm (a unit read +# 1.03). Lengths in the config are commanded mm, so encoder readings are +# converted with the ratio seen on this session's completed moves; a move +# whose reading is outside this band was cut short or misread and is not +# learned from. +CAL_ENCODER_RATIO_MIN = 0.85 +CAL_ENCODER_RATIO_MAX = 1.20 +CAL_ENCODER_LEARN_MIN_MM = 50 +# After the toolhead sensor stops a feed: let the lane come to rest, then +# take one last encoder reading before the distance is booked. +CAL_SETTLE_S = 0.4 +# V1 (ACE Pro) stops an unwind only with stop_unwind_filament, and a firmware +# that does not know a stop just keeps going; V1 reports no position. So +# after a stop the calibration waits for a status report that says ready +# and then judges by WHEN: ready clearly before the move could have run out +# means the stop was obeyed (book the distance up to the stop), ready only +# around its natural end means it was ignored (book the full length). Only +# a report that ARRIVED at least SETTLED after the command counts: the +# heartbeat refreshes every second and lags ~1-2 s, so an earlier report +# can still describe the unit before it started moving. MARGIN keeps a +# stop near the natural end on the safe side (booked as the full move). +CAL_STATUS_SETTLED_S = 2.5 +CAL_V1_STOP_MARGIN_S = 1.0 +# A calibration move never waits long for a busy ACE: wait_ace_ready_on +# blocks the G-code command for up to a minute (and then reconnects), which +# outlasts the web request and leaves the page out of step with the printer. +CAL_READY_WAIT_S = 2.0 +# Every state in which the ACE is being driven by the calibration. +CAL_MOVING_STATES = ( + 'feeding', 'retracting', 'returning', 'feed_jogging', 'homing', + 'verifying_feed', 'verifying_splitter', 'verifying_return', + 'verify_toolhead_adjusting', 'verify_splitter_adjusting') +# States that leave no calibration filament in the tube. +CAL_PARKED_STATES = ('idle', 'complete', 'verified') # Seconds to keep V1 feed-assist dispatches out of the homing/probe # window. A V1 start_feed_assist is a synchronous ser.write on the @@ -2093,6 +2151,15 @@ def _parse_idx_list(key): self.gcode.register_command( 'ACE_CALIBRATION_VERIFY', self.cmd_ACE_CALIBRATION_VERIFY, desc=self.cmd_ACE_CALIBRATION_VERIFY_help) + self.gcode.register_command( + 'ACE_CALIBRATION_STOP', self.cmd_ACE_CALIBRATION_STOP, + desc=self.cmd_ACE_CALIBRATION_STOP_help) + self.gcode.register_command( + 'ACE_CALIBRATION_RESUME', self.cmd_ACE_CALIBRATION_RESUME, + desc=self.cmd_ACE_CALIBRATION_RESUME_help) + self.gcode.register_command( + 'ACE_CALIBRATION_HOME', self.cmd_ACE_CALIBRATION_HOME, + desc=self.cmd_ACE_CALIBRATION_HOME_help) self.gcode.register_command( 'ACE_CALIBRATION_CANCEL', self.cmd_ACE_CALIBRATION_CANCEL, desc=self.cmd_ACE_CALIBRATION_CANCEL_help) @@ -3580,6 +3647,7 @@ def _multiace_rfid_cb(channel, info, is_clear=False, _orig=orig_cb): self._set_active_idx(self._active_device_index) def _handle_disconnect(self): + self._calibration_abort('klippy disconnected', restore=False) logging.info('[multiACE] Closing all ACE connections') for idx in list(self._serials.keys()): try: @@ -4658,6 +4726,14 @@ def _head_filament_present(self, sensor, head=None): return motion def _on_print_start(self, *args): + self._calibration_abort('print started') + occupied = self._calibration_route_occupied() + if occupied is not None: + self.log_error( + '[multiACE] Calibration filament is still in the tube of ' + 'ACE %s slot %s. Loads are refused until it is returned to ' + 'the ACE on the Calibration tab.' + % (occupied.get('ace'), occupied.get('slot'))) # Sniffed per print. print_stats:start fires on start AND on resume # (note_start runs in the virtual_sdcard work handler), so the flag # is refreshed on every resume as well. @@ -9954,6 +10030,11 @@ def _calibration_idle_state(self): 'protocol': '', 'commanded_feed_mm': 0, 'commanded_retract_mm': 0, + 'feed_travel_mm': 0.0, + 'encoder_counts': 0.0, + 'encoder_commanded_mm': 0.0, + 'decoder_ok': False, + 'position_source': 'timing', 'load_length_mm': None, 'swap_retract_length_mm': None, 'retract_length_mm': None, @@ -9969,7 +10050,13 @@ def _calibration_idle_state(self): 'verify_feed_actual_mm': None, 'verify_feed_delta_mm': None, 'verify_fine_offset_mm': 0, - 'verify_fine_limit_mm': 50, + 'verify_fine_limit_mm': CAL_VERIFY_SEARCH_MM + 50, + 'verify_search_mm': CAL_VERIFY_SEARCH_MM, + 'verify_remeasured_mm': None, + 'verify_hub_extra_mm': 0, + 'verify_swap_mm': None, + 'verify_load_budget_mm': None, + 'values_source': 'measured', 'verify_decoder_span': None, 'verify_move_seq': 0, 'toolhead_sensor': None, @@ -9981,6 +10068,19 @@ def _calibration_idle_state(self): 'error': None, } + def _calibration_route_occupied(self): + # The session dict when calibration filament is (believed to be) + # somewhere in the tube, else None. Everything that would push a + # second filament toward the hub asks this first. + c = getattr(self, '_calibration', None) or {} + if c.get('state') in CAL_PARKED_STATES: + return None + try: + tip = float(c.get('tip_position_mm') or 0) + except (TypeError, ValueError): + tip = 0.0 + return c if tip > 0 else None + def _calibration_sensor(self, head): sensor = None if getattr(self, 'calibration_sensor', None): @@ -10108,7 +10208,12 @@ def _calibration_unload_stop_hardware(self): state = self._calibration_unload ace, slot, head = state.get('ace'), state.get('slot'), state.get('head') if isinstance(ace, int) and isinstance(slot, int): - for method in ('stop_feed_filament', 'stop_feed_assist'): + proto = getattr(self, '_protocols', {}).get(ace) + methods = ['stop_feed_filament', 'stop_feed_assist'] + if getattr(proto, 'NAME', None) != 'v2': + # the preparation unload retracts; on V1 only this stops it + methods.insert(1, 'stop_unwind_filament') + for method in methods: try: self.send_request_to(ace, { 'method': method, 'params': {'index': slot}}, @@ -10155,17 +10260,43 @@ def _calibration_stop_timer(self): except Exception: pass - def _calibration_send_stop(self): + def _calibration_require_ready(self, gcmd, idx): + deadline = self.reactor.monotonic() + CAL_READY_WAIT_S + while ((self._info_per_ace.get(idx, {}) or {}).get('status') + != 'ready'): + now = self.reactor.monotonic() + if now >= deadline: + raise gcmd.error( + '[multiACE] the ACE is still moving - wait until it has ' + 'stopped, then try again') + self.reactor.pause(now + 0.1) + + def _calibration_stop_method(self, move): + # V2 stops either direction with one command. V1 has a separate + # stop for each: stop_feed_filament leaves an unwind running. + if (self._calibration.get('protocol') != 'v2' and move is not None + and move.get('method') == 'unwind_filament'): + return 'stop_unwind_filament' + return 'stop_feed_filament' + + def _calibration_send_stop(self, move=None): c = self._calibration idx, slot = c.get('ace'), c.get('slot') if not isinstance(idx, int) or not isinstance(slot, int): return - try: - self.send_request_to(idx, { - 'method': 'stop_feed_filament', - 'params': {'index': slot}}, lambda self, response: None) - except Exception: - pass + move = move if move is not None else self._calibration_move + if move is None and c.get('protocol') != 'v2': + # direction unknown on V1: stop both + methods = ('stop_feed_filament', 'stop_unwind_filament') + else: + methods = (self._calibration_stop_method(move),) + for method in methods: + try: + self.send_request_to(idx, { + 'method': method, + 'params': {'index': slot}}, lambda self, response: None) + except Exception: + pass def _calibration_restore_active(self): prev = self._calibration_prev_ace @@ -10215,15 +10346,17 @@ def _calibration_move_callback(self, response, session_id, move_id): if response and response.get('code', 0) != 0: move['error'] = response.get('msg') or 'ACE rejected movement' - def _calibration_sample_decoder(self, idx, slot, session_id, move_id): + def _calibration_sample_decoder(self, idx, slot, session_id, move_id, + on_done=None): + requested = self.reactor.monotonic() def _cb(self, response, _sid=session_id, _mid=move_id, - _slot=slot): - c = self._calibration - move = self._calibration_move - if (c.get('session_id') != _sid or move is None - or move.get('id') != _mid): - return + _slot=slot, _requested=requested, _on_done=on_done): try: + c = self._calibration + move = self._calibration_move + if (c.get('session_id') != _sid or move is None + or move.get('id') != _mid): + return rows = ((response or {}).get('result') or {}).get( 'feed_info') or [] for row in rows: @@ -10238,25 +10371,157 @@ def _cb(self, response, _sid=session_id, _mid=move_id, value = int(raw) if value >= (1 << 63): value -= (1 << 64) - cur_min = move.get('decoder_min') - cur_max = move.get('decoder_max') - move['decoder_min'] = value if cur_min is None else min( - cur_min, value) - move['decoder_max'] = value if cur_max is None else max( - cur_max, value) + started = float(move['started']) + if _requested - started < CAL_DECODER_HOLDOFF_S: + break + travel = float(abs(value)) + ceiling = ((self.reactor.monotonic() - started) + * float(move['speed']) + + CAL_DECODER_SLACK_MM) + if travel > ceiling: + break + previous = move.get('dec') + move['dec'] = travel if previous is None else max( + previous, travel) + c['decoder_ok'] = True break except Exception: pass + finally: + if _on_done is not None: + _on_done() try: self.send_request_to(idx, {'method': 'get_feed_info'}, _cb) except Exception: - pass + if on_done is not None: + on_done() + + def _calibration_move_travel(self, move, eventtime): + # Distance the current move has covered: the lane encoder when it + # has reported (V2), else commanded speed x elapsed time. The clock + # starts when the command is queued, so the time estimate runs ahead + # of the filament and is capped at the commanded length. + if move.get('stop_ignored'): + # the ACE ran the whole move despite the stop + return float(move['length']) + dec = move.get('dec') + if dec is not None: + self._calibration['position_source'] = 'encoder' + return min(float(move['length']), + float(dec) / self._calibration_encoder_ratio()) + self._calibration['position_source'] = 'timing' + elapsed = max(0.0, eventtime - float(move['started'])) + return min(float(move['length']), elapsed * float(move['speed'])) - def _calibration_accumulate_decoder(self, move): - dmin, dmax = move.get('decoder_min'), move.get('decoder_max') - if dmin is None or dmax is None: + def _calibration_encoder_ratio(self): + # Encoder counts per commanded mm on this session's completed moves. + c = self._calibration + commanded = float(c.get('encoder_commanded_mm') or 0.0) + if commanded < CAL_ENCODER_LEARN_MIN_MM: + return 1.0 + return float(c.get('encoder_counts') or 0.0) / commanded + + def _calibration_learn_encoder(self, move): + # A move that ran its full commanded length. + c = self._calibration + dec = move.get('dec') + length = float(move.get('length') or 0.0) + if (dec is None or move.get('stop_ignored') + or length < CAL_ENCODER_LEARN_MIN_MM): + return + if not (CAL_ENCODER_RATIO_MIN <= float(dec) / length + <= CAL_ENCODER_RATIO_MAX): + return + c['encoder_counts'] = float(c.get('encoder_counts') or 0.0) + float(dec) + c['encoder_commanded_mm'] = float( + c.get('encoder_commanded_mm') or 0.0) + length + + def _calibration_settle(self, finalize): + # Called when the toolhead sensor ends a feed. On V2 the booked + # distance comes from one encoder reading taken after the lane has + # stopped; without a working encoder there is nothing to wait for. + c = self._calibration + move = self._calibration_move + self._calibration_send_stop() + self._calibration_timer = None + if (c.get('protocol') != 'v2' and move is not None + and move.get('method') == 'unwind_filament'): + self._calibration_settle_v1_unwind(finalize) + return + if (c.get('protocol') != 'v2' or move is None + or not c.get('decoder_ok')): + finalize() return - span = max(0, int(dmax) - int(dmin)) + sid, state, mid = c['session_id'], c['state'], move['id'] + idx, slot = c['ace'], c['slot'] + token = {'done': False} + def _finish(*_args): + if token['done']: + return + token['done'] = True + cc = self._calibration + current = self._calibration_move + if (cc.get('session_id') != sid or cc.get('state') != state + or current is None or current.get('id') != mid): + return + finalize() + def _sample(eventtime): + self._calibration_sample_decoder( + idx, slot, sid, mid, on_done=_finish) + now = self.reactor.monotonic() + self.reactor.register_callback(_sample, now + CAL_SETTLE_S) + self.reactor.register_callback(_finish, now + CAL_SETTLE_S + 1.5) + + def _calibration_settle_v1_unwind(self, finalize): + # A V1 unwind after its stop (see CAL_STATUS_SETTLED_S). + c = self._calibration + move = self._calibration_move + sid, state, mid, idx = c['session_id'], c['state'], move['id'], c['ace'] + started = float(move['started']) + expected = float(move['length']) / max(float(move['speed']), 1.) + seen = {'report': self._info_per_ace.get(idx), 'at': None} + def _poll(eventtime): + cc = self._calibration + current = self._calibration_move + if (cc.get('session_id') != sid or cc.get('state') != state + or current is None or current.get('id') != mid): + return + if not self._connected_per_ace.get(idx, False): + self._calibration_move = None + self._calibration_fail('selected ACE disconnected') + return + report = self._info_per_ace.get(idx) or {} + if report is not seen['report']: + # the heartbeat replaces the report wholesale + seen['report'], seen['at'] = report, eventtime + fresh = (seen['at'] is not None + and seen['at'] - started >= CAL_STATUS_SETTLED_S) + if fresh and report.get('status') == 'ready': + if eventtime >= started + expected - CAL_V1_STOP_MARGIN_S: + move['stop_ignored'] = True + self.log_error( + '[multiACE] ACE %d did not stop the unwind - ' + 'booked the full %d mm' % (idx, int(move['length']))) + finalize() + return + if eventtime - started > expected + 8.0 + CAL_STATUS_SETTLED_S: + move['stop_ignored'] = True + finalize() + return + self.reactor.register_callback(_poll, eventtime + 0.1) + self.reactor.register_callback(_poll, self.reactor.monotonic() + 0.1) + + def _calibration_accumulate_decoder(self, move): + # Returns False when the standstill guard failed the session, so the + # caller must not carry on and overwrite the failed state. + dec = move.get('dec') + if dec is None: + return True + span = max(0, int(round(dec))) + commanded = float(move.get('length', 0.0)) + kind = str(move.get('kind') or '') + logging.info('[multiACE] calibration move %s: commanded %.0f mm, ' + 'encoder %d' % (kind or '?', commanded, span)) # Command 76 standstill guard: a long commanded move whose lane # encoder never moved is a stall, not a slow start. Same verdict the @@ -10264,21 +10529,19 @@ def _calibration_accumulate_decoder(self, move): # turns every decoder-based verdict off. A protocol without the # reading never gets here: the None check above returns first, which # is what keeps V1 and any firmware that omits the field out of it. - commanded = float(move.get('length', 0.0)) if (self.unload_decoder_gate and commanded >= CAL_DECODER_MIN_LEN and span < CAL_DECODER_MIN_MOVE): self._calibration_fail( 'Standstill guard (cmd 76): commanded %.0f mm, the lane ' 'encoder moved %d' % (commanded, span)) - return + return False - kind = str(move.get('kind') or '') if kind.startswith('verify_'): current = self._calibration.get('verify_decoder_span') self._calibration['verify_decoder_span'] = span \ if current is None else int(current) + span - return + return True current = self._calibration.get('decoder_span') self._calibration['decoder_span'] = span if current is None \ else int(current) + span @@ -10287,6 +10550,7 @@ def _calibration_accumulate_decoder(self, move): current = self._calibration.get(key) self._calibration[key] = span if current is None \ else int(current) + span + return True def _calibration_finish_return(self): c = self._calibration @@ -10320,9 +10584,7 @@ def _calibration_verify_position(self, eventtime, commit=False): move = self._calibration_move if move is None or not str(move.get('kind', '')).startswith('verify_'): return int(c.get('verify_position_mm') or 0) - elapsed = max(0.0, eventtime - float(move['started'])) - travelled = min(float(move['length']), - elapsed * float(move['speed'])) + travelled = self._calibration_move_travel(move, eventtime) position = (float(move.get('position_start', 0)) + float(move.get('direction', 1)) * travelled) load = int(c.get('load_length_mm') or 0) @@ -10332,40 +10594,126 @@ def _calibration_verify_position(self, eventtime, commit=False): upper = load + fine_limit else: lower, upper = 0, load + if move.get('kind') == 'verify_feed': + upper = load + int(c.get('verify_search_mm') or 0) position = max(float(lower), min(float(upper), position)) c['verify_position_mm'] = int(round(position)) c['tip_position_mm'] = c['verify_position_mm'] if commit: + self._calibration_verify_book_encoder(move, complete=False) self._calibration_accumulate_decoder(move) self._calibration_move = None return c['verify_position_mm'] - def _calibration_verify_hold_toolhead(self, eventtime): + def _calibration_verify_book_encoder(self, move, complete): + # Filament this verify run moved each way, by the lane encoder. The + # return to park pulls back what was fed, so the ACE moving more per + # mm one way than the other does not shift the park position. + c = self._calibration + dec = move.get('dec') + if dec is None: + c['verify_encoder_ok'] = False + return + if int(move.get('direction', 1)) > 0: + c['verify_fed_counts'] = float( + c.get('verify_fed_counts') or 0.0) + float(dec) + return + c['verify_back_counts'] = float( + c.get('verify_back_counts') or 0.0) + float(dec) + length = float(move.get('length') or 0.0) + if complete and length >= CAL_VERIFY_RATIO_MIN_MM: + c['verify_ratio_counts'] = float( + c.get('verify_ratio_counts') or 0.0) + float(dec) + c['verify_ratio_mm'] = float( + c.get('verify_ratio_mm') or 0.0) + length + + def _calibration_verify_encoder_return(self, position): + # Commanded mm still to pull back to reach park, from the encoder; + # None when the encoder did not see the whole run (V1, missed + # readings) or the result is implausible next to the booked one. + c = self._calibration + ratio_mm = float(c.get('verify_ratio_mm') or 0.0) + fed = float(c.get('verify_fed_counts') or 0.0) + if (c.get('protocol') != 'v2' or not c.get('verify_encoder_ok') + or ratio_mm <= 0 or fed <= 0): + return None + ratio = float(c.get('verify_ratio_counts') or 0.0) / ratio_mm + if not (CAL_ENCODER_RATIO_MIN <= ratio <= CAL_ENCODER_RATIO_MAX): + return None + rest = (fed - float(c.get('verify_back_counts') or 0.0)) / ratio + load = int(c.get('load_length_mm') or 0) + if abs(rest - position) > max(30.0, 0.05 * load): + return None + return max(0, int(round(rest))) + + def _calibration_verify_swap(self): + # The hub clearance under test: what the page sent (the value that + # will be saved, margin included) or else the session's own. + c = self._calibration + return int(c.get('verify_swap_mm') + or c.get('swap_retract_length_mm') or 0) + + def _calibration_verify_at_toolhead(self, actual): + # The sensor is the physical reference. Within the deadband the + # calibrated distance stands; outside it the verify run IS the new + # measurement, so the rest of the round trip (splitter stop, return + # to preload) and the proposed config values use the real distance. c = self._calibration - actual = self._calibration_verify_position(eventtime, commit=True) + self._calibration_send_stop() + self._calibration_timer = None + if c.get('state') == 'failed': + return target = int(c.get('load_length_mm') or 0) + delta = actual - target + position = target + if abs(delta) > max(CAL_VERIFY_DEADBAND_MM, + CAL_VERIFY_TOLERANCE * target): + swap = self._calibration_verify_swap() + if actual <= swap: + self._calibration_fail( + 'toolhead sensor triggered after %d mm, before the ' + 'splitter distance of %d mm - recalibrate this route' + % (actual, swap)) + return + c['load_length_mm'] = actual + c['retract_length_mm'] = actual + c['verify_remeasured_mm'] = ( + int(c.get('verify_remeasured_mm') or 0) + delta) + position = actual c['verify_feed_actual_mm'] = actual - c['verify_feed_delta_mm'] = actual - target - c['verify_fine_offset_mm'] = actual - target - c['verify_position_mm'] = target - c['tip_position_mm'] = target - c['verify_phase'] = 'toolhead' - c['state'] = 'verify_toolhead' + c['verify_feed_delta_mm'] = delta + c['verify_fine_offset_mm'] = 0 + c['verify_position_mm'] = position + c['tip_position_mm'] = position c['toolhead_sensor'] = True - self._calibration_send_stop() - self._calibration_timer = None self.log_always( - '[multiACE] Verification paused at toolhead sensor ' - '(actual~%dmm, calibrated=%dmm)' % (actual, target)) + '[multiACE] Verification reached the toolhead sensor at ~%dmm ' + '(calibrated %dmm, %s)' + % (actual, target, + 'distance re-measured' if position != target + else 'confirmed')) + # The sensor has confirmed the endpoint - nothing for the user to + # look at here. Carry straight on to the hub clearance point, which + # is the one stop that needs eyes. + c['verify_phase'] = 'splitter_move' + c['state'] = 'verifying_splitter' + self._calibration_move = None + self._calibration_arm_timer() + + def _calibration_verify_hold_toolhead(self, eventtime): + self._calibration_settle( + lambda: self._calibration_verify_at_toolhead( + self._calibration_verify_position(eventtime, commit=True))) def _calibration_verify_enter_adjust(self): c = self._calibration target = int(c.get('load_length_mm') or 0) - c['verify_position_mm'] = target - c['tip_position_mm'] = target - c['verify_feed_actual_mm'] = target - c['verify_feed_delta_mm'] = 0 - c['verify_fine_offset_mm'] = 0 + position = int(c.get('verify_position_mm') or target) + c['verify_position_mm'] = position + c['tip_position_mm'] = position + c['verify_feed_actual_mm'] = position + c['verify_feed_delta_mm'] = position - target + c['verify_fine_offset_mm'] = position - target c['verify_phase'] = 'toolhead_adjust' c['state'] = 'verify_toolhead_adjust' c['toolhead_sensor'] = False @@ -10373,49 +10721,43 @@ def _calibration_verify_enter_adjust(self): self._calibration_move = None self._calibration_timer = None self.log_always( - '[multiACE] Verification reached the calibrated toolhead ' - 'endpoint with the sensor clear; bounded fine positioning ' - 'is available') + '[multiACE] Verification searched %dmm past the calibrated ' + 'toolhead endpoint with the sensor still clear; manual fine ' + 'positioning is available' % (position - target)) def _calibration_verify_hold_after_adjust(self, eventtime): - c = self._calibration - actual = self._calibration_verify_position(eventtime, commit=True) - target = int(c.get('load_length_mm') or 0) - offset = actual - target - c['verify_feed_actual_mm'] = actual - c['verify_feed_delta_mm'] = offset - c['verify_fine_offset_mm'] = offset - c['verify_position_mm'] = actual - c['tip_position_mm'] = actual - c['verify_phase'] = 'toolhead' - c['state'] = 'verify_toolhead' - c['toolhead_sensor'] = True - self._calibration_send_stop() - self._calibration_timer = None - self.log_always( - '[multiACE] Verification fine positioning reached the ' - 'toolhead sensor at %+dmm' % offset) + self._calibration_verify_at_toolhead( + self._calibration_verify_position(eventtime, commit=True)) def _calibration_verify_dispatch(self, eventtime, state): c = self._calibration position = int(c.get('verify_position_mm') or 0) load = int(c.get('load_length_mm') or 0) if state == 'verifying_feed': - remaining = load - position + remaining = (load + int(c.get('verify_search_mm') or 0) + - position) if remaining <= 0: if self._calibration_sensor(c['head']): self._calibration_verify_hold_toolhead(eventtime) else: self._calibration_verify_enter_adjust() return False - # Verification is a continuous traversal, unlike measurement: - # command the complete remaining route and keep polling the - # sensor so the move can still be stopped immediately. - length = remaining + # One continuous move to just short of the calibrated endpoint, + # then short chunks through and past it (see CAL_VERIFY_*). The + # sensor is polled throughout and stops the move immediately. + # With a working lane encoder the position is measured, so the + # steps are not needed: one move, stopped by the sensor. + coarse = load - CAL_VERIFY_FINE_ZONE_MM - position + if c.get('protocol') == 'v2' and c.get('decoder_ok'): + length = remaining + elif coarse >= CAL_VERIFY_CHUNK_MM: + length = coarse + else: + length = min(CAL_VERIFY_CHUNK_MM, remaining) kind, method, direction = 'verify_feed', 'feed_filament', 1 speed = c['speed_mm_s'] elif state == 'verifying_splitter': - target = load - int(c.get('swap_retract_length_mm') or 0) + target = load - self._calibration_verify_swap() remaining = position - target if remaining <= 0: c['verify_position_mm'] = target @@ -10430,6 +10772,9 @@ def _calibration_verify_dispatch(self, eventtime, state): speed = min(30, self.get_retract_speed(c['ace'])) else: remaining = position + by_encoder = self._calibration_verify_encoder_return(position) + if by_encoder is not None: + remaining = by_encoder if by_encoder >= 5 else 0 if remaining <= 0: if self._calibration_sensor(c['head']) is True: self._calibration_fail( @@ -10456,7 +10801,8 @@ def _calibration_verify_dispatch(self, eventtime, state): 'id': move_id, 'kind': kind, 'length': int(length), 'speed': speed, 'started': eventtime, 'position_start': position, 'direction': direction, - 'decoder_min': None, 'decoder_max': None, + 'dec': None, + 'method': method, 'next_decoder_sample': eventtime, 'error': None, } self._calibration_move = move @@ -10502,12 +10848,34 @@ def _calibration_verify_timer_tick(self, eventtime): ready = (self._info_per_ace.get(idx, {}) or {}).get( 'status') == 'ready' if ready and elapsed >= max(0.15, expected * 0.80): - c['verify_position_mm'] = int( + covered = move['length'] + if (move.get('kind') == 'verify_feed' + and move.get('dec') is not None): + self._calibration_learn_encoder(move) + covered = self._calibration_move_travel(move, eventtime) + c['verify_position_mm'] = int(round( move.get('position_start', 0) - + move.get('direction', 1) * move['length']) + + move.get('direction', 1) * covered)) c['tip_position_mm'] = c['verify_position_mm'] - self._calibration_accumulate_decoder(move) + self._calibration_verify_book_encoder(move, complete=True) + if not self._calibration_accumulate_decoder(move): + self._calibration_move = None + self._calibration_timer = None + return self.reactor.NEVER self._calibration_move = None + if state == 'verify_splitter_adjusting': + # Pulled back further at the hub stop: that distance + # belongs to the hub clearance of this run. It is kept + # apart from the measured value, which the margin is + # added to on saving. + extra = int(move['length']) + c['verify_swap_mm'] = self._calibration_verify_swap() + extra + c['verify_hub_extra_mm'] = int( + c.get('verify_hub_extra_mm') or 0) + extra + c['state'] = 'verify_splitter' + c['verify_phase'] = 'splitter' + self._calibration_timer = None + return self.reactor.NEVER if state == 'verify_toolhead_adjusting': offset = (int(c['verify_position_mm']) - int(c.get('load_length_mm') or 0)) @@ -10517,8 +10885,8 @@ def _calibration_verify_timer_tick(self, eventtime): limit = int(c.get('verify_fine_limit_mm') or 50) if abs(offset) >= limit: self._calibration_fail( - 'toolhead sensor was not reached within the ' - 'verification fine-position limit') + 'toolhead sensor was not reached within %d mm ' + 'of the calibrated endpoint' % limit) self._calibration_timer = None return self.reactor.NEVER c['state'] = 'verify_toolhead_adjust' @@ -10537,17 +10905,31 @@ def _calibration_verify_timer_tick(self, eventtime): return self.reactor.NEVER return eventtime + 0.05 + def _calibration_jog_reached_sensor(self): + c = self._calibration + self._calibration_move = None + c['commanded_retract_mm'] = 0 + c['tip_position_mm'] = int(c.get('load_length_mm') or 0) + c['toolhead_sensor'] = True + c['state'] = 'retract_ready' + self.log_always('[multiACE] Calibration forward move stopped at the ' + 'toolhead sensor') + + def _calibration_feed_reached(self, eventtime): + self._calibration_settle( + lambda: self._calibration_finish_feed(eventtime)) + def _calibration_finish_feed(self, eventtime): c = self._calibration move = self._calibration_move partial = 0.0 if move is not None: - elapsed = max(0.0, eventtime - move['started']) - partial = min(float(move['length']), - elapsed * float(move['speed'])) - self._calibration_accumulate_decoder(move) - c['commanded_feed_mm'] = int(round( - float(c.get('commanded_feed_mm', 0)) + partial)) + partial = self._calibration_move_travel(move, eventtime) + if not self._calibration_accumulate_decoder(move): + self._calibration_move = None + return + c['feed_travel_mm'] = float(c.get('feed_travel_mm') or 0.0) + partial + c['commanded_feed_mm'] = int(round(c['feed_travel_mm'])) c['load_length_mm'] = c['commanded_feed_mm'] c['tip_position_mm'] = c['load_length_mm'] c['toolhead_sensor'] = True @@ -10557,15 +10939,16 @@ def _calibration_finish_feed(self, eventtime): self._calibration_send_stop() self.log_always( '[multiACE] Calibration load sensor reached at ~%dmm ' - '(ACE %d slot %d -> head %d)' - % (c['load_length_mm'], c['ace'], c['slot'], c['head'])) + '(ACE %d slot %d -> head %d, %s)' + % (c['load_length_mm'], c['ace'], c['slot'], c['head'], + c.get('position_source') or 'timing')) def _calibration_timer_tick(self, eventtime): c = self._calibration state = c.get('state') verify_moving = ( 'verifying_feed', 'verifying_splitter', 'verifying_return', - 'verify_toolhead_adjusting') + 'verify_toolhead_adjusting', 'verify_splitter_adjusting') if state in verify_moving: idx = c.get('ace') if not self._connected_per_ace.get(idx, False): @@ -10573,7 +10956,8 @@ def _calibration_timer_tick(self, eventtime): self._calibration_timer = None return self.reactor.NEVER return self._calibration_verify_timer_tick(eventtime) - if state not in ('feeding', 'retracting', 'returning', 'feed_jogging'): + if state not in ('feeding', 'retracting', 'returning', + 'feed_jogging', 'homing'): self._calibration_timer = None return self.reactor.NEVER idx, slot, head = c['ace'], c['slot'], c['head'] @@ -10587,18 +10971,25 @@ def _calibration_timer_tick(self, eventtime): move = self._calibration_move if state == 'feeding' and detected is True: - self._calibration_finish_feed(eventtime) - self._calibration_timer = None + self._calibration_feed_reached(eventtime) + return self.reactor.NEVER + if state == 'feed_jogging' and detected is True: + # A forward correction is bounded by the booked pull-back, which + # can be off; the sensor is the real end of the route. + self._calibration_settle(self._calibration_jog_reached_sensor) return self.reactor.NEVER if move is not None: if state == 'feeding': - elapsed = max(0.0, eventtime - float(move['started'])) - partial = min(float(move['length']), - elapsed * float(move['speed'])) c['tip_position_mm'] = max(0, int(round( - int(c.get('commanded_feed_mm') or 0) + partial))) + float(c.get('feed_travel_mm') or 0.0) + + self._calibration_move_travel(move, eventtime)))) + elif state == 'homing': + c['tip_position_mm'] = max(0, int(round( + float(move['tip_start']) + - self._calibration_move_travel(move, eventtime)))) elif state in ('retracting', 'returning'): + c['position_source'] = 'timing' elapsed = max(0.0, eventtime - float(move['started'])) partial = min(float(move['length']), elapsed * float(move['speed'])) @@ -10606,6 +10997,12 @@ def _calibration_timer_tick(self, eventtime): int(c.get('load_length_mm') or 0) - int(c.get('commanded_retract_mm') or 0) - partial))) + elif state == 'feed_jogging': + # forward correction: the page shows how far it has to go + c['tip_position_mm'] = max(0, int(round( + int(c.get('load_length_mm') or 0) + - int(c.get('commanded_retract_mm') or 0) + + self._calibration_move_travel(move, eventtime)))) if move.get('error'): self._calibration_fail(move['error']) self._calibration_timer = None @@ -10625,11 +11022,27 @@ def _calibration_timer_tick(self, eventtime): if ready and elapsed >= max(0.15, expected * 0.80): # A forward jog is a correction, not a measurement - keep it # out of the retract decoder span. - if state != 'feed_jogging': - self._calibration_accumulate_decoder(move) + if state == 'homing': + c['tip_position_mm'] = max(0, int(round( + float(move['tip_start']) - float(move['length'])))) + self._calibration_move = None + return eventtime + 0.05 + if (state != 'feed_jogging' + and not self._calibration_accumulate_decoder(move)): + self._calibration_move = None + self._calibration_timer = None + return self.reactor.NEVER + self._calibration_learn_encoder(move) if state == 'feeding': c['commanded_feed_mm'] = int( c.get('commanded_feed_mm', 0)) + int(move['length']) + # A chunk the ACE cut short counts as what the encoder + # saw, not as what was asked for. + c['feed_travel_mm'] = ( + float(c.get('feed_travel_mm') or 0.0) + + (self._calibration_move_travel(move, eventtime) + if move.get('dec') is not None + else float(move['length']))) self._calibration_move = None if c['commanded_feed_mm'] >= c['max_feed_mm']: self._calibration_fail( @@ -10679,22 +11092,48 @@ def _calibration_timer_tick(self, eventtime): info = self._info_per_ace.get(idx, {}) or {} if info.get('status') != 'ready': return eventtime + 0.10 - if state == 'returning': + tip_start = None + if state == 'homing': + remaining = int(round(float(c.get('tip_position_mm') or 0))) + if remaining <= 0: + self._calibration_timer = None + self._calibration_move = None + self._calibration_restore_active() + self._calibration = self._calibration_idle_state() + self.log_always( + '[multiACE] Calibration filament returned to the ACE') + return self.reactor.NEVER + length = min(500, remaining) + tip_start = remaining + kind = 'home' + method = 'unwind_filament' + speed = min(30, self.get_retract_speed(idx)) + c['home_seq'] = int(c.get('home_seq') or 0) + 1 + move_id = 900000 + c['home_seq'] + elif state == 'returning': remaining = (int(c.get('load_length_mm') or 0) - int(c.get('commanded_retract_mm') or 0)) if remaining <= 0: self._calibration_finish_return() self._calibration_timer = None return self.reactor.NEVER - length = min(500, remaining) + # One move: a stop books the part that was covered, so there is + # nothing to gain from chunks. + length = remaining kind = 'return' method = 'unwind_filament' speed = min(30, self.get_retract_speed(idx)) move_id = int(c.get('commanded_retract_mm', 0)) + 1 else: remaining = int(c['max_feed_mm']) - int(c['commanded_feed_mm']) - length = min(500 if not c.get('commanded_feed_mm') else 100, - remaining) + # 500 mm first, then 100 mm steps - unless the lane encoder has + # reported during the first chunk: then the position is measured + # and the rest is one move, stopped by the sensor. + if c.get('commanded_feed_mm') and c.get('decoder_ok'): + length = remaining + else: + length = min(500 if not c.get('commanded_feed_mm') else 100, + remaining) kind = 'feed' method = 'feed_filament' speed = c['speed_mm_s'] @@ -10707,7 +11146,8 @@ def _calibration_timer_tick(self, eventtime): move = { 'id': move_id, 'kind': kind, 'length': length, 'speed': speed, 'started': eventtime, - 'decoder_min': None, 'decoder_max': None, + 'dec': None, 'tip_start': tip_start, + 'method': method, 'next_decoder_sample': eventtime, 'error': None, } self._calibration_move = move @@ -10733,11 +11173,13 @@ def _calibration_arm_timer(self): '[multiACE] Start Bowden calibration. ' 'ACE_CALIBRATION_START ACE=0 SLOT=0 HEAD=0 SCOPE=ace|slot') def cmd_ACE_CALIBRATION_START(self, gcmd): - if self._calibration.get('state') in ( - 'feeding', 'retracting', 'returning', 'feed_jogging', - 'verifying_feed', 'verifying_splitter', - 'verifying_return', 'verify_toolhead_adjusting'): + if self._calibration.get('state') in CAL_MOVING_STATES: raise gcmd.error('[multiACE] calibration movement already active') + if self._calibration_route_occupied() is not None: + # Starting from anywhere but the park position measures short. + raise gcmd.error( + '[multiACE] calibration filament is still in the tube - ' + 'return it to the ACE first') ace = gcmd.get_int('ACE') slot = gcmd.get_int('SLOT') head = gcmd.get_int('HEAD') @@ -10799,7 +11241,9 @@ def cmd_ACE_CALIBRATION_START(self, gcmd): 'protocol': protocol, 'toolhead_sensor': False, 'tip_position_mm': 0, - 'max_feed_mm': min(5000, max(100, effective_load + 300)), + # The configured load length is the very value being measured, + # so it must not decide whether the measurement can succeed. + 'max_feed_mm': min(5000, max(100, effective_load + 1000)), 'speed_mm_s': min(30, self.get_feed_speed(ace)), 'previous_load_length': effective_load, 'previous_swap_retract_length': @@ -10816,7 +11260,7 @@ def cmd_ACE_CALIBRATION_START(self, gcmd): '[multiACE] Feed a prepared calibration path to its toolhead sensor') def cmd_ACE_CALIBRATION_FEED(self, gcmd): c = self._calibration - if c.get('state') != 'prepared': + if c.get('state') not in ('prepared', 'feed_paused'): raise gcmd.error('[multiACE] calibration is not prepared') if self._calibration_sensor(c['head']): raise gcmd.error('[multiACE] toolhead sensor is already active') @@ -10832,21 +11276,22 @@ def cmd_ACE_CALIBRATION_RETRACT(self, gcmd): if c.get('state') not in ('at_sensor', 'retract_ready', 'swap_marked'): raise gcmd.error('[multiACE] calibration is not ready to retract') length = gcmd.get_int('LENGTH') - if length < 5 or length > 500: - raise gcmd.error('[multiACE] calibration retract LENGTH must be 5-500') + if length < 5 or length > 5000: + raise gcmd.error('[multiACE] calibration retract LENGTH must be 5-5000') if int(c.get('commanded_retract_mm', 0)) + length \ > int(c.get('load_length_mm') or 0): raise gcmd.error( '[multiACE] calibration retract would pass the ACE preload ' 'reference') - self.wait_ace_ready_on(c['ace']) + self._calibration_require_ready(gcmd, c['ace']) move_id = int(c.get('commanded_retract_mm', 0)) + 1 now = self.reactor.monotonic() move = { 'id': move_id, 'kind': 'retract', 'length': length, 'speed': min(30, self.get_retract_speed(c['ace'])), 'started': now, - 'decoder_min': None, 'decoder_max': None, + 'dec': None, + 'method': 'unwind_filament', 'next_decoder_sample': now, 'error': None, } self._calibration_move = move @@ -10865,7 +11310,7 @@ def cmd_ACE_CALIBRATION_RETRACT(self, gcmd): cmd_ACE_CALIBRATION_FEED_JOG_help = ( '[multiACE] Calibration forward jog: correct an over-retract while ' - 'clearing the splitter. LENGTH=5..500 mm. Feeds toward the toolhead ' + 'clearing the splitter. LENGTH=5..5000 mm. Feeds toward the toolhead ' 'and reduces the cumulative retract - never past the sensor start.') def cmd_ACE_CALIBRATION_FEED_JOG(self, gcmd): c = self._calibration @@ -10876,22 +11321,23 @@ def cmd_ACE_CALIBRATION_FEED_JOG(self, gcmd): raise gcmd.error( '[multiACE] calibration is not ready for a forward jog') length = gcmd.get_int('LENGTH') - if length < 5 or length > 500: + if length < 5 or length > 5000: raise gcmd.error( - '[multiACE] calibration jog LENGTH must be 5-500') + '[multiACE] calibration jog LENGTH must be 5-5000') # Mirror of the retract guard: never feed back past the toolhead # sensor start (commanded_retract == 0), which would re-trigger it. if int(c.get('commanded_retract_mm', 0)) - length < 0: raise gcmd.error( '[multiACE] forward jog would pass the toolhead sensor start') - self.wait_ace_ready_on(c['ace']) + self._calibration_require_ready(gcmd, c['ace']) move_id = int(c.get('commanded_retract_mm', 0)) + 1 now = self.reactor.monotonic() move = { 'id': move_id, 'kind': 'feed_jog', 'length': length, 'speed': min(30, self.get_feed_speed(c['ace'])), 'started': now, - 'decoder_min': None, 'decoder_max': None, + 'dec': None, + 'method': 'feed_filament', 'next_decoder_sample': now, 'error': None, } self._calibration_move = move @@ -10923,11 +11369,20 @@ def cmd_ACE_CALIBRATION_MARK(self, gcmd): raise gcmd.error('[multiACE] swap checkpoint must be positive') if value >= int(c.get('load_length_mm') or 0): raise gcmd.error( - '[multiACE] splitter checkpoint must be before the ACE ' - 'preload reference') + '[multiACE] the filament is pulled all the way back to ' + 'the park position - move it toward the toolhead until ' + 'the tip is just past the hub, then mark') c['swap_retract_length_mm'] = value c['state'] = 'swap_marked' return + if mark == 'undo': + if c.get('state') not in ('swap_marked', 'return_paused'): + raise gcmd.error( + '[multiACE] there is no hub mark to undo here - stop ' + 'the return first') + c['swap_retract_length_mm'] = None + c['state'] = 'retract_ready' + return if mark == 'full': if c.get('state') != 'swap_marked': raise gcmd.error('[multiACE] mark swap checkpoint first') @@ -10940,7 +11395,7 @@ def cmd_ACE_CALIBRATION_MARK(self, gcmd): 'preload reference') self._calibration_finish_return() return - raise gcmd.error('[multiACE] MARK must be swap or full') + raise gcmd.error('[multiACE] MARK must be swap, undo or full') cmd_ACE_CALIBRATION_RETURN_help = ( '[multiACE] Return from the splitter checkpoint to the ACE preload ' @@ -10969,6 +11424,22 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): 'verifying_feed', 'verifying_splitter', 'verifying_return') if action == 'start': configured_start = False + # The values to test - what Save would write: LOAD = feed + # budget, SWAP = hub clearance (both incl. margin), RETRACT = + # toolhead distance. Without them the session's own values. + given = [gcmd.get_int(key, None) + for key in ('LOAD', 'SWAP', 'RETRACT')] + if any(value is not None for value in given): + if not all(value is not None for value in given): + raise gcmd.error( + '[multiACE] verification LOAD, SWAP and RETRACT go ' + 'together') + if not (0 < given[1] < given[2] <= given[0]): + raise gcmd.error( + '[multiACE] verification values must satisfy ' + '0 < SWAP < RETRACT <= LOAD') + else: + given = None requested_ace = gcmd.get_int('ACE', None) requested_slot = gcmd.get_int('SLOT', None) requested_head = gcmd.get_int('HEAD', None) @@ -11020,10 +11491,15 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): raise gcmd.error('[multiACE] verification ACE out of range') if slot < 0 or slot > 3: raise gcmd.error('[multiACE] verification SLOT must be 0-3') - configured_retract = int( - self.get_retract_length(ace, slot) or 0) - configured_swap = int( - self.get_swap_retract_length(ace, slot) or 0) + if given is not None: + # values the page has (e.g. saved, Klipper not + # restarted yet) take precedence over the loaded ones + configured_retract, configured_swap = given[2], given[1] + else: + configured_retract = int( + self.get_retract_length(ace, slot) or 0) + configured_swap = int( + self.get_swap_retract_length(ace, slot) or 0) if (configured_retract <= 0 or configured_swap <= 0 or configured_swap >= configured_retract): raise gcmd.error( @@ -11042,6 +11518,7 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): 'verify_phase': 'ready', 'verify_position_mm': 0, 'tip_position_mm': 0, + 'values_source': 'saved', }) configured_start = True if c.get('state') not in ('complete', 'verified'): @@ -11052,6 +11529,11 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): if load <= 0 or swap <= 0 or swap >= load: raise gcmd.error( '[multiACE] calibrated route anchors are invalid') + if given is not None: + verify_swap = min(given[1], load - 10) + search = max(0, given[0] - load) + else: + verify_swap, search = swap, CAL_VERIFY_SEARCH_MM detected = self._calibration_sensor(c['head']) if detected is None: raise gcmd.error( @@ -11062,12 +11544,13 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): if not self._ensure_ace_available(c['ace']): raise gcmd.error('[multiACE] selected ACE is not connected') if not configured_start: + # refuse a busy unit before anything is switched + self._calibration_require_ready(gcmd, c['ace']) self._calibration_prev_ace = self._active_device_index if not self._switch_ace_for_head_target(c['ace']): raise gcmd.error( '[multiACE] failed to activate selected ACE') self._disable_feed_assist_all() - self.wait_ace_ready_on(c['ace']) c.update({ 'state': 'verifying_feed', 'verify_phase': 'feed', @@ -11077,9 +11560,21 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): 'verify_feed_actual_mm': None, 'verify_feed_delta_mm': None, 'verify_fine_offset_mm': 0, - 'verify_fine_limit_mm': 50, + 'verify_fine_limit_mm': search + 50, + 'verify_search_mm': search, + 'verify_swap_mm': verify_swap, + 'verify_load_budget_mm': load + search, + 'verify_remeasured_mm': None, + # verify_hub_extra_mm is NOT reset: it adds up over the runs + # of one session (a new session starts from 0), and the page + # sends SWAP with it included 'verify_decoder_span': None, 'verify_move_seq': 0, + 'verify_fed_counts': 0.0, + 'verify_back_counts': 0.0, + 'verify_ratio_counts': 0.0, + 'verify_ratio_mm': 0.0, + 'verify_encoder_ok': True, 'toolhead_sensor': False, 'error': None, }) @@ -11115,8 +11610,8 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): if abs(target_offset) > limit: raise gcmd.error( '[multiACE] verification jog would exceed the ' - '+/-50 mm fine-position limit') - self.wait_ace_ready_on(c['ace']) + '+/-%d mm fine-position limit' % limit) + self._calibration_require_ready(gcmd, c['ace']) c['verify_move_seq'] = int(c.get('verify_move_seq') or 0) + 1 move_id = c['verify_move_seq'] direction = 1 if delta > 0 else -1 @@ -11129,7 +11624,8 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): else 'verify_adjust_retract'), 'length': abs(delta), 'speed': speed, 'started': now, 'position_start': position, 'direction': direction, - 'decoder_min': None, 'decoder_max': None, + 'dec': None, + 'method': method, 'next_decoder_sample': now, 'error': None, } self._calibration_move = move @@ -11165,35 +11661,220 @@ def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): self._calibration_move = None self._calibration_arm_timer() return - if action == 'pause': - if c.get('state') not in moving: - raise gcmd.error('[multiACE] verification is not moving') + if action == 'hub_jog': + if c.get('state') != 'verify_splitter': + raise gcmd.error( + '[multiACE] verification is not waiting at the hub') + length = gcmd.get_int('LENGTH') + if length not in (5, 10, 25, 50): + raise gcmd.error( + '[multiACE] hub jog LENGTH must be 5, 10, 25 or 50 mm') + position = int(c.get('verify_position_mm') or 0) + if position - length < 10: + raise gcmd.error( + '[multiACE] that would pull the filament back to the ' + 'park position') + self._calibration_require_ready(gcmd, c['ace']) + c['verify_move_seq'] = int(c.get('verify_move_seq') or 0) + 1 + move_id = c['verify_move_seq'] + speed = min(30, self.get_retract_speed(c['ace'])) now = self.reactor.monotonic() + self._calibration_move = { + 'id': move_id, 'kind': 'verify_hub_adjust', + 'length': length, 'speed': speed, 'started': now, + 'position_start': position, 'direction': -1, + 'dec': None, + 'method': 'unwind_filament', + 'next_decoder_sample': now, 'error': None, + } + c['state'] = 'verify_splitter_adjusting' + c['verify_phase'] = 'splitter_adjusting' + c['error'] = None + try: + self.send_request_to(c['ace'], { + 'method': 'unwind_filament', + 'params': {'index': c['slot'], 'length': length, + 'speed': speed}}, + lambda self, response, _sid=c['session_id'], + _mid=move_id: self._calibration_move_callback( + response, _sid, _mid)) + except Exception as e: + self._calibration_fail( + 'failed to start the hub pull-back: %s' % e) + raise gcmd.error('[multiACE] failed to start the hub jog') + self._calibration_arm_timer() + return + if action == 'pause': + self._calibration_verify_pause(gcmd) + return + if action == 'resume': + self._calibration_verify_resume(gcmd) + return + raise gcmd.error( + '[multiACE] verification ACTION must be ' + 'start, continue, pause, resume, jog or hub_jog') + + _CAL_VERIFY_MOVING = ( + 'verifying_feed', 'verifying_splitter', 'verifying_return') + + def _calibration_verify_pause(self, gcmd): + c = self._calibration + if c.get('state') not in self._CAL_VERIFY_MOVING: + raise gcmd.error('[multiACE] verification is not moving') + now = self.reactor.monotonic() + move = self._calibration_move + + def _commit(): c['verify_position_mm'] = self._calibration_verify_position( now, commit=True) + if c.get('state') == 'failed': + self._calibration_stop_timer() + return c['verify_paused_from'] = c['state'] c['state'] = 'verify_paused' c['verify_phase'] = 'paused' - self._calibration_send_stop() + + if (c.get('protocol') != 'v2' and move is not None + and move.get('method') == 'unwind_filament'): + # same as a stop: book only what the V1 unit confirms self._calibration_stop_timer() + self._calibration_send_stop(move) + self._calibration_settle_v1_unwind(_commit) return - if action == 'resume': - previous = c.get('verify_paused_from') - if c.get('state') != 'verify_paused' or previous not in moving: - raise gcmd.error('[multiACE] verification is not paused') - c['state'] = previous - c['verify_phase'] = { - 'verifying_feed': 'feed', - 'verifying_splitter': 'splitter_move', - 'verifying_return': 'return', - }[previous] - c['verify_paused_from'] = None + _commit() + self._calibration_send_stop(move) + self._calibration_stop_timer() + + def _calibration_verify_resume(self, gcmd): + c = self._calibration + previous = c.get('verify_paused_from') + if (c.get('state') != 'verify_paused' + or previous not in self._CAL_VERIFY_MOVING): + raise gcmd.error('[multiACE] verification is not paused') + c['state'] = previous + c['verify_phase'] = { + 'verifying_feed': 'feed', + 'verifying_splitter': 'splitter_move', + 'verifying_return': 'return', + }[previous] + c['verify_paused_from'] = None + self._calibration_move = None + self._calibration_arm_timer() + + cmd_ACE_CALIBRATION_STOP_help = ( + '[multiACE] Stop the current calibration move and keep the distance ' + 'it has covered') + def cmd_ACE_CALIBRATION_STOP(self, gcmd): + c = self._calibration + state = c.get('state') + if state in self._CAL_VERIFY_MOVING: + self._calibration_verify_pause(gcmd) + return + if state not in ('feeding', 'retracting', 'feed_jogging', + 'returning', 'homing'): + raise gcmd.error('[multiACE] no calibration movement to stop') + now = self.reactor.monotonic() + self._calibration_stop_timer() + + def _book(): + cc = self._calibration + move = self._calibration_move + travel = 0.0 + if move is not None: + travel = self._calibration_move_travel(move, now) + if (state not in ('feed_jogging', 'homing') + and not self._calibration_accumulate_decoder(move)): + self._calibration_move = None + return self._calibration_move = None - self._calibration_arm_timer() + load = int(cc.get('load_length_mm') or 0) + retract = int(cc.get('commanded_retract_mm') or 0) + if state == 'feeding': + cc['feed_travel_mm'] = float( + cc.get('feed_travel_mm') or 0.0) + travel + cc['commanded_feed_mm'] = int( + cc.get('commanded_feed_mm') or 0) + int(round(travel)) + cc['tip_position_mm'] = int(round(cc['feed_travel_mm'])) + cc['state'] = 'feed_paused' + elif state == 'homing': + cc['tip_position_mm'] = max(0, int(round( + float(move['tip_start']) - travel))) if move else int( + round(float(cc.get('tip_position_mm') or 0))) + cc['state'] = 'cancelled' + cc['error'] = 'stopped while returning to the ACE' + elif state == 'feed_jogging': + retract = max(0, retract - int(round(travel))) + cc['commanded_retract_mm'] = retract + cc['tip_position_mm'] = max(0, load - retract) + cc['state'] = 'retract_ready' + else: + retract = min(load, retract + int(round(travel))) + cc['commanded_retract_mm'] = retract + cc['tip_position_mm'] = max(0, load - retract) + if state == 'returning': + cc['state'] = 'return_paused' + else: + cc['state'] = ( + 'swap_marked' + if cc.get('swap_retract_length_mm') is not None + else 'retract_ready') + cc['toolhead_sensor'] = self._calibration_sensor(cc['head']) + self._calibration_settle(_book) + + cmd_ACE_CALIBRATION_RESUME_help = ( + '[multiACE] Carry on a stopped calibration feed, return or ' + 'verification') + def cmd_ACE_CALIBRATION_RESUME(self, gcmd): + c = self._calibration + state = c.get('state') + if state == 'verify_paused': + self._calibration_verify_resume(gcmd) return - raise gcmd.error( - '[multiACE] verification ACTION must be ' - 'start, continue, pause, resume, or jog') + if state == 'feed_paused': + c['state'] = 'feeding' + elif state == 'return_paused': + c['state'] = 'returning' + else: + raise gcmd.error('[multiACE] nothing to carry on') + c['error'] = None + self._calibration_move = None + self._calibration_arm_timer() + + cmd_ACE_CALIBRATION_HOME_help = ( + '[multiACE] Return the calibration filament to the ACE from ' + 'wherever it stopped') + def cmd_ACE_CALIBRATION_HOME(self, gcmd): + c = self._calibration + if c.get('state') in CAL_MOVING_STATES: + raise gcmd.error('[multiACE] stop the current movement first') + if self._calibration_route_occupied() is None: + raise gcmd.error( + '[multiACE] no calibration filament is out of the ACE') + ace = c.get('ace') + ps = self.printer.lookup_object('print_stats', None) + ps_state = (getattr(ps, 'state', '') or '').lower() + if ps_state in ('printing', 'paused'): + raise gcmd.error('[multiACE] requires an idle printer') + if self._swap_in_progress: + raise gcmd.error('[multiACE] a swap is in progress') + if not self._ensure_ace_available(ace): + raise gcmd.error('[multiACE] selected ACE is not connected') + # refuse a busy unit before anything is switched + self._calibration_require_ready(gcmd, ace) + # A failed/cancelled session has already handed the active unit + # back; take it again for the pull-back. + if self._calibration_prev_ace is None: + self._calibration_prev_ace = self._active_device_index + if not self._switch_ace_for_head_target(ace): + raise gcmd.error('[multiACE] failed to activate selected ACE') + self._disable_feed_assist_all() + c['state'] = 'homing' + c['error'] = None + self._calibration_move = None + self.log_always( + '[multiACE] Returning calibration filament to the ACE (~%dmm)' + % int(round(float(c.get('tip_position_mm') or 0)))) + self._calibration_arm_timer() cmd_ACE_CALIBRATION_CANCEL_help = ( '[multiACE] Stop and cancel the active calibration') @@ -11203,10 +11884,7 @@ def cmd_ACE_CALIBRATION_CANCEL(self, gcmd): cmd_ACE_CALIBRATION_RESET_help = ( '[multiACE] Clear a completed/failed/cancelled calibration session') def cmd_ACE_CALIBRATION_RESET(self, gcmd): - if self._calibration.get('state') in ( - 'feeding', 'retracting', 'returning', - 'verifying_feed', 'verifying_splitter', - 'verifying_return', 'verify_toolhead_adjusting'): + if self._calibration.get('state') in CAL_MOVING_STATES: raise gcmd.error('[multiACE] cancel active movement before reset') self._calibration_restore_active() self._calibration_stop_timer() @@ -19484,6 +20162,7 @@ def _tipform_rejected(self, resp): return str(resp.get('msg', '')).strip().upper() == 'FORBIDDEN' def _run_tipform(self, head, temp, soft, nozzle_diameter): + self._check_calibration_unload_cancel() # Inline tip form: the stock INNER_FILAMENT_UNLOAD macro, OR the # same FRAME with a per-material pull table replacing only the # CONTROL_RETRACT_ACTION move step ([ace_tipform] mode: custom). @@ -19530,6 +20209,7 @@ def _run_tipform(self, head, temp, soft, nozzle_diameter): self.gcode.run_script_from_command( "INNER_FILAMENT_UNLOAD TEMP=%d SOFT=%d NOZZLE_DIAMETER=%f\r\n" % (temp, soft, nozzle_diameter)) + self._check_calibration_unload_cancel() return _tf_desc = (('%s %s' % (vendor, material)) if vendor and material else (material.lower() if material else 'default')) @@ -19575,6 +20255,7 @@ def _run_tipform(self, head, temp, soft, nozzle_diameter): fwd_armed = False def _tf_fa_start(): + self._check_calibration_unload_cancel() # Device-truth pre-check: at tipform start the # unload path's rollback-arm often has the slot ALREADY # assisting - re-sending start_feed_assist then answers @@ -19590,6 +20271,7 @@ def _tf_fa_start(): # ~1s backoff: right after a stop the V2 rejects FORBIDDEN # during its wind-down. for _a in range(3): + self._check_calibration_unload_cancel() resp = self._tipform_send(ace_idx, { 'method': 'start_feed_assist', 'params': {'index': slot}}) if not self._tipform_rejected(resp): @@ -19600,6 +20282,7 @@ def _tf_fa_start(): def _tf_unwind(ln): for _a in range(3): + self._check_calibration_unload_cancel() self._tipform_send(ace_idx, { 'method': 'stop_feed_assist', 'params': {'index': slot}}) resp = self._tipform_send(ace_idx, { @@ -19615,8 +20298,10 @@ def _tf_unwind(ln): try: run('MOVE_TO_DISCARD_FILAMENT_POSITION') run('M109 S%d' % int(temp)) + self._check_calibration_unload_cancel() run('M83') for tok in table: + self._check_calibration_unload_cancel() kind = tok[0] if kind == 'move': mm, feed = float(tok[1]), int(tok[2]) @@ -19686,6 +20371,7 @@ def _tf_unwind(ln): break self.reactor.pause( self.reactor.monotonic() + 0.5) + self._check_calibration_unload_cancel() elif kind == 'fan': run('M106 S%d' % int(tok[1])) finally: @@ -19736,6 +20422,14 @@ def cmd_ACE_LOAD_HEAD(self, gcmd): if head < 0 or head > 3: raise self._ace_error(gcmd, 'HEAD must be 0-3', code=200) self._refuse_during_manual_load(gcmd, head) + if (self.head_uses_ace(head) + and self._calibration_route_occupied() is not None): + # The tubes share the hub: a second filament would run into + # the one the calibration left there. + raise self._ace_error( + gcmd, 'Calibration filament is still in the tube - return ' + 'it to the ACE on the Calibration tab first', + code=200, head=head) # A head without ACE (feeder, or a head flagged manual) loads through # the U1 stock side feeder further down, the same path for both. # Hand feeding without the feeder is the display's own manual-load @@ -20091,13 +20785,17 @@ def cmd_ACE_LOAD_HEAD(self, gcmd): cmd_ACE_UNLOAD_HEAD_help = ( '[multiACE] Unload a toolhead back to its ACE. ' - 'Usage: ACE_UNLOAD_HEAD HEAD=0 [RETRACT_LENGTH=] [KEEP_HEAT=]') + 'Usage: ACE_UNLOAD_HEAD HEAD=0 [RETRACT_LENGTH=] ' + '[KEEP_HEAT=] [CALIBRATION_PREPARE=1 ACE=0 SLOT=0]') def cmd_ACE_UNLOAD_HEAD(self, gcmd): head = gcmd.get_int('HEAD') retract_override = gcmd.get_int('RETRACT_LENGTH', 0) keep_heat = gcmd.get_int('KEEP_HEAT', 0) + calibration_prepare = bool(gcmd.get_int('CALIBRATION_PREPARE', 0)) + requested_ace = gcmd.get_int('ACE', None) + requested_slot = gcmd.get_int('SLOT', None) self._last_unload_ok = True self._last_unload_reason = 'toolhead' @@ -20125,6 +20823,10 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): raise self._ace_error(gcmd, 'STAGE= is for feeder/manual heads ' 'only (head %d is ACE-driven)' % self._disp(head), code=200) + selected_source = None + if calibration_prepare: + selected_source = self._calibration_prepare_unload_source( + gcmd, head, requested_ace, requested_slot) self._wait_bg_op(head, gcmd) # Genuinely empty head (no ACE mapping AND toolhead sensor clear) -> # no-op. A stale web queue (e.g. Unload All already cleared it, then a @@ -20162,6 +20864,17 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): logging.info('[multiACE] unload head %d: using bg-staged ' 'ACE %d slot %d as the source' % (head, staged[0], staged[1])) + recovery_source = False + if calibration_prepare: + if source is not None and ( + source.get('ace_index') != selected_source['ace_index'] + or source.get('slot') != selected_source['slot']): + raise gcmd.error( + '[multiACE] selected calibration route does not match ' + 'the tracked toolhead source') + if source is None: + source = selected_source + recovery_source = True if source: ace_index = source['ace_index'] slot = source['slot'] @@ -20174,6 +20887,19 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): 'Failed to connect to ACE %d for unload!' % self._disp(ace_index), code=208, head=head) + if recovery_source: + # FEED_AUTO resolves its retract slot through head_source. + # Record the user's validated route in memory so every + # downstream stage targets the selected spool rather than a + # first-ready or active-ACE fallback. A verified unload clears + # it normally; a failed/cancelled unload retains it for a safe + # retry during this Klipper session. + self._head_source[head] = dict(source) + self.log_always( + '[multiACE] calibration preparation recovered unknown ' + 'head %d source as ACE %d slot %d' + % (self._disp(head), self._disp(ace_index), + self._disp(slot))) else: logging.info(self._t('msg.unload_head_no_mapping', head=self._raw(head))) @@ -20262,13 +20988,18 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): self.gcode.run_script_from_command( "SET_FILAMENT_SENSOR SENSOR=e%d_filament ENABLE=0" % head) + if calibration_prepare: + self._calibration_unload_begin( + head, active_idx, int(source.get('slot', head))) self._retract_length_override = retract_override if retract_override > 0 else None + cancelled = False # Keep unload_heat_finish away from the touchscreen for the whole # prepare..doing span (a staged caller keeps it up until its doing # or cancel), see filament_feed_ace.get_status. if stage in ('all', 'prepare'): self._feed_hide_heat_finish(module, channel, True) try: + self._check_calibration_unload_cancel() if stage in ('all', 'prepare'): self.gcode.run_script_from_command( "FEED_AUTO MODULE=%s CHANNEL=%d EXTRUDER=%d UNLOAD=1 STAGE=prepare" @@ -20282,16 +21013,41 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): self.log_always(_m) logging.info(_m) return + self._check_calibration_unload_cancel() self.gcode.run_script_from_command( "FEED_AUTO MODULE=%s CHANNEL=%d EXTRUDER=%d UNLOAD=1 STAGE=doing" % (module, channel, head)) + self._check_calibration_unload_cancel() except Exception as e: self._audit_state('UNLOAD_HEAD_FAILED', {'head': head, 'reason': 'feed_auto_error', 'error': str(e), 'active_device': self._active_device_index}) - raise + cancelled = bool( + calibration_prepare + and self._calibration_unload.get('cancel_requested')) + if not cancelled: + if calibration_prepare: + self._calibration_unload_finish() + raise finally: self._retract_length_override = None if stage != 'prepare': self._feed_hide_heat_finish(module, channel, False) + if cancelled: + self._last_unload_ok = False + try: + self.gcode.run_script_from_command('M104 S0') + self.gcode.run_script_from_command( + 'SET_FILAMENT_SENSOR SENSOR=e%d_filament ENABLE=1' % head) + except Exception: + pass + self._calibration_unload_finish() + self.log_always( + '[multiACE] Calibration preparation unload cancelled for ' + 'head %d; verify filament position before retrying' + % self._disp(head)) + self._audit_state('CALIBRATION_UNLOAD_CANCELLED', {'head': head}) + return + if calibration_prepare: + self._calibration_unload_finish() if keep_heat > 0: self.gcode.run_script_from_command('M104 S%d' % keep_heat) diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index 0e157cb..4c49527 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -976,6 +976,10 @@ class CalibrationAction(BaseModel): slot: int | None = None head: int | None = None scope: str | None = None + # verify_start: the values to test (what Save would write) + load_budget: int | None = None + swap: int | None = None + retract: int | None = None class SnapshotSave(BaseModel): name: str @@ -4424,12 +4428,28 @@ def _raw_calibration(status: dict) -> dict: ace = status.get('ace', {}) or {} return ace.get('calibration') or {'state': 'idle', 'session_id': 0} +def _moonraker_error_text(detail: str) -> str: + """Moonraker answers a failed G-code script with a JSON body that + carries the message next to a full traceback; the page only needs the + message. Anything that is not that JSON is returned unchanged.""" + try: + body = json.loads(detail) + except (TypeError, ValueError): + return detail + error = body.get('error') if isinstance(body, dict) else None + if isinstance(error, dict) and error.get('message'): + return str(error['message']) + if isinstance(error, str) and error: + return error + return detail + async def _dispatch_calibration(script: str) -> dict: try: return await _mr_post('/printer/gcode/script', {'script': script}, timeout=30.0) except httpx.HTTPStatusError as e: - detail = e.response.text if e.response is not None else str(e) + detail = _moonraker_error_text( + e.response.text if e.response is not None else str(e)) raise HTTPException( status_code=e.response.status_code if e.response is not None else 502, detail=detail) @@ -4472,16 +4492,29 @@ async def calibration_action(payload: CalibrationAction) -> dict: script = 'ACE_CALIBRATION_FEED' elif action == 'retract': length = int(payload.length or 0) - if length < 5 or length > 500: - raise HTTPException(400, 'retract length must be 5-500 mm') + if length < 5 or length > 5000: + raise HTTPException(400, 'retract length must be 5-5000 mm') script = 'ACE_CALIBRATION_RETRACT LENGTH=%d' % length elif action == 'feed_jog': length = int(payload.length or 0) - if length < 5 or length > 500: - raise HTTPException(400, 'forward jog length must be 5-500 mm') + if length < 5 or length > 5000: + raise HTTPException(400, 'forward jog length must be 5-5000 mm') script = 'ACE_CALIBRATION_FEED_JOG LENGTH=%d' % length elif action == 'mark_swap': script = 'ACE_CALIBRATION_MARK MARK=swap' + elif action == 'mark_undo': + script = 'ACE_CALIBRATION_MARK MARK=undo' + elif action == 'stop': + script = 'ACE_CALIBRATION_STOP' + elif action == 'resume': + script = 'ACE_CALIBRATION_RESUME' + elif action == 'home': + script = 'ACE_CALIBRATION_HOME' + elif action == 'verify_hub_jog': + length = int(payload.length or 0) + if length not in (5, 10, 25, 50): + raise HTTPException(400, 'hub jog must be 5, 10, 25 or 50 mm') + script = 'ACE_CALIBRATION_VERIFY ACTION=hub_jog LENGTH=%d' % length elif action == 'return_park': script = 'ACE_CALIBRATION_RETURN' elif action == 'verify_start': @@ -4497,6 +4530,10 @@ async def calibration_action(payload: CalibrationAction) -> dict: raise HTTPException(400, 'scope must be ace or slot') script += ' ACE=%d SLOT=%d HEAD=%d SCOPE=%s' % ( payload.ace, payload.slot, payload.head, scope) + values = (payload.load_budget, payload.swap, payload.retract) + if all(value is not None for value in values): + script += ' LOAD=%d SWAP=%d RETRACT=%d' % tuple( + int(value) for value in values) elif action == 'verify_continue': script = 'ACE_CALIBRATION_VERIFY ACTION=continue' elif action == 'verify_pause': @@ -4856,8 +4893,30 @@ def _write_cfg_atomic(path, text: str) -> None: p = Path(path) tmp = p.with_suffix(p.suffix + ".tmp") tmp.write_text(text, encoding="utf-8") + _klipper_readable(tmp) os.replace(str(tmp), str(p)) + +def _klipper_readable(path) -> None: + """Make a file this service wrote readable by Klipper before it replaces + the real one. The service runs as root: with a strict umask (e.g. 0077, + inherited from an SSH shell that restarted it) the new file came out + root-owned 0600 and Klipper, running as the printer user, failed with + "Unable to open config file". Readable by all, owned like its directory + when running as root. Best-effort.""" + p = Path(path) + try: + os.chmod(str(p), 0o644) + except OSError: + pass + geteuid = getattr(os, "geteuid", None) + if geteuid is not None and geteuid() == 0: + try: + owner = p.parent.stat() + os.chown(str(p), owner.st_uid, owner.st_gid) + except OSError: + pass + @app.get("/api/config") async def get_config() -> dict: p = Path(_resolve_cfg_path()) @@ -5305,6 +5364,7 @@ def _save_overrides_to_disk() -> None: try: tmp = p.with_suffix(p.suffix + ".tmp") tmp.write_text(json.dumps(_slot_overrides, indent=2), encoding="utf-8") + _klipper_readable(tmp) os.replace(str(tmp), str(p)) try: _overrides_mtime = p.stat().st_mtime diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index f4ab600..73b70f0 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -8,9 +8,9 @@ const WS_URL = (location.protocol === "https:" ? "wss://" : "ws://") const SCREEN = "/screen"; createApp({ setup() { - // Bowden-path calibration tab (physicsG port): hidden for now, also on - // dev (Dirk 2026-09-25). The code stays; flip to true to show it. - const CALIBRATION_TAB = false; + // Bowden-path calibration tab (physicsG port): hidden upstream (Dirk + // 2026-09-25), shown on this branch. + const CALIBRATION_TAB = true; const _validTabs = new Set(["dashboard", "control", "history", "spools", "config"]); if (CALIBRATION_TAB) _validTabs.add("calibration"); const _storedTab = localStorage.getItem("multiace.tab"); @@ -698,14 +698,31 @@ createApp({ const calibrationUi = reactive({ ace: 0, slot: 0, head: 0, scope: 'ace', - loadAllowance: 20, swapAllowance: 20, - baselinePrepared: false, routeConfirmed: false, + // ACE transport varies run to run, so the saved values carry a + // generous margin: the load is stopped by the toolhead sensor + // anyway, and a swap retract that runs long only costs seconds + // while one that stops short leaves filament in the hub. + loadAllowance: 150, swapAllowance: 100, panel: 'setup', panelTouched: false, recalibrate: false, busy: '', cancelRequested: false, error: '', saved: false, + hubAdjust: false, }); const calibrationSelectionTouched = reactive({ ace: false, slot: false, head: false, }); + const CAL_MEASURING = ['prepared', 'feeding', 'feed_paused', 'at_sensor', + 'retract_ready', 'retracting', 'feed_jogging', 'swap_marked', + 'returning', 'return_paused']; + const CAL_VERIFYING = ['verifying_feed', 'verify_toolhead', + 'verify_toolhead_adjust', 'verify_toolhead_adjusting', + 'verifying_splitter', 'verify_splitter', 'verify_splitter_adjusting', + 'verifying_return', 'verify_paused']; + const CAL_MOVING = ['feeding', 'retracting', 'returning', 'feed_jogging', + 'homing', 'verifying_feed', 'verifying_splitter', 'verifying_return', + 'verify_toolhead_adjusting', 'verify_splitter_adjusting']; + const CAL_PARKED = ['idle', 'complete', 'verified']; + // Stopped mid-way with the position kept: can be carried on. + const CAL_PAUSED = ['feed_paused', 'return_paused', 'verify_paused']; const connectedCalibrationAces = computed(() => state.aces.filter(a => a && a.connected)); const calibrationSelectedAce = computed(() => @@ -719,84 +736,103 @@ createApp({ const calibrationVerifyMoving = computed(() => ['verifying_feed', 'verifying_splitter', 'verifying_return'] .includes(state.calibration?.state)); + const calibrationVerifyActive = computed(() => + CAL_VERIFYING.includes(state.calibration?.state)); const calibrationMoving = computed(() => - ['feeding', 'retracting', 'returning', 'feed_jogging'] - .includes(state.calibration?.state) - || calibrationVerifyMoving.value - || state.calibration?.state === 'verify_toolhead_adjusting'); - const calibrationRouteLocked = computed(() => { + CAL_MOVING.includes(state.calibration?.state)); + // Calibration filament is (believed to be) somewhere in the tube. + const calibrationRouteOccupied = computed(() => { const c = state.calibration || {}; const position = Number(c.tip_position_mm); - const tipAwayFromPark = Number.isFinite(position) && position > 0; - const nonParkedState = [ - 'feeding', 'at_sensor', 'retract_ready', 'retracting', - 'swap_marked', 'returning', 'feed_jogging', 'verifying_feed', - 'verify_toolhead', 'verify_toolhead_adjust', - 'verify_toolhead_adjusting', 'verifying_splitter', 'verify_splitter', - 'verifying_return', 'verify_paused', 'cancelled', 'failed', - ].includes(c.state); + return !CAL_PARKED.includes(c.state || 'idle') + && Number.isFinite(position) && position > 0; + }); + const calibrationRouteLocked = computed(() => { + const c = state.calibration || {}; return c.ace != null && c.slot != null && c.head != null - && (tipAwayFromPark || nonParkedState); + && (calibrationRouteOccupied.value + || !CAL_PARKED.includes(c.state || 'idle')); }); const calibrationSelectedHeadSensorKnown = computed(() => typeof calibrationSelectedToolhead.value?.filament_at_extruder === 'boolean'); const calibrationSelectedHeadClear = computed(() => calibrationSelectedToolhead.value?.filament_at_extruder === false); + const calibrationPrinterFree = computed(() => + state.mode !== 'normal' + && !['printing', 'paused'].includes(state.printer_state) + && !state.swap_in_progress + && !(state.bg_swap?.busy || []).length); const calibrationCanPrepare = computed(() => { const ace = calibrationSelectedAce.value; const toolhead = calibrationSelectedToolhead.value; return !!ace && ace.connected && !!toolhead && !toolhead.manual && !toolhead.feeder && calibrationSelectedHeadSensorKnown.value - && state.mode !== 'normal' - && !['printing', 'paused'].includes(state.printer_state) - && !state.swap_in_progress - && !(state.bg_swap?.busy || []).length + && calibrationPrinterFree.value && !calibrationMoving.value; }); - const calibrationCanStart = computed(() => { - const ace = calibrationSelectedAce.value; - const toolhead = calibrationSelectedToolhead.value; - const slot = ace?.slots?.find( - s => Number(s.idx) === Number(calibrationUi.slot)); - return !!ace && ace.connected - && calibrationUi.baselinePrepared - && calibrationUi.routeConfirmed - && calibrationSelectedHeadClear.value - && state.mode !== 'normal' - && !['printing', 'paused'].includes(state.printer_state) - && !state.swap_in_progress - && !(state.bg_swap?.busy || []).length - && !!slot && slot.state === 'ready' - && toolhead?.filament_at_extruder === false - && !toolhead?.manual && !toolhead?.feeder - && !calibrationMoving.value; + // Why a new measurement cannot start right now ('' = it can). + const calibrationStartBlockedReason = computed(() => { + if (calibrationRouteOccupied.value) return t('ui.calibration.blk_occupied'); + if (!calibrationSelectedAce.value?.connected) return t('ui.calibration.blk_ace_off'); + if (calibrationSelectedSlot.value?.state !== 'ready') { + return t('ui.calibration.blk_slot_ready'); + } + if (!calibrationSelectedHeadSensorKnown.value) { + return t('ui.calibration.blk_sensor_na'); + } + if (calibrationSelectedToolhead.value?.manual + || calibrationSelectedToolhead.value?.feeder) { + return t('ui.calibration.blk_not_ace_head'); + } + if (!calibrationSelectedHeadClear.value) return t('ui.calibration.blk_head_loaded'); + if (state.mode === 'normal') return t('ui.calibration.blk_normal_mode'); + if (['printing', 'paused'].includes(state.printer_state)) { + return t('ui.calibration.blk_printing'); + } + if (state.swap_in_progress || (state.bg_swap?.busy || []).length) { + return t('ui.calibration.blk_busy'); + } + if (![...CAL_PARKED, 'cancelled', 'failed'] + .includes(state.calibration?.state || 'idle')) { + return t('ui.calibration.blk_session'); + } + return ''; }); - const calibrationCanVerifyConfigured = computed(() => { - const ace = calibrationSelectedAce.value; - const toolhead = calibrationSelectedToolhead.value; - const slot = calibrationSelectedSlot.value; - return ['idle', 'cancelled', 'failed', 'complete', 'verified'] - .includes(state.calibration?.state) - && calibrationRouteKnown.value - && !!ace && ace.connected - && !!slot && slot.state === 'ready' - && calibrationSelectedHeadClear.value - && !!toolhead && !toolhead.manual && !toolhead.feeder - && state.mode !== 'normal' - && !['printing', 'paused'].includes(state.printer_state) - && !state.swap_in_progress - && !(state.bg_swap?.busy || []).length - && !calibrationMoving.value; + const calibrationCanStart = computed(() => + !calibrationStartBlockedReason.value && !calibrationMoving.value); + const calibrationVerifyBlockedReason = computed(() => { + if (!calibrationRouteKnown.value) return t('ui.calibration.blk_no_values'); + // Klipper still runs the values from before the save. + return calibrationStartBlockedReason.value; }); + const calibrationCanVerifyConfigured = computed(() => + !calibrationVerifyBlockedReason.value && !calibrationMoving.value); const calibrationProposed = computed(() => { const c = state.calibration || {}; + const allowance = (v) => Math.max(0, Number(v) || 0); + const retract = c.retract_length_mm == null ? null : Number(c.retract_length_mm); + // A session primed from saved values already carries its margin; + // adding it again would grow the value on every verify + save. + const swapFromConfig = c.values_source === 'saved'; + let swap = null, capped = false; + if (c.swap_retract_length_mm != null) { + const base = Number(c.swap_retract_length_mm); + // plus whatever was pulled back further at the hub stop in verify + swap = (swapFromConfig + ? base : base + allowance(calibrationUi.swapAllowance)) + + Math.max(0, Number(c.verify_hub_extra_mm) || 0); + // The hub clearance must stay short of the full retract. + if (retract != null) { + const cap = Math.max(base, retract - 10); + capped = swap > cap; + swap = Math.min(swap, cap); + } + } return { load: c.load_length_mm == null ? null - : Number(c.load_length_mm) + Math.max(0, Number(calibrationUi.loadAllowance) || 0), - swap: c.swap_retract_length_mm == null ? null - : Number(c.swap_retract_length_mm) + Math.max(0, Number(calibrationUi.swapAllowance) || 0), - retract: c.retract_length_mm == null ? null : Number(c.retract_length_mm), + : Number(c.load_length_mm) + allowance(calibrationUi.loadAllowance), + swap, retract, capped, swapFromConfig, }; }); const calibrationConfiguredRoute = computed(() => { @@ -821,8 +857,7 @@ createApp({ const c = state.calibration || {}; return Number(c.ace) === Number(calibrationUi.ace) && Number(c.slot) === Number(calibrationUi.slot) - && Number(c.head) === Number(calibrationUi.head) - && String(c.scope || 'ace') === String(calibrationUi.scope || 'ace'); + && Number(c.head) === Number(calibrationUi.head); }); const calibrationRouteAnchors = computed(() => { const c = state.calibration || {}; @@ -838,23 +873,36 @@ createApp({ return {load, swap, source: measuredLoad > 0 && measuredSwap > 0 ? 'measured' : 'configured'}; }); + const _calSavedAt = (cfg, key) => cfg && cfg[key] !== '' && cfg[key] != null + && Number.isFinite(Number(cfg[key])) && Number(cfg[key]) > 0; // A route counts as CALIBRATED only when a value was actually SAVED at // the per-ACE or per-slot level - NOT the shipped global [ace] default // (retract_length/swap_retract_length), which is always present and - // otherwise makes every fresh install read as "Route known" and pushes - // the user to verify instead of a first calibration. - // Scope-independent for the question "is this route calibrated": a - // per-slot value OR the per-ACE value that the slot inherits both count. + // otherwise makes every fresh install read as calibrated. const calibrationRouteCalibrated = computed(() => { - const ace = Number(calibrationUi.ace); - const slot = Number(calibrationUi.slot); - const aceCfg = configForm.perAce?.[ace] || {}; - const slotCfg = aceCfg.perSlot?.[slot] || {}; - const saved = (key) => [slotCfg[key], aceCfg[key]].some( - v => v !== '' && v != null && Number.isFinite(Number(v)) - && Number(v) > 0); + const aceCfg = configForm.perAce?.[Number(calibrationUi.ace)] || {}; + const slotCfg = aceCfg.perSlot?.[Number(calibrationUi.slot)] || {}; + const saved = (key) => _calSavedAt(slotCfg, key) || _calSavedAt(aceCfg, key); return saved('retract_length') && saved('swap_retract_length'); }); + // Slots of the selected ACE that carry their own values. + const calibrationOwnValueSlots = computed(() => { + const aceCfg = configForm.perAce?.[Number(calibrationUi.ace)] || {}; + return (aceCfg.perSlot || []) + .map((slotCfg, idx) => ({slotCfg, idx})) + .filter(({slotCfg}) => ['load_length', 'swap_retract_length', 'retract_length'] + .some(key => _calSavedAt(slotCfg, key))) + .map(({idx}) => idx); + }); + function calibrationSlotBadge(slot) { + if (!slot || slot.state !== 'ready') return slot?.state || ''; + const aceCfg = configForm.perAce?.[Number(calibrationUi.ace)] || {}; + if (calibrationOwnValueSlots.value.includes(Number(slot.idx))) { + return t('ui.calibration.slot_own'); + } + if (_calSavedAt(aceCfg, 'retract_length')) return t('ui.calibration.slot_shared'); + return t('ui.calibration.slot_none'); + } const calibrationRouteKnown = computed(() => { const {load, swap} = calibrationRouteAnchors.value; const valid = Number.isFinite(load) && Number.isFinite(swap) @@ -865,122 +913,187 @@ createApp({ return calibrationRouteAnchors.value.source === 'measured' || calibrationRouteCalibrated.value; }); - const calibrationVerifyBlockedReason = computed(() => { - if (!calibrationRouteKnown.value) return t('ui.calibration.blk_no_values'); - if (!calibrationSelectedAce.value?.connected) return t('ui.calibration.blk_ace_off'); - if (calibrationSelectedSlot.value?.state !== 'ready') { - return t('ui.calibration.blk_slot_ready'); - } - if (!calibrationSelectedHeadSensorKnown.value) { - return t('ui.calibration.blk_sensor_na'); - } - if (!calibrationSelectedHeadClear.value) return t('ui.calibration.blk_head_loaded'); - if (calibrationSelectedToolhead.value?.manual - || calibrationSelectedToolhead.value?.feeder) { - return t('ui.calibration.blk_not_ace_head'); - } - if (state.mode === 'normal') return t('ui.calibration.blk_normal_mode'); - if (['printing', 'paused'].includes(state.printer_state)) { - return t('ui.calibration.blk_printing'); - } - if (state.swap_in_progress || (state.bg_swap?.busy || []).length) { - return t('ui.calibration.blk_busy'); - } - if (!['idle', 'cancelled', 'failed', 'complete', 'verified'] - .includes(state.calibration?.state)) { - return t('ui.calibration.blk_session'); - } - return ''; + const calibrationMeasureStep = computed(() => { + const s = state.calibration?.state; + if (['prepared', 'feeding', 'feed_paused'].includes(s)) return 1; + if (['at_sensor', 'retract_ready', 'retracting', 'feed_jogging'].includes(s)) return 2; + if (['swap_marked', 'returning', 'return_paused'].includes(s)) return 3; + return 0; + }); + const calibrationVerifyStep = computed(() => { + const c = state.calibration || {}; + const s = c.state === 'verify_paused' ? c.verify_paused_from : c.state; + if (['verifying_feed', 'verify_toolhead', 'verify_toolhead_adjust', + 'verify_toolhead_adjusting'].includes(s)) return 1; + if (['verifying_splitter', 'verify_splitter', + 'verify_splitter_adjusting'].includes(s)) return 2; + if (s === 'verifying_return') return 3; + return 0; }); const calibrationTabStatus = computed(() => { const c = state.calibration || {}; - const measuring = ['prepared', 'feeding', 'at_sensor', 'retract_ready', - 'retracting', 'swap_marked', 'returning'].includes(c.state); - const verifying = ['verifying_feed', 'verify_toolhead', - 'verify_toolhead_adjust', 'verify_toolhead_adjusting', - 'verifying_splitter', 'verify_splitter', 'verifying_return', - 'verify_paused'].includes(c.state); + const measuring = CAL_MEASURING.includes(c.state); + const verifying = CAL_VERIFYING.includes(c.state); return { - setup: calibrationRouteKnown.value ? t('ui.calibration.st_route_ready') : t('ui.calibration.st_choose_route'), + setup: t('ui.calibration.st_route_ready'), calibrate: measuring ? t('ui.calibration.st_running') : calibrationRouteKnown.value ? t('ui.calibration.st_values') : t('ui.calibration.st_required'), verify: verifying ? t('ui.calibration.st_running') : c.state === 'verified' ? t('ui.calibration.st_passed') - : calibrationRouteKnown.value ? 'Available' : t('ui.calibration.st_needs_values'), - results: c.state === 'verified' ? t('ui.calibration.st_passed') - : c.state === 'complete' ? t('ui.calibration.st_review') : t('ui.calibration.st_no_result'), + : calibrationRouteKnown.value ? t('ui.calibration.st_available') : t('ui.calibration.st_needs_values'), + results: calibrationUi.saved ? t('ui.calibration.st_saved') + : calibrationResultCase.value === 'passed' ? t('ui.calibration.st_passed') + : calibrationResultCase.value ? t('ui.calibration.st_review') : t('ui.calibration.st_no_result'), }; }); + // One human-readable status for the route header. + const calibrationStatus = computed(() => { + const s = state.calibration?.state || 'idle'; + if (CAL_MOVING.includes(s)) return {text: t('ui.calibration.status_moving'), kind: 'busy'}; + if (CAL_PAUSED.includes(s) + || (['cancelled', 'failed'].includes(s) && calibrationRouteOccupied.value)) { + return {text: t('ui.calibration.status_stopped'), kind: 'attention'}; + } + if (['at_sensor', 'retract_ready', 'swap_marked', 'verify_toolhead', + 'verify_toolhead_adjust', 'verify_splitter'].includes(s)) { + return {text: t('ui.calibration.status_waiting'), kind: 'attention'}; + } + return calibrationRouteKnown.value + ? {text: t('ui.calibration.route_known'), kind: 'ready'} + : {text: t('ui.calibration.route_needs'), kind: 'attention'}; + }); + // A stopped move is never a dead end: null, or what the user can do. + const calibrationRecovery = computed(() => { + const c = state.calibration || {}; + if (CAL_PAUSED.includes(c.state)) return {canResume: true}; + if (['cancelled', 'failed'].includes(c.state) + && calibrationRouteOccupied.value) return {canResume: false}; + return null; + }); + const calibrationNothingToSave = computed(() => { + const c = state.calibration || {}; + return c.state === 'verified' && c.values_source === 'saved' + && !c.verify_remeasured_mm && !c.verify_hub_extra_mm; + }); + const calibrationResultCase = computed(() => { + const c = state.calibration || {}; + if (!['complete', 'verified'].includes(c.state)) return ''; + if (c.state === 'complete') return 'measured'; + if (calibrationNothingToSave.value) return 'passed'; + return c.verify_remeasured_mm || c.verify_hub_extra_mm + ? 'changed' : 'measured'; + }); const calibrationTipPositionMm = computed(() => { - if (!calibrationRouteKnown.value) return null; + if (!calibrationRouteKnown.value + && !CAL_MEASURING.includes(state.calibration?.state) + && !calibrationRouteOccupied.value) return null; const c = state.calibration || {}; - const load = calibrationRouteAnchors.value.load; if (c.tip_position_mm != null && Number.isFinite(Number(c.tip_position_mm))) { - const fineLimit = Math.max(0, Number(c.verify_fine_limit_mm) || 50); - return Math.max(0, Math.min(load + fineLimit, Number(c.tip_position_mm))); + return Math.max(0, Number(c.tip_position_mm)); } + const load = calibrationRouteAnchors.value.load; if (calibrationSelectedToolhead.value?.filament_at_extruder === true) return load; if (['complete', 'verified'].includes(c.state)) return 0; - const slot = calibrationSelectedAce.value?.slots?.find( - item => Number(item.idx) === Number(calibrationUi.slot)); - if (slot?.state === 'ready' + if (calibrationSelectedSlot.value?.state === 'ready' && calibrationSelectedToolhead.value?.filament_at_extruder === false) return 0; return null; }); - const calibrationTipSegment = computed(() => { + // Where the marker is DRAWN. A position taken from the lane encoder is + // drawn as is. A time estimate runs ahead of the filament (the ACE + // starts late), so while moving the marker is held back by a margin and + // does not show the hub as passed before the filament is there. + const CALIBRATION_ESTIMATE_MARGIN_MM = 75; + const calibrationTipEstimated = computed(() => + calibrationMoving.value + && state.calibration?.position_source !== 'encoder'); + const calibrationTipDrawnMm = computed(() => { const position = calibrationTipPositionMm.value; - if (position == null) return 'ace'; + if (position == null || !calibrationTipEstimated.value) return position; + const feeding = ['feeding', 'feed_jogging', 'verifying_feed'] + .includes(state.calibration?.state); + const load = calibrationRouteAnchors.value.load; + return feeding + ? Math.max(0, position - CALIBRATION_ESTIMATE_MARGIN_MM) + : Math.min(Number.isFinite(load) ? load : position, + position + CALIBRATION_ESTIMATE_MARGIN_MM); + }); + // Hub position on the route, in mm from the park position (null while + // it is not known yet, e.g. during the first measurement). + const calibrationHubMm = computed(() => { const {load, swap} = calibrationRouteAnchors.value; - const splitter = load - swap; - return position <= splitter ? 'ace' : 'toolhead'; + return Number.isFinite(load) && Number.isFinite(swap) && swap > 0 && swap < load + ? load - swap : null; + }); + const calibrationRouteLengthMm = computed(() => { + const c = state.calibration || {}; + const load = calibrationRouteAnchors.value.load; + if (Number.isFinite(load) && load > 0) return load; + return Number(c.max_feed_mm) > 0 ? Number(c.max_feed_mm) : null; + }); + const calibrationTipSegment = computed(() => { + const position = calibrationTipDrawnMm.value; + const hub = calibrationHubMm.value; + if (position == null) return 'ace'; + // Hub not measured yet: the two tubes are just the two halves of + // the route, so the marker still ends at the toolhead. + if (hub == null) { + const total = calibrationRouteLengthMm.value; + return total > 0 && position / total > 0.5 ? 'toolhead' : 'ace'; + } + return position <= hub ? 'ace' : 'toolhead'; }); const calibrationTipSegmentPercent = computed(() => { - const position = calibrationTipPositionMm.value; + const position = calibrationTipDrawnMm.value; if (position == null) return 50; - const {load, swap} = calibrationRouteAnchors.value; - const splitter = load - swap; - const percent = position <= splitter - ? (splitter > 0 ? 100 * position / splitter : 0) - : (swap > 0 ? 100 * (position - splitter) / swap : 0); + const hub = calibrationHubMm.value; + const total = calibrationRouteLengthMm.value; + let percent = 0; + if (hub == null) { + const fraction = total > 0 ? position / total : 0; + percent = fraction <= 0.5 ? 200 * fraction : 200 * (fraction - 0.5); + } else if (position <= hub) { + percent = hub > 0 ? 100 * position / hub : 0; + } else { + percent = total - hub > 0 ? 100 * (position - hub) / (total - hub) : 0; + } return Math.max(0, Math.min(100, percent)); }); + // Tube columns sized from the real distances, so the marker moves at + // one speed along the whole route. + const calibrationRouteGrid = computed(() => { + const hub = calibrationHubMm.value; + const total = calibrationRouteLengthMm.value; + if (hub == null || !(total > hub)) return 'auto 1fr auto 1fr auto'; + return `auto minmax(56px, ${Math.round(hub)}fr) auto ` + + `minmax(56px, ${Math.round(total - hub)}fr) auto`; + }); const calibrationTipLabel = computed(() => { const c = state.calibration || {}; const position = calibrationTipPositionMm.value; - if (!calibrationRouteKnown.value) return t('ui.calibration.tip_no_anchors'); - if (position == null) return t('ui.calibration.tip_pos_na'); - if (c.state === 'verify_paused') { - return t('ui.calibration.tip_paused', {mm: Math.round(position)}); - } - if (c.state === 'verify_toolhead_adjust') { - const offset = Number(c.verify_fine_offset_mm) || 0; - return t('ui.calibration.tip_adjust', {mm: (offset >= 0 ? '+' : '') + offset}); + if (position == null) { + return calibrationRouteKnown.value + ? t('ui.calibration.tip_pos_na') : t('ui.calibration.tip_no_anchors'); } - if (c.state === 'verify_toolhead_adjusting') { - return t('ui.calibration.tip_fine', {mm: Math.round(position)}); - } - if (c.state === 'verify_toolhead') return t('ui.calibration.tip_at_head'); - if (c.state === 'verify_splitter') return t('ui.calibration.tip_at_splitter'); - if (c.state === 'verified') return t('ui.calibration.tip_verified'); - if (c.state === 'complete') return t('ui.calibration.tip_parked'); + const mm = Math.round(position); + if (c.state === 'homing') return t('ui.calibration.tip_homing', {mm}); if (calibrationMoving.value) { - return t('ui.calibration.tip_moving', {mm: Math.round(position)}); + return t(calibrationTipEstimated.value + ? 'ui.calibration.tip_moving' : 'ui.calibration.tip_moving_enc', {mm}); } - const source = calibrationRouteAnchors.value.source === 'configured' - ? t('ui.calibration.tip_src_config') : t('ui.calibration.tip_src_measured'); - return t('ui.calibration.tip_estimate', {source: source, mm: Math.round(position)}); + if (calibrationRecovery.value) return t('ui.calibration.tip_stopped', {mm}); + if (['at_sensor', 'verify_toolhead'].includes(c.state)) return t('ui.calibration.tip_at_head'); + if (c.state === 'verify_splitter') return t('ui.calibration.tip_at_splitter'); + if (mm <= 0) return t('ui.calibration.tip_parked'); + return t('ui.calibration.tip_at', {mm}); }); watch(() => state.calibration?.state, (next, previous) => { - const verifying = ['verifying_feed', 'verify_toolhead', - 'verify_toolhead_adjust', 'verify_toolhead_adjusting', - 'verifying_splitter', 'verify_splitter', 'verifying_return', - 'verify_paused']; - const measuring = ['prepared', 'feeding', 'at_sensor', 'retract_ready', - 'retracting', 'swap_marked', 'returning']; - if (verifying.includes(next)) { + if (!['verify_splitter', 'verify_splitter_adjusting'].includes(next)) { + calibrationUi.hubAdjust = false; + } + if (CAL_VERIFYING.includes(next)) { calibrationSetPanel('verify', false); - } else if (measuring.includes(next)) { + } else if (CAL_MEASURING.includes(next)) { calibrationSetPanel('calibrate', false); } else if (next === 'verified' && previous && previous !== 'verified') { calibrationSetPanel('results', false); @@ -989,10 +1102,6 @@ createApp({ } }); - function calibrationResetPreparation() { - calibrationUi.baselinePrepared = false; - calibrationUi.routeConfirmed = false; - } function calibrationSetPanel(panel, touched=true) { if (!['setup', 'calibrate', 'verify', 'results'].includes(panel)) return; calibrationUi.panel = panel; @@ -1010,18 +1119,11 @@ createApp({ calibrationUi.recalibrate = false; calibrationUi.error = ''; } + // Where the result is saved - decided on the Results panel, at the + // moment of saving, not before anything has been measured. function calibrationSetScope(scope) { - if (calibrationRouteLocked.value || calibrationUi.busy - || !['ace', 'slot'].includes(scope)) return; + if (calibrationUi.busy || !['ace', 'slot'].includes(scope)) return; calibrationUi.scope = scope; - calibrationResetPreparation(); - calibrationUi.recalibrate = false; - } - function calibrationRouteChanged() { - if (!calibrationRouteLocked.value) { - calibrationResetPreparation(); - calibrationUi.recalibrate = false; - } } function syncCalibrationDefaults() { @@ -1030,7 +1132,6 @@ createApp({ calibrationUi.ace = Number(c.ace); calibrationUi.slot = Number(c.slot); calibrationUi.head = Number(c.head); - calibrationUi.scope = c.scope === 'slot' ? 'slot' : 'ace'; return; } const connected = state.aces.filter(ace => ace?.connected); @@ -1063,20 +1164,13 @@ createApp({ calibrationUi.head = Number(mapped); } } + // Unload the selected toolhead so the measurement starts from a clear + // sensor. Has its own out-of-band cancel (calibrationCancelPrepare). async function calibrationPrepare() { calibrationUi.busy = 'prepare'; calibrationUi.cancelRequested = false; calibrationUi.error = ''; - calibrationResetPreparation(); try { - if (['cancelled', 'failed'].includes(state.calibration?.state)) { - const resetOk = await calibrationRequest('action', { - action: 'reset', - session_id: Number(state.calibration?.session_id || 0), - }, 'prepare'); - calibrationUi.busy = 'prepare'; - if (!resetOk) return; - } if (calibrationSelectedHeadClear.value) return; const ok = await run('ACE_UNLOAD_HEAD', { HEAD: Number(calibrationUi.head), @@ -1100,33 +1194,6 @@ createApp({ calibrationUi.cancelRequested = false; } } - async function calibrationConfirmBaseline() { - if (!calibrationCanPrepare.value || !calibrationSelectedHeadClear.value - || calibrationUi.busy) return; - calibrationUi.busy = 'confirm-baseline'; - calibrationUi.error = ''; - calibrationResetPreparation(); - try { - if (['cancelled', 'failed'].includes(state.calibration?.state)) { - const resetOk = await calibrationRequest('action', { - action: 'reset', - session_id: Number(state.calibration?.session_id || 0), - }, 'confirm-baseline'); - calibrationUi.busy = 'confirm-baseline'; - if (!resetOk) return; - } - await reloadState(); - if (!calibrationSelectedHeadClear.value) { - throw new Error( - t('ui.calibration.err_head_not_clear')); - } - calibrationUi.baselinePrepared = true; - } catch (e) { - calibrationUi.error = String(e?.message || e); - } finally { - calibrationUi.busy = ''; - } - } async function calibrationCancelPrepare() { if (calibrationUi.busy !== 'prepare' || calibrationUi.cancelRequested) return; calibrationUi.cancelRequested = true; @@ -1141,43 +1208,36 @@ createApp({ calibrationUi.error = String(e?.message || e); } } - function calibrationConfirmRoute() { - if (!calibrationUi.baselinePrepared || calibrationMoving.value) return; - calibrationUi.routeConfirmed = !calibrationUi.routeConfirmed; + + function _calibrationRouteReset() { calibrationUi.error = ''; + calibrationUi.saved = false; + calibrationUi.recalibrate = false; } - function calibrationSelectAce(idx) { if (calibrationRouteLocked.value || calibrationUi.busy) return; calibrationSelectionTouched.ace = true; calibrationUi.ace = Number(idx); - calibrationResetPreparation(); if (state.mode === 'head') { const h = (state.ace_heads || []).find( head => Number(state.head_ace?.[String(head)] ?? state.head_ace?.[head] ?? head) === Number(idx)); if (h != null) calibrationUi.head = Number(h); } - calibrationUi.error = ''; - calibrationUi.saved = false; - calibrationUi.recalibrate = false; + _calibrationRouteReset(); } function calibrationSelectSlot(idx) { if (calibrationRouteLocked.value || calibrationUi.busy) return; calibrationSelectionTouched.slot = true; calibrationUi.slot = Number(idx); - calibrationResetPreparation(); if (state.mode === 'multi') calibrationUi.head = Number(idx); - calibrationUi.error = ''; - calibrationUi.saved = false; - calibrationUi.recalibrate = false; + _calibrationRouteReset(); } - function calibrationSelectHead() { + function calibrationSelectHead(idx) { if (calibrationRouteLocked.value || calibrationUi.busy) return; calibrationSelectionTouched.head = true; - calibrationRouteChanged(); - calibrationUi.error = ''; - calibrationUi.saved = false; + calibrationUi.head = Number(idx); + _calibrationRouteReset(); } async function calibrationRequest(path, body, busy) { calibrationUi.busy = busy; @@ -1205,6 +1265,16 @@ createApp({ calibrationUi.busy = ''; } } + // Poll until the session state satisfies `done` (a stop is booked a + // moment after the command returns, once the lane has come to rest). + async function _calibrationWaitState(done, timeoutMs=4000) { + const until = Date.now() + timeoutMs; + while (!done(state.calibration?.state) && Date.now() < until) { + await new Promise(resolve => setTimeout(resolve, 150)); + await reloadState(); + } + return done(state.calibration?.state); + } async function calibrationStart() { calibrationUi.saved = false; calibrationSetPanel('calibrate', false); @@ -1212,32 +1282,174 @@ createApp({ ace: Number(calibrationUi.ace), slot: Number(calibrationUi.slot), head: Number(calibrationUi.head), - scope: calibrationUi.scope, + scope: 'ace', }, 'start'); if (ok) await calibrationAction('feed'); } + // What a verification tests: the values Save would write (margins + // included) when this route has a result in the session, otherwise the + // saved ones from the config form - which is also what makes a just + // saved route verifiable before the printer has restarted. + const calibrationVerifyValues = computed(() => { + const c = state.calibration || {}; + if (['complete', 'verified'].includes(c.state) + && calibrationSessionMatchesSelection.value) { + const p = calibrationProposed.value; + if (p.load != null && p.swap != null && p.retract != null) { + return {load_budget: Math.max(p.load, p.retract), swap: p.swap, + retract: p.retract}; + } + } + const cfg = calibrationConfiguredRoute.value; + if (!(cfg.load > 0 && cfg.swap > 0)) return null; + return {load_budget: Math.max(Number(cfg.feedLimit) || 0, cfg.load), + swap: cfg.swap, retract: cfg.load}; + }); async function calibrationVerifyConfigured() { if (!calibrationCanVerifyConfigured.value) return false; + // A verify run can re-measure the toolhead distance, so its result + // has to be applicable again. + calibrationUi.saved = false; calibrationSetPanel('verify', false); + // Where the result gets saved is a UI choice made at save time; the + // printer must not read a different choice as a different route. + const sessionScope = calibrationSessionMatchesSelection.value + ? (state.calibration?.scope || 'ace') : 'ace'; return calibrationRequest('action', { action: 'verify_start', session_id: Number(state.calibration?.session_id || 0), ace: Number(calibrationUi.ace), slot: Number(calibrationUi.slot), head: Number(calibrationUi.head), - scope: calibrationUi.scope, + scope: sessionScope, + ...(calibrationVerifyValues.value || {}), }, 'verify_start'); } async function calibrationAction(action, length=null) { - const ok = await calibrationRequest('action', { + return calibrationRequest('action', { action, length, session_id: Number(state.calibration?.session_id || 0), }, action); - if (ok && ['cancel', 'reset'].includes(action)) { - calibrationResetPreparation(); + } + // Hub mark and return in one go - after marking there is nothing else + // the user could want to do. + async function calibrationMarkAndReturn() { + if (await calibrationAction('mark_swap')) { + await calibrationAction('return_park'); + } + } + async function calibrationUndoMark() { + if (state.calibration?.state === 'returning') { + if (!await calibrationAction('stop')) return; + await _calibrationWaitState(s => s !== 'returning'); + } + await calibrationAction('mark_undo'); + } + // Drag handle on the "clear the hub" step: nothing moves while + // dragging; on release ONE bounded move covers the difference (a + // pull-back never past the park position, a forward move never past the + // toolhead-sensor start). Below 5 mm nothing is sent. + const CALIBRATION_MIN_MOVE_MM = 5; + // `moving` is the move a release or a step button started: its target, + // length and direction, shown until the ACE has stopped. + const calibrationDrag = reactive({active: false, targetMm: null, moving: null}); + const CAL_TRACK_MOVES = ['retracting', 'feed_jogging']; + const calibrationTipNowMm = computed(() => { + const c = state.calibration || {}; + // while a move runs the session reports the tip live + if (CAL_TRACK_MOVES.includes(c.state) && c.tip_position_mm != null) { + return Math.max(0, Number(c.tip_position_mm)); + } + return Math.max(0, Number(c.load_length_mm || 0) + - Number(c.commanded_retract_mm || 0)); + }); + // The saved hub clearance point as a reference mark, if there is one. + // Only a hub clearance really saved for this route is drawn; the global + // default says nothing about where this route's hub is. + const calibrationTrackHubMm = computed(() => { + if (!calibrationRouteCalibrated.value) return null; + const load = Number(state.calibration?.load_length_mm || 0); + const swap = calibrationConfiguredRoute.value.swap; + return load > 0 && swap > 0 && swap < load ? load - swap : null; + }); + function calibrationTrackPercent(mm) { + const load = Number(state.calibration?.load_length_mm || 0); + return load > 0 ? Math.max(0, Math.min(100, 100 * Number(mm) / load)) : 0; + } + const calibrationDragLabel = computed(() => { + const moving = calibrationDrag.moving; + if (moving && calibrationDrag.targetMm == null) { + return t('ui.calibration.drag_moving', { + mm: moving.mm, dir: t(moving.dir), + left: Math.round(Math.abs(moving.target - calibrationTipNowMm.value)), + }); + } + if (calibrationDrag.targetMm == null) return ''; + const diff = calibrationDrag.targetMm - calibrationTipNowMm.value; + if (Math.abs(diff) < CALIBRATION_MIN_MOVE_MM) return t('ui.calibration.drag_small'); + return t('ui.calibration.drag_release', { + mm: Math.abs(Math.round(diff)), + dir: t(diff < 0 ? 'ui.calibration.dir_ace' : 'ui.calibration.dir_head'), + }); + }); + function _calibrationTrackMm(evt) { + const rect = evt.currentTarget.getBoundingClientRect(); + const load = Number(state.calibration?.load_length_mm || 0); + const fraction = rect.width > 0 ? (evt.clientX - rect.left) / rect.width : 0; + return Math.round(Math.max(0, Math.min(1, fraction)) * load); + } + function _calibrationCanDrag() { + return ['at_sensor', 'retract_ready'].includes(state.calibration?.state) + && !calibrationMoving.value && !calibrationUi.busy; + } + function calibrationDragStart(evt) { + if (!_calibrationCanDrag()) return; + evt.preventDefault(); + try { evt.currentTarget.setPointerCapture(evt.pointerId); } catch (_) {} + calibrationDrag.active = true; + calibrationDrag.targetMm = _calibrationTrackMm(evt); + } + function calibrationDragMove(evt) { + if (calibrationDrag.active) calibrationDrag.targetMm = _calibrationTrackMm(evt); + } + function calibrationDragCancel() { + calibrationDrag.active = false; + calibrationDrag.targetMm = null; + } + async function calibrationDragEnd() { + if (!calibrationDrag.active) return; + const target = calibrationDrag.targetMm; + calibrationDragCancel(); + if (target != null) await calibrationMoveTo(target); + } + async function calibrationMoveTo(targetMm) { + const c = state.calibration || {}; + const load = Number(c.load_length_mm || 0); + const retract = Number(c.commanded_retract_mm || 0); + const target = Math.max(0, Math.min(load, Math.round(targetMm))); + const diff = target - (load - retract); + if (Math.abs(diff) < CALIBRATION_MIN_MOVE_MM) return false; + const mm = diff < 0 ? -diff : Math.min(diff, retract); + calibrationDrag.moving = { + target: (load - retract) + Math.sign(diff) * mm, mm, + dir: diff < 0 ? 'ui.calibration.dir_ace' : 'ui.calibration.dir_head', + }; + const ok = await calibrationAction(diff < 0 ? 'retract' : 'feed_jog', mm); + if (!ok || !CAL_TRACK_MOVES.includes(state.calibration?.state)) { + calibrationDrag.moving = null; } return ok; } + // The step buttons go through the same path, so they get the same + // "moving" label. + function calibrationJog(mm) { + return calibrationMoveTo(calibrationTipNowMm.value + mm); + } + watch(() => state.calibration?.state, (s) => { + if (calibrationDrag.moving && !CAL_TRACK_MOVES.includes(s)) { + calibrationDrag.moving = null; + } + }); async function calibrationApply() { const c = state.calibration || {}; const v = calibrationProposed.value; @@ -1264,7 +1476,7 @@ createApp({ calibrationUi.error = t('ui.calibration.err_no_section'); return; } - if (c.scope === 'slot') { + if (calibrationUi.scope === 'slot') { target.perSlot[slot].load_length = v.load; target.perSlot[slot].swap_retract_length = v.swap; target.perSlot[slot].retract_length = v.retract; @@ -1276,12 +1488,217 @@ createApp({ calibrationUi.busy = 'apply'; await saveConfigForm(); calibrationUi.busy = ''; - calibrationUi.saved = rebootNeeded.value; + // saveConfigForm reports through configLog: a line starting with the + // check mark is the only success it writes. It overwrites the log + // first, so an earlier save's identical line cannot pass for this one. + calibrationUi.saved = String(configLog.value || '').startsWith('✓'); if (!calibrationUi.saved) { calibrationUi.error = configLog.value || t('ui.calibration.err_save'); + } else { + _calibrationClearBarClose(); } } + // After saving: on to the next slot or ACE. The saved values reach + // loads and swaps with the next printer restart, which can wait until + // everything is calibrated; verification already uses them (it sends + // the values from the page). + async function calibrationNextRoute() { + await calibrationAction('reset'); + calibrationUi.saved = false; + calibrationUi.recalibrate = false; + calibrationSetPanel('setup'); + } + + // Clearing saved values: the three keys go blank at the level Save + // writes (this slot's own values or the ACE's shared ones), through the + // same config save. No G-code; like Save it takes effect with the next + // printer restart. Undo writes the old values back to that same level. + const CAL_CLEAR_KEYS = [ + {key: 'load_length', label: 'row_load'}, + {key: 'swap_retract_length', label: 'm_splitter'}, + {key: 'retract_length', label: 'row_retract'}, + ]; + const calibrationClear = reactive({ + open: false, undo: null, restored: false, sha1: '', + }); + let _calibrationRestoredTimer = null; + // Where the selected slot gets its values: own, the ACE's shared, none. + const calibrationSavedKind = computed(() => { + const aceCfg = configForm.perAce?.[Number(calibrationUi.ace)] || {}; + if (calibrationOwnValueSlots.value.includes(Number(calibrationUi.slot))) return 'own'; + return _calSavedAt(aceCfg, 'retract_length') ? 'shared' : 'none'; + }); + // The three values at each level for the selected route, the level + // each one is taken from marked as in use. + const calibrationSavedLevels = computed(() => { + const ace = Number(calibrationUi.ace); + const slot = Number(calibrationUi.slot); + const aceCfg = configForm.perAce?.[ace] || {}; + const slotCfg = aceCfg.perSlot?.[slot] || {}; + const levels = [ + {src: 'slot', cfg: slotCfg, + name: t('ui.calibration.values_level_slot', {slot: dispIdx(slot)})}, + {src: 'ace', cfg: aceCfg, + name: t('ui.calibration.values_level_ace', {ace: dispIdx(ace)})}, + {src: 'default', cfg: configForm, + name: t('ui.calibration.values_level_default')}, + ]; + const inUse = CAL_CLEAR_KEYS.map(({key}) => + levels.find(level => _calSavedAt(level.cfg, key))?.src); + return levels.map(level => ({ + src: level.src, name: level.name, + values: CAL_CLEAR_KEYS.map(({key}) => + _calSavedAt(level.cfg, key) ? Number(level.cfg[key]) : null), + set: CAL_CLEAR_KEYS.some(({key}) => _calSavedAt(level.cfg, key)), + inUse: inUse.includes(level.src), + })); + }); + function _calibrationSlotList(slots) { + const names = slots.map(slot => dispIdx(slot)); + if (names.length < 2) return names.join(''); + return t('ui.calibration.clear_list_and', { + rest: names.slice(0, -1).join(', '), last: names[names.length - 1]}); + } + // What a clear at the current scope removes, and what each key falls + // back to afterwards. Calibrated or not follows calibrationRouteCalibrated. + const calibrationClearPlan = computed(() => { + const ace = Number(calibrationUi.ace); + const slot = Number(calibrationUi.slot); + const level = calibrationUi.scope === 'slot' ? 'slot' : 'ace'; + const aceCfg = configForm.perAce?.[ace] || {}; + const slotCfg = aceCfg.perSlot?.[slot] || {}; + const levelCfg = level === 'slot' ? slotCfg : aceCfg; + const rows = CAL_CLEAR_KEYS.map(({key, label}) => { + const fromAce = level === 'slot' && _calSavedAt(aceCfg, key); + const after = fromAce ? Number(aceCfg[key]) + : _calSavedAt(configForm, key) ? Number(configForm[key]) : null; + return { + key, label, config: level === 'slot' ? `${key}_${slot}` : key, + old: _calSavedAt(levelCfg, key) ? Number(levelCfg[key]) : null, + after, src: fromAce ? 'ace' : 'default', + }; + }); + const calibrated = (own, shared) => ['retract_length', 'swap_retract_length'] + .every(key => _calSavedAt(own, key) || _calSavedAt(shared, key)); + const affected = [], keeps = []; + if (level === 'ace') { + (aceCfg.perSlot || []).forEach((own, idx) => { + if (calibrated(own, aceCfg) && !calibrated(own, null)) affected.push(idx); + else if (calibrationOwnValueSlots.value.includes(idx)) keeps.push(idx); + }); + } + return { + level, ace, slot, rows, + saved: rows.some(row => row.old != null), + staysCalibrated: level === 'slot' && calibrated(null, aceCfg), + affected, keeps, + }; + }); + // Why Clear cannot run right now (null = it can). `blocked` marks the + // reasons that are a temporary refusal rather than nothing to clear. + const calibrationClearBlocked = computed(() => { + const plan = calibrationClearPlan.value; + if (calibrationMoving.value) { + return {blocked: true, why: t('ui.calibration.clear_blk_moving', { + ace: dispIdx(state.calibration?.ace ?? plan.ace)})}; + } + if (!plan.saved) { + return {blocked: false, why: plan.level === 'slot' + ? t('ui.calibration.clear_blk_none_slot') + : t('ui.calibration.clear_blk_none_ace', {ace: dispIdx(plan.ace)})}; + } + return null; + }); + // The open run of this route goes with the values it was measured + // against. Filament still out of the ACE: the run is cancelled instead, + // which keeps the tip position, so the return to the ACE stays offered. + const calibrationClearSession = computed(() => { + const c = state.calibration || {}; + if (!calibrationSessionMatchesSelection.value || calibrationMoving.value + || (c.state || 'idle') === 'idle') return null; + if (calibrationRouteOccupied.value) { + return { + action: ['cancelled', 'failed'].includes(c.state) ? '' : 'cancel', + mm: Math.round(Number(c.tip_position_mm) || 0), + }; + } + return {action: 'reset', mm: 0}; + }); + const calibrationClearLead = computed(() => { + const plan = calibrationClearPlan.value; + if (plan.level === 'slot') { + return plan.staysCalibrated + ? t('ui.calibration.clear_falls_ace', {ace: dispIdx(plan.ace)}) + : t('ui.calibration.clear_falls_default'); + } + if (!plan.affected.length) { + return t('ui.calibration.clear_affected_none', {ace: dispIdx(plan.ace)}); + } + return t(plan.affected.length === 1 + ? 'ui.calibration.clear_affected_one' : 'ui.calibration.clear_affected_many', + {slots: _calibrationSlotList(plan.affected)}); + }); + function calibrationClearOpen() { + if (calibrationClearBlocked.value || calibrationUi.busy) return; + calibrationClear.open = true; + } + function _calibrationClearBarClose() { + calibrationClear.undo = null; + calibrationClear.restored = false; + clearTimeout(_calibrationRestoredTimer); + } + // Writes the three keys at one level and saves the config the way Save + // does. On a failed save the form gets its previous values back. + async function _calibrationWriteLevel(level, ace, slot, values, busy) { + const aceCfg = configForm.perAce?.[ace]; + const target = level === 'slot' ? aceCfg?.perSlot?.[slot] : aceCfg; + if (!target) { + calibrationUi.error = t('ui.calibration.err_no_section'); + return false; + } + const previous = {}; + for (const {key} of CAL_CLEAR_KEYS) { + previous[key] = target[key]; + target[key] = values[key]; + } + calibrationUi.busy = busy; + calibrationUi.error = ''; + await saveConfigForm(); + calibrationUi.busy = ''; + const ok = String(configLog.value || '').startsWith('✓'); + if (!ok) { + Object.assign(target, previous); + calibrationUi.error = configLog.value || t('ui.calibration.err_save'); + } + return ok; + } + async function calibrationClearConfirm() { + const plan = calibrationClearPlan.value; + const session = calibrationClearSession.value; + if (calibrationClearBlocked.value || calibrationUi.busy) return; + calibrationClear.open = false; + const old = {}; + for (const row of plan.rows) old[row.key] = row.old == null ? '' : row.old; + const blank = {load_length: '', swap_retract_length: '', retract_length: ''}; + if (!await _calibrationWriteLevel(plan.level, plan.ace, plan.slot, blank, 'clear')) return; + _calibrationClearBarClose(); + calibrationClear.undo = {level: plan.level, ace: plan.ace, slot: plan.slot, values: old}; + calibrationClear.sha1 = config.sha1; + calibrationUi.saved = false; + if (session?.action) await calibrationAction(session.action); + } + // Config only: a run reset by the clear stays gone. + async function calibrationClearUndo() { + const undo = calibrationClear.undo; + if (!undo || calibrationUi.busy) return; + if (!await _calibrationWriteLevel(undo.level, undo.ace, undo.slot, undo.values, 'undo')) return; + _calibrationClearBarClose(); + calibrationClear.restored = true; + calibrationClear.sha1 = config.sha1; + _calibrationRestoredTimer = setTimeout(() => { calibrationClear.restored = false; }, 6000); + } + function clearAllErrors() { cmdQueue.value = cmdQueue.value.filter(it => it.status !== 'error'); cmdPaused.value = false; @@ -5003,6 +5420,21 @@ createApp({ || state.calibration?.state !== 'idle') return; calibrationSetPanel(routeKnown ? 'verify' : 'setup', false); }, {immediate: true}); + // A new route (or the config arriving) starts the scope from the + // route's badge: own values -> this slot, otherwise the whole ACE. The + // undo bar of a clear belongs to the route it was made on. + watch(() => [calibrationUi.ace, calibrationUi.slot, calibrationUi.head, config.path], + (next, previous) => { + calibrationUi.scope = calibrationSavedKind.value === 'own' ? 'slot' : 'ace'; + if (previous && next.slice(0, 3).some((v, i) => Number(v) !== Number(previous[i]))) { + _calibrationClearBarClose(); + } + }, {immediate: true}); + // Any other config write (or a reload of a changed file) ends the undo. + watch(() => config.sha1, (sha1) => { + if ((calibrationClear.undo || calibrationClear.restored) + && sha1 !== calibrationClear.sha1) _calibrationClearBarClose(); + }); // True after a config save, which needs a full printer restart to take // effect (a bare Klipper restart misses USB/serial + PAXX boot-script // changes). Drives the prominent top reboot banner; cleared once a restart @@ -7930,21 +8362,36 @@ createApp({ calibrationUi, connectedCalibrationAces, calibrationSelectedAce, calibrationSelectedSlot, calibrationSelectedToolhead, calibrationMoving, calibrationRouteLocked, - calibrationVerifyMoving, calibrationRouteKnown, calibrationRouteAnchors, - calibrationVerifyBlockedReason, calibrationTabStatus, CALIBRATION_TAB, + calibrationVerifyMoving, calibrationVerifyActive, calibrationRouteKnown, + calibrationRouteAnchors, calibrationRouteOccupied, + calibrationVerifyBlockedReason, calibrationStartBlockedReason, + calibrationTabStatus, CALIBRATION_TAB, + calibrationStatus, calibrationRecovery, calibrationMeasureStep, + calibrationVerifyStep, calibrationResultCase, calibrationNothingToSave, + calibrationOwnValueSlots, calibrationSlotBadge, calibrationTipPositionMm, calibrationTipSegment, calibrationTipSegmentPercent, calibrationTipLabel, + calibrationHubMm, calibrationRouteLengthMm, calibrationRouteGrid, calibrationSelectedHeadSensorKnown, calibrationSelectedHeadClear, calibrationCanPrepare, calibrationCanStart, calibrationCanVerifyConfigured, calibrationProposed, + calibrationTipEstimated, calibrationSetPanel, calibrationContinueRoute, calibrationBeginRecalibration, calibrationKeepSaved, - calibrationSetScope, calibrationRouteChanged, calibrationPrepare, - calibrationConfirmBaseline, - calibrationCancelPrepare, calibrationConfirmRoute, + calibrationSetScope, calibrationPrepare, + calibrationCancelPrepare, calibrationSelectAce, calibrationSelectSlot, calibrationSelectHead, - calibrationStart, + calibrationStart, calibrationMarkAndReturn, calibrationUndoMark, + calibrationDrag, calibrationTipNowMm, calibrationTrackHubMm, + calibrationTrackPercent, calibrationDragLabel, calibrationDragStart, + calibrationDragMove, calibrationDragEnd, calibrationDragCancel, calibrationJog, + calibrationNextRoute, + calibrationClear, calibrationSavedKind, calibrationSavedLevels, + calibrationClearPlan, calibrationClearBlocked, calibrationClearSession, + calibrationClearLead, calibrationClearOpen, calibrationClearConfirm, + calibrationClearUndo, calibrationVerifyConfigured, calibrationAction, calibrationApply, + calibrationVerifyValues, panelMode, panelAce, panelAceIdx, panelSlotHead, panelPages, panelPage, panelPageId, panelFeederHeads, setPanelPage, panelSlotHeadLoaded, panelSlotActive, panelSlotLabel, panelSlotOp, panelMini, fullUiHref, uiTheme, uiThemeChoice, uiLight, setUiTheme, spoolPct, gaugeColor, thSettingsOpen, slotGaugePct, slotGaugeLabel, headGaugePct, headGaugeText, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 9ef5f22..f990b38 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -768,13 +768,11 @@

{{ t('ui.dashboard.snapshots') }}

-

{{ calibrationUi.error }}

{{ t('ui.calibration.title') }}

{{ t('ui.calibration.subtitle') }}

- {{ t('ui.calibration.session') }} {{ state.calibration.state || 'idle' }}
{{ t('ui.calibration.no_ace') }}
@@ -783,61 +781,126 @@

{{ t('ui.calibration.title') }}

ACE {{ dispIdx(calibrationUi.ace) }} · Slot {{ dispIdx(calibrationUi.slot) }} - → Toolhead T{{ dispIdx(calibrationUi.head) }} + → T{{ dispIdx(calibrationUi.head) }} {{ calibrationTipLabel }}
- - {{ calibrationMoving ? 'Moving' - : state.calibration.state === `verify_paused` ? 'Paused' - : calibrationRouteKnown ? t('ui.calibration.route_known') : t('ui.calibration.route_needs') }} - + {{ calibrationStatus.text }}
- ACE {{ dispIdx(calibrationUi.ace) }}Slot {{ dispIdx(calibrationUi.slot) }} + ACE {{ dispIdx(calibrationUi.ace) }} · S{{ dispIdx(calibrationUi.slot) }}{{ t('ui.calibration.park_sub') }}
- {{ calibrationTipPositionMm == null ? '?' : '●' }} + {{ calibrationTipPositionMm == null ? '?' + : calibrationTipEstimated ? '○' : '●' }} + {{ Math.round(calibrationHubMm) }} mm
- {{ t('ui.calibration.splitter') }}{{ t('ui.calibration.splitter_sub') }} + {{ t('ui.calibration.splitter') }}{{ calibrationHubMm == null + ? t('ui.calibration.splitter_unknown') : t('ui.calibration.splitter_sub') }}
● + :style='{"--tip": `${calibrationTipSegmentPercent}%`}'>{{ calibrationTipEstimated ? '○' : '●' }} + {{ Math.round(calibrationRouteLengthMm - calibrationHubMm) }} mm
- Toolhead T{{ dispIdx(calibrationUi.head) }} - {{ calibrationSelectedHeadClear ? 'sensor clear' + T{{ dispIdx(calibrationUi.head) }} + {{ calibrationSelectedHeadClear ? t('ui.calibration.sensor_clear') : calibrationSelectedToolhead?.filament_at_extruder === true - ? 'filament detected' : 'sensor unavailable' }} + ? t('ui.calibration.sensor_on') : t('ui.calibration.sensor_na') }}
- - {{ t('ui.calibration.anchor_splitter') }} {{ calibrationRouteAnchors.load - calibrationRouteAnchors.swap }} mm + + {{ calibrationRouteAnchors.source === 'measured' + ? t('ui.calibration.src_measured') : t('ui.calibration.src_saved') }}: + {{ t('ui.calibration.anchor_splitter') }} {{ Math.round(calibrationHubMm) }} mm · {{ t('ui.calibration.anchor_head') }} {{ calibrationRouteAnchors.load }} mm {{ t('ui.calibration.anchors_none') }} - {{ calibrationUi.scope === 'slot' - ? 'Slot ' + dispIdx(calibrationUi.slot) + ' override' - : t('ui.calibration.scope_ace_short') }} +
+ {{ t('ui.calibration.glossary') }} +
+
{{ t('ui.calibration.g_park') }}
{{ t('ui.calibration.g_park_text') }}
+
{{ t('ui.calibration.splitter') }}
{{ t('ui.calibration.g_hub_text') }}
+
{{ t('ui.calibration.m_toolhead') }}
{{ t('ui.calibration.g_toolhead_text') }}
+
{{ t('ui.calibration.m_splitter') }}
{{ t('ui.calibration.g_clearance_text') }}
+
{{ t('ui.calibration.g_margin') }}
{{ t('ui.calibration.g_margin_text') }}
+
+
+
+ +

{{ calibrationUi.error }}

+ + +
+
+ {{ t('ui.calibration.rec_title', {mm: Math.round(calibrationTipPositionMm ?? 0)}) }} + {{ calibrationRecovery.canResume + ? t('ui.calibration.rec_paused') : t('ui.calibration.rec_ended') }} + {{ state.calibration.error }} +
+
+ + + +
+
+
+
+ {{ t('ui.calibration.rec_homing') }} + {{ t('ui.calibration.rec_homing_text', {mm: Math.round(calibrationTipPositionMm ?? 0)}) }} +
+
+ +
+
+ + +
+
+ {{ calibrationClear.undo.level === 'slot' + ? t('ui.calibration.clear_done_slot', {slot: dispIdx(calibrationClear.undo.slot)}) + : t('ui.calibration.clear_done_ace', {ace: dispIdx(calibrationClear.undo.ace)}) }} + {{ t('ui.calibration.clear_undo_hint') }} +
+ +
+
+
+ {{ t('ui.calibration.clear_undo_done') }} + {{ t('ui.calibration.clear_undo_done_hint') }} +
+ :aria-label='t(`ui.calibration.workflow_aria`)'>
+ +
@@ -858,34 +921,13 @@

{{ t('ui.calibration.setup_head') }}

@click='calibrationSelectAce(ace.idx)'> ACE {{ dispIdx(ace.idx) }} {{ ace.protocol === 'v2' - ? 'ACE 2 Pro · connected' : 'ACE Pro · connected' }} + ? t('ui.calibration.unit_v2') : t('ui.calibration.unit_v1') }}
- -
- - -
-
- -
- +
-
- -
- {{ calibrationUi.scope === 'ace' - ? t('ui.calibration.shared_via') : t('ui.calibration.scope_slot_short') }} - ACE {{ dispIdx(calibrationUi.ace) }} · Slot - {{ dispIdx(calibrationUi.slot) }} → T{{ dispIdx(calibrationUi.head) }} + || head.manual || head.feeder + || (state.mode === `multi` + && Number(head.idx) !== Number(calibrationUi.slot))' + @click='calibrationSelectHead(head.idx)'> + T{{ dispIdx(head.idx) }} + {{ head.manual ? t('ui.calibration.head_manual') + : head.feeder ? t('ui.calibration.head_feeder') + : t('ui.calibration.head_ace') }} +
+ +
+
+ {{ t('ui.calibration.values_head') }} + {{ calibrationSlotBadge(calibrationSelectedSlot) }} +
+ + + + + + + + + + + + + +
{{ t('ui.calibration.row_load') }}{{ t('ui.calibration.m_splitter') }}{{ t('ui.calibration.row_retract') }}
{{ level.name }}{{ t('ui.calibration.values_in_use') }}{{ value ?? '–' }}
+
+ +
+
+
+ + +
+
+ +
+
+

{{ calibrationClearBlocked.why }}

+
+

- Route locked to ACE {{ dispIdx(state.calibration.ace) }} · Slot - {{ dispIdx(state.calibration.slot) }} while the tip is away from - preload. Return or clear this route before selecting another spool. + {{ t('ui.calibration.route_locked') }}

@@ -933,296 +1021,367 @@

{{ t('ui.calibration.setup_head') }}

&& state.printer_state !== `paused`}'>{{ t('ui.calibration.chk_idle') }}
{{ t('ui.calibration.chk_no_move') }}
-
- T{{ dispIdx(calibrationUi.head) }} sensor - {{ calibrationSelectedHeadClear ? 'is clear' : 'needs attention' }} -
-
- Slot {{ dispIdx(calibrationUi.slot) }} - {{ calibrationSelectedSlot?.state === 'ready' - ? 'is parked at preload' : 'is not ready' }} -
-
- -
- {{ calibrationSelectedAce?.protocol === 'v2' - ? t('ui.calibration.conf_encoder') : t('ui.calibration.conf_estimate') }} - - {{ t('ui.calibration.conf_encoder_hint') }} - - {{ t('ui.calibration.conf_estimate_hint') }}
+
- + - + - +
+
{{ t('ui.calibration.tab_verify') }} -

{{ calibrationVerifyMoving - || [`verify_toolhead`, `verify_toolhead_adjust`, - `verify_toolhead_adjusting`, `verify_splitter`, - `verify_paused`].includes(state.calibration.state) - ? t('ui.calibration.verify_running') : t('ui.calibration.verify_head') }}

+

{{ calibrationVerifyActive + ? t('ui.calibration.verify_running') : t('ui.calibration.verify_head') }}

{{ t('ui.calibration.verify_hint') }}

- {{ t('ui.calibration.available') }}
- + - -
+
{{ t('ui.calibration.tab_results') }} -

{{ state.calibration.state === 'verified' - ? t('ui.calibration.r_passed') : t('ui.calibration.r_title') }}

-

{{ t('ui.calibration.r_hint') }}

+

{{ calibrationResultCase + ? t('ui.calibration.r_head_' + calibrationResultCase) + : t('ui.calibration.r_head_none') }}

+

{{ calibrationResultCase + ? t('ui.calibration.r_lead_' + calibrationResultCase) + : t('ui.calibration.r_lead_none') }}

- + +

{{ calibrationClearBlocked.why }}

@@ -2961,6 +3217,47 @@

{{ confirmDialog.title }}

+ +
+ +

diff --git a/multiace/web/frontend/style-new.css b/multiace/web/frontend/style-new.css index 8b3018c..03e216f 100644 --- a/multiace/web/frontend/style-new.css +++ b/multiace/web/frontend/style-new.css @@ -1392,6 +1392,284 @@ html[data-theme="new"] .nt-config-tab > .config-form > .config-row:has(.spool-pi html[data-theme="new"] .nt-config-tab > .config-form > .config-row:has(.spool-picker-row) { justify-content: flex-start; } +/* ---- Calibration tab ------------------------------------------------- + style.css paints this tab with fixed dark colours and rounded cards. + Here every surface goes through the design tokens (so the light shade + needs no rules of its own) and takes the square, line-drawn look of the + other tabs. */ +html[data-theme="new"] .calibration-page h2 { + margin: 18px 0 6px; font-size: 22px; font-weight: 600; + text-transform: none; letter-spacing: 0; color: var(--fg); +} +html[data-theme="new"] .calibration-heading { margin-bottom: 14px; } +html[data-theme="new"] .calibration-heading p.muted { font-size: 12.5px; color: var(--muted); max-width: 720px; } +html[data-theme="new"] .calibration-guided { max-width: none; } + +/* route header + diagram */ +html[data-theme="new"] .calibration-route-summary { + padding: 14px 16px; background: var(--card); border-color: var(--border); border-radius: 0; +} +html[data-theme="new"] .calibration-route-summary strong { font-size: 14px; font-weight: 700; color: var(--fg); } +html[data-theme="new"] .calibration-route-summary small { font-size: 12.5px; color: var(--muted); } +html[data-theme="new"] .calibration-route-summary > span { + padding: 4px 10px; border-radius: 0; font-size: 11.5px; font-weight: 700; + color: var(--nt-warn-fg); border-color: var(--nt-warn); background: transparent; +} +html[data-theme="new"] .calibration-route-summary > span.ready { color: var(--nt-ok); border-color: var(--nt-ok); } +html[data-theme="new"] .calibration-route-summary > span.busy { color: var(--accent); border-color: var(--accent); } +html[data-theme="new"] .calibration-route-summary > span.attention { color: var(--nt-warn-fg); border-color: var(--nt-warn); } +html[data-theme="new"] .calibration-guided .calibration-route { + padding: 18px 16px 22px; background: var(--nt-bg2); border-color: var(--border); border-radius: 0; +} +html[data-theme="new"] .calibration-route span { + padding: 8px 10px; border-radius: 0; border-color: var(--nt-ctl); + background: var(--card); color: var(--muted); font-size: 11.5px; +} +html[data-theme="new"] .calibration-route strong { color: var(--fg); font-size: 13px; font-weight: 700; } +html[data-theme="new"] .calibration-route-tube { height: 2px; border-radius: 0; background: var(--nt-ctl); } +html[data-theme="new"] .calibration-route-tube small { top: 10px; font-size: 11px; color: var(--nt-label); } +html[data-theme="new"] .calibration-tip-marker { + color: var(--accent-fg); background: var(--accent); border-color: var(--nt-bg2); +} +html[data-theme="new"] .calibration-route.paused .calibration-tip-marker { + color: var(--nt-on-color); background: var(--nt-warn-hi); +} +html[data-theme="new"] .calibration-route.unknown .calibration-tip-marker { + color: var(--nt-warn-fg); background: var(--nt-warn-bg); border-color: var(--nt-warn); +} +html[data-theme="new"] .calibration-route.moving .calibration-tip-marker { animation-name: nt-calibration-tip-pulse; } +@keyframes nt-calibration-tip-pulse { + 50% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), .18); } +} +html[data-theme="new"] .calibration-route-caption { + padding: 10px 16px; margin-bottom: 16px; font-size: 12px; + color: var(--muted); background: var(--card); border-color: var(--border); border-radius: 0; +} +html[data-theme="new"] .calibration-route-caption strong { color: var(--fg); } +html[data-theme="new"] .calibration-glossary summary { color: var(--accent); } +html[data-theme="new"] .calibration-glossary dt { color: var(--fg); } + +/* stopped / returning bar */ +html[data-theme="new"] .calibration-recovery { + margin-bottom: 16px; padding: 14px 16px; border-radius: 0; + border: 1px solid var(--nt-warn); background: var(--nt-warn-bg); + box-shadow: inset 2px 0 0 var(--nt-warn-hi); +} +html[data-theme="new"] .calibration-recovery > div:first-child { color: var(--nt-fg2); font-size: 12.5px; } +html[data-theme="new"] .calibration-recovery strong { color: var(--nt-warn-fg); font-size: 14px; } + +/* workflow shell + its four tabs */ +html[data-theme="new"] .calibration-workflow { background: var(--card); border-color: var(--border); border-radius: 0; } +html[data-theme="new"] .calibration-workflow-tabs { background: var(--nt-bg2); border-bottom-color: var(--border); } +html[data-theme="new"] .calibration-workflow-tabs button { + padding: 12px 16px; color: var(--muted); border-right-color: var(--border); +} +html[data-theme="new"] .calibration-workflow-tabs button:hover { background: rgba(var(--nt-tint), .03); } +html[data-theme="new"] .calibration-workflow-tabs button.active { + color: var(--accent); background: var(--card); box-shadow: inset 0 -2px 0 var(--accent); +} +html[data-theme="new"] .calibration-workflow-tabs strong { font-size: 13.5px; font-weight: 600; color: var(--fg); } +html[data-theme="new"] .calibration-workflow-tabs button.active strong { color: var(--accent); } +html[data-theme="new"] .calibration-workflow-tabs small { font-size: 11.5px; } +html[data-theme="new"] .calibration-workflow-body { padding: 20px; } + +/* panel heading */ +html[data-theme="new"] .calibration-task-heading > div > small { + margin-bottom: 4px; font-size: 12px; font-weight: 400; letter-spacing: 0; + text-transform: none; color: var(--nt-label); +} +html[data-theme="new"] .calibration-task-heading h3 { + margin: 0 0 4px; font-size: 17px; font-weight: 600; + text-transform: none; letter-spacing: 0; color: var(--fg); +} +html[data-theme="new"] .calibration-task-heading p { font-size: 12.5px; color: var(--muted); } +html[data-theme="new"] .calibration-task-heading > span { + padding: 4px 10px; border-radius: 0; font-size: 11.5px; + color: var(--accent); border-color: var(--accent); +} + +/* route choices (ACE, slot, toolhead, save-for) */ +html[data-theme="new"] .calibration-selector-block > label { font-size: 12px; color: var(--muted); } +html[data-theme="new"] .calibration-route-option { + padding: 10px 12px; border-radius: 0; color: var(--fg); + background: var(--nt-input); border: 1px solid var(--nt-ctl); +} +html[data-theme="new"] .calibration-route-option strong { font-size: 13.5px; font-weight: 700; } +html[data-theme="new"] .calibration-route-option small { font-size: 11.5px; color: var(--muted); } +html[data-theme="new"] .calibration-route-option:hover:not(:disabled) { border-color: var(--accent); } +html[data-theme="new"] .calibration-route-option.selected { + border-color: var(--accent); background: var(--nt-sel); box-shadow: inset 2px 0 0 var(--accent); +} +html[data-theme="new"] .calibration-route-option:disabled { opacity: 1; color: var(--nt-disabled); border-color: var(--nt-line); } +html[data-theme="new"] .calibration-route-option:disabled small { color: var(--nt-disabled); } +html[data-theme="new"] .calibration-route-option.selected:disabled { border-color: var(--nt-ctl); box-shadow: inset 2px 0 0 var(--nt-ctl); color: var(--nt-fg2); } + +/* check list: a line each, the state carried by the mark */ +html[data-theme="new"] .calibration-checks { gap: 0 24px; } +html[data-theme="new"] .calibration-checks div { + padding: 10px 0; border: 0; border-bottom: 1px solid var(--nt-line); border-radius: 0; + background: transparent; color: var(--muted); font-size: 12.5px; +} +html[data-theme="new"] .calibration-checks div::before { color: var(--nt-warn-fg); } +html[data-theme="new"] .calibration-checks div.ok { color: var(--fg); background: transparent; border-color: var(--nt-line); } +html[data-theme="new"] .calibration-checks div.ok::before { color: var(--nt-ok); } + +/* step chips: a rule above, like the tab underline */ +html[data-theme="new"] .calibration-steps { gap: 12px; margin: 14px 0; } +html[data-theme="new"] .calibration-steps div { + padding: 8px 0 0; border: 0; border-top: 2px solid var(--nt-ctl); border-radius: 0; + background: transparent; color: var(--nt-label); font-size: 12px; +} +html[data-theme="new"] .calibration-steps div.active { color: var(--accent); border-top-color: var(--accent); } +html[data-theme="new"] .calibration-steps div.done { color: var(--nt-ok); border-top-color: var(--nt-ok); } + +/* value cells */ +html[data-theme="new"] .calibration-metrics { gap: 0; border: 1px solid var(--border); } +html[data-theme="new"] .calibration-metrics div { + padding: 12px 16px; border: 0; border-right: 1px solid var(--border); border-radius: 0; background: var(--nt-bg2); +} +html[data-theme="new"] .calibration-metrics div:last-child { border-right: 0; } +html[data-theme="new"] .calibration-metrics small { font-size: 12px; text-transform: none; color: var(--nt-label); } +html[data-theme="new"] .calibration-metrics strong { font-size: 18px; font-weight: 700; color: var(--fg); } +html[data-theme="new"] .calibration-metrics span { font-size: 11.5px; color: var(--muted); } + +/* jog panels */ +html[data-theme="new"] .calibration-jog-panel, +html[data-theme="new"] .calibration-fine-position { + padding: 14px 16px; border-radius: 0; background: var(--nt-bg2); border-color: var(--border); +} +html[data-theme="new"] .calibration-fine-position { border-color: var(--nt-warn); } +html[data-theme="new"] .calibration-fine-head span, +html[data-theme="new"] .calibration-fine-row > span { font-size: 12px; color: var(--muted); } +html[data-theme="new"] .calibration-fine-row > strong { color: var(--fg); } + +/* buttons: outlined by default, the accent fill for the one next step */ +html[data-theme="new"] .calibration-jogs button, +html[data-theme="new"] .calibration-page .row button { + min-height: 34px; padding: 0 14px; border-radius: 0; font-size: 12.5px; + color: var(--fg); background: transparent; border: 1px solid var(--nt-ctl); +} +html[data-theme="new"] .calibration-jogs button:hover:not(:disabled), +html[data-theme="new"] .calibration-page .row button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +html[data-theme="new"] .calibration-page .row button.primary { + min-height: 40px; padding: 0 18px; font-weight: 700; + color: var(--accent-fg); background: var(--accent); border-color: var(--accent); +} +html[data-theme="new"] .calibration-page .row button.primary:hover:not(:disabled) { + color: var(--accent-fg); background: var(--accent-hi); border-color: var(--accent-hi); +} +html[data-theme="new"] .calibration-page .row button.warn, +html[data-theme="new"] .calibration-jogs button.warn { + color: var(--nt-danger-fg); border-color: var(--nt-danger); background: transparent; +} +html[data-theme="new"] .calibration-page .row button.warn:hover:not(:disabled) { + color: var(--nt-on-color); background: var(--nt-danger); border-color: var(--nt-danger); +} +html[data-theme="new"] .calibration-page button:disabled { opacity: 1; } +html[data-theme="new"] .calibration-jogs button:disabled, +html[data-theme="new"] .calibration-page .row button:disabled { color: var(--nt-disabled); border-color: var(--nt-line); } +html[data-theme="new"] .calibration-page .row button.primary:disabled { + color: var(--nt-main-dis-fg); background: var(--nt-main-dis-bg); border-color: var(--nt-main-dis-bg); +} + +/* drag track */ +html[data-theme="new"] .calibration-track-wrap { + padding: 14px 16px 10px; border-radius: 0; background: var(--nt-bg2); border-color: var(--border); +} +html[data-theme="new"] .calibration-track-wrap > p { font-size: 12.5px; color: var(--muted); } +html[data-theme="new"] .calibration-track { height: 2px; border-radius: 0; background: var(--nt-ctl); } +html[data-theme="new"] .calibration-track-fill { border-radius: 0; background: var(--accent); } +html[data-theme="new"] .calibration-track-hub { background: var(--nt-warn-hi); } +html[data-theme="new"] .calibration-track-hub small { color: var(--nt-warn-fg); font-size: 11px; } +html[data-theme="new"] .calibration-track-handle { background: var(--accent); border-color: var(--nt-bg2); } +html[data-theme="new"] .calibration-track-ghost { border-color: var(--accent); } +html[data-theme="new"] .calibration-track-ends { font-size: 11.5px; color: var(--nt-label); } +html[data-theme="new"] .calibration-drag-label { font-size: 12.5px; color: var(--accent); } + +/* messages */ +html[data-theme="new"] .calibration-notice, +html[data-theme="new"] .calibration-warning, +html[data-theme="new"] .calibration-success { + padding: 12px 16px; border-radius: 0; font-size: 12.5px; line-height: 1.5; +} +html[data-theme="new"] .calibration-notice { color: var(--muted); background: var(--nt-bg2); border-color: var(--border); } +html[data-theme="new"] .calibration-notice strong { color: var(--fg); } +html[data-theme="new"] .calibration-warning { + color: var(--nt-warn-fg); background: var(--nt-warn-bg); border: 1px solid var(--nt-warn); + box-shadow: inset 2px 0 0 var(--nt-warn-hi); +} +html[data-theme="new"] .calibration-success { + color: var(--fg); background: var(--nt-bg2); border: 1px solid var(--border); + box-shadow: inset 2px 0 0 var(--nt-ok); +} +html[data-theme="new"] .calibration-success strong { color: var(--nt-ok); } +html[data-theme="new"] .calibration-details { color: var(--muted); font-size: 12.5px; } +html[data-theme="new"] .calibration-panel p.muted { font-size: 12.5px; color: var(--muted); } +html[data-theme="new"] .calibration-details summary { color: var(--fg); } + +/* results table */ +html[data-theme="new"] .calibration-review { border: 1px solid var(--border); font-size: 13px; } +html[data-theme="new"] .calibration-review th { + padding: 10px 12px; font-size: 12px; font-weight: 400; color: var(--muted); border-bottom-color: var(--border); +} +html[data-theme="new"] .calibration-review td { padding: 10px 12px; border-bottom-color: var(--border); } +html[data-theme="new"] .calibration-review tr:last-child td { border-bottom: 0; } +html[data-theme="new"] .calibration-review td small { font-size: 11.5px; color: var(--muted); } +html[data-theme="new"] .calibration-review td.save { color: var(--accent); font-weight: 700; font-size: 14px; } +html[data-theme="new"] .calibration-review input { + height: 30px; border-radius: 0; color: var(--fg); background: var(--nt-input); border: 1px solid var(--nt-ctl); +} +html[data-theme="new"] .calibration-review input:focus { outline: none; border-color: var(--accent); } + +/* saved values, clear, undo bar and the clear dialog */ +html[data-theme="new"] .calibration-saved-values { + padding: 14px 16px; border-radius: 0; background: var(--nt-bg2); border-color: var(--border); +} +html[data-theme="new"] .calibration-saved-head { font-size: 12px; } +html[data-theme="new"] .calibration-badge { border-radius: 0; font-size: 11.5px; border-color: var(--nt-ctl); } +html[data-theme="new"] .calibration-badge.own { color: var(--accent); border-color: var(--accent); } +html[data-theme="new"] .calibration-badge.shared { color: var(--nt-ok); border-color: var(--nt-ok); } +html[data-theme="new"] .calibration-badge.none { color: var(--nt-warn-fg); border-color: var(--nt-warn); } +html[data-theme="new"] .calibration-clear-table { font-size: 13px; } +html[data-theme="new"] .calibration-clear-table th { font-size: 12px; font-weight: 400; color: var(--nt-label); } +html[data-theme="new"] .calibration-clear-table th, +html[data-theme="new"] .calibration-clear-table td { padding: 8px 10px; border-bottom-color: var(--nt-line); } +html[data-theme="new"] .calibration-clear-table td small { font-size: 11px; color: var(--nt-label); } +html[data-theme="new"] .calibration-clear-table tr.unset td { color: var(--nt-disabled); } +html[data-theme="new"] .calibration-clear-table td.changed { color: var(--nt-warn-fg); } +html[data-theme="new"] .calibration-clear-reason { font-size: 12px; } +html[data-theme="new"] .calibration-clear-reason.blocked { color: var(--nt-warn-fg); } +html[data-theme="new"] .calibration-undo { + border-radius: 0; font-size: 12.5px; background: var(--nt-bg2); + border-color: var(--border); border-left: 1px solid var(--border); + box-shadow: inset 2px 0 0 var(--nt-warn-hi); +} +html[data-theme="new"] .calibration-undo.done { box-shadow: inset 2px 0 0 var(--nt-ok); } +html[data-theme="new"] .nt-confirm.calibration-clear-dialog { width: 500px; } +html[data-theme="new"] .nt-confirm > .calibration-clear-table { margin: 14px 18px 0; width: calc(100% - 36px); } +html[data-theme="new"] .nt-confirm > .calibration-warning, +html[data-theme="new"] .nt-confirm > .calibration-notice { margin: 12px 18px 0; } +html[data-theme="new"] .nt-confirm .nt-dlg-foot .nt-main.calibration-clear-confirm { + color: var(--nt-on-color); background: var(--nt-danger); border-color: var(--nt-danger); +} +@media (max-width: 760px) { + html[data-theme="new"] .calibration-workflow-body { padding: 14px 12px; } + html[data-theme="new"] .calibration-steps { gap: 8px; } + html[data-theme="new"] .calibration-steps div { font-size: 11px; } +} +@media (max-width: 560px) { + html[data-theme="new"] .calibration-guided .calibration-route { padding: 14px 12px; } + html[data-theme="new"] .calibration-route-tube { width: 2px; height: 34px; } + html[data-theme="new"] .calibration-route-tube small { top: 50%; } + html[data-theme="new"] .calibration-metrics div { border-right: 0; border-bottom: 1px solid var(--border); } + html[data-theme="new"] .calibration-metrics div:last-child { border-bottom: 0; } + html[data-theme="new"] .calibration-review th, + html[data-theme="new"] .calibration-review td { padding: 8px 6px; } + html[data-theme="new"] .calibration-page .row button.primary { flex: 1 1 100%; } + html[data-theme="new"] .calibration-clear-table th, + html[data-theme="new"] .calibration-clear-table td { padding: 7px 5px; } +} + /* ---- Light shade: surfaces the new design inherits from style.css ---- style.css paints these with fixed dark colours. The dark shade lives with that, the light one replaces them here. Kept at the end of the file diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index 83fa6d7..700e0a7 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -1483,6 +1483,8 @@ body.panel-embedded .sm-right { font-size: .68rem; } } .calibration-derived-route > div small { color: var(--muted); } .calibration-primary-actions { margin-top: 1rem; } +.calibration-mark-hint { margin: 1rem 0 0; font-size: .76rem; } +.calibration-mark-hint + .calibration-primary-actions { margin-top: .5rem; } .calibration-primary-actions .primary { padding: .6rem 1rem; } .calibration-saved-summary { min-height: 280px; } .calibration-notice { @@ -1951,6 +1953,214 @@ body:has(.panel) .overlay .dialog { background: var(--card); border: 1px solid var(--border); border-radius: 4px; white-space: pre-wrap; font-size: .8rem; } +/* Guided calibration: to-scale route, steps, recovery, hold */ +@media (min-width: 761px) { + .calibration-guided .calibration-route { + grid-template-columns: var(--route-cols, auto 1fr auto 1fr auto); + } +} +.calibration-route span.unknown { border-style: dashed; opacity: .6; } +.calibration-route-tube small { + position: absolute; top: 9px; left: 50%; transform: translateX(-50%); + color: var(--muted); font-size: .62rem; white-space: nowrap; +} +.calibration-route-summary > span.busy { + color: var(--accent); border-color: #2b666a; +} +.calibration-route-summary > span.attention { + color: var(--warn); border-color: #735429; +} +.calibration-route-caption { align-items: flex-start; } +.calibration-glossary { flex: 0 1 auto; text-align: right; } +.calibration-glossary summary { color: var(--accent); cursor: pointer; } +.calibration-glossary dl { + display: grid; grid-template-columns: max-content minmax(0, 34rem); + gap: .3rem .9rem; margin: .5rem 0 0; text-align: left; +} +.calibration-glossary dt { color: var(--fg); } +.calibration-glossary dd { margin: 0; } +.calibration-recovery { + display: flex; align-items: center; justify-content: space-between; + gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; padding: .8rem 1rem; + border: 1px solid #735429; border-radius: 7px; background: #342815; +} +.calibration-recovery > div:first-child { + display: flex; flex-direction: column; gap: .2rem; min-width: 0; + color: #ffd6a0; font-size: .8rem; +} +.calibration-recovery strong { font-size: .9rem; } +.calibration-recovery .row { margin: 0; } +.calibration-page .row button.warn, .calibration-jogs button.warn { + color: var(--error); border-color: var(--error); background: var(--error-bg); +} +.calibration-steps { + display: grid; grid-template-columns: repeat(3, 1fr); gap: .4rem; + margin: .8rem 0; +} +.calibration-steps div { + padding: .45rem .55rem; border: 1px solid var(--border); border-radius: 4px; + color: var(--muted); background: #191919; font-size: .74rem; +} +.calibration-steps div.active { color: var(--accent); border-color: #2b666a; } +.calibration-steps div.done { color: var(--ok); border-color: #2d7137; } +.calibration-metrics.two { grid-template-columns: repeat(2, 1fr); } +.calibration-metrics span { color: var(--muted); font-size: .68rem; } +.calibration-jog-panel { + display: grid; gap: .65rem; margin: .8rem 0; padding: .75rem; + border: 1px solid var(--border); border-radius: 5px; background: #191919; +} +.calibration-jog-panel .calibration-jogs { margin: 0; } +.calibration-track-wrap { + margin: .8rem 0; padding: .75rem .9rem .6rem; + border: 1px solid var(--border); border-radius: 5px; background: #191919; +} +.calibration-track-wrap > p { margin: 0 0 1.4rem; color: var(--muted); font-size: .74rem; } +.calibration-track { + position: relative; height: 4px; margin: 0 12px; border-radius: 4px; + background: #484848; cursor: pointer; touch-action: none; +} +.calibration-track::before { /* a taller hit area than the 4 px line */ + content: ''; position: absolute; left: -12px; right: -12px; top: -14px; bottom: -14px; +} +.calibration-track.disabled { cursor: not-allowed; opacity: .55; } +.calibration-track-fill { + position: absolute; left: 0; top: 0; bottom: 0; border-radius: 4px; background: var(--accent); +} +.calibration-track-hub { + position: absolute; top: -9px; width: 2px; height: 22px; + background: var(--warn); transform: translateX(-1px); pointer-events: none; +} +.calibration-track-hub small { + position: absolute; top: -17px; left: 50%; transform: translateX(-50%); + color: var(--warn); font-size: .64rem; white-space: nowrap; +} +.calibration-track-handle, .calibration-track-ghost { + position: absolute; top: 50%; width: 22px; height: 22px; border-radius: 50%; + transform: translate(-50%, -50%); pointer-events: none; +} +.calibration-track-handle { background: var(--accent); border: 2px solid #111; } +.calibration-track.dragging .calibration-track-handle { opacity: .55; } +.calibration-track-ghost { border: 2px dashed var(--accent); } +.calibration-track-ends { + display: flex; justify-content: space-between; margin-top: .75rem; + color: var(--muted); font-size: .68rem; +} +.calibration-drag-label { + display: flex; flex-wrap: wrap; align-items: center; justify-content: center; + gap: .5rem; min-height: 1.2rem; margin-top: .35rem; text-align: center; + color: var(--accent); font-size: .76rem; +} +.calibration-review td small { + display: block; color: var(--muted); font-size: .66rem; +} +.calibration-review td.save { color: var(--accent); font-weight: 700; } +.calibration-review input { + width: 4.5rem; background: #111; color: var(--fg); text-align: right; + border: 1px solid var(--border); border-radius: 4px; padding: .25rem; +} +.calibration-success strong { display: block; } +.calibration-panel p.muted { margin: .6rem 0; font-size: .78rem; line-height: 1.45; } +/* Saved values of the route on Setup, with the scope and Clear. */ +.calibration-saved-values { + margin-top: 1rem; padding: .75rem; + background: var(--bg); border: 1px solid var(--border); border-radius: 5px; +} +.calibration-saved-head { + display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; + margin-bottom: .5rem; color: var(--muted); font-size: .72rem; +} +.calibration-badge { + padding: .18rem .5rem; border: 1px solid var(--border); border-radius: 999px; + color: var(--muted); font-size: .68rem; white-space: nowrap; +} +.calibration-badge.own { color: var(--accent); border-color: var(--accent); } +.calibration-badge.shared { color: var(--ok); border-color: var(--ok); } +.calibration-badge.none { color: var(--warn); border-color: var(--warn); } +.calibration-clear-table { + width: 100%; border-collapse: collapse; font-size: .76rem; + font-variant-numeric: tabular-nums; +} +.calibration-clear-table th, .calibration-clear-table td { + padding: .38rem .4rem; text-align: right; + border-bottom: 1px solid var(--border); +} +.calibration-clear-table th:first-child, +.calibration-clear-table td:first-child { text-align: left; } +.calibration-clear-table th { color: var(--muted); font-weight: 500; } +.calibration-clear-table tr:last-child td { border-bottom: 0; } +.calibration-clear-table td small { + display: block; color: var(--muted); font-size: .66rem; +} +.calibration-clear-table tr.unset td { color: var(--muted); } +.calibration-clear-table tr.in-use td:first-child { color: var(--fg); font-weight: 600; } +.calibration-clear-table .in-use-tag { + margin-left: .3rem; color: var(--accent); font-size: .64rem; font-weight: 500; +} +.calibration-clear-table td.changed { color: var(--warn); font-weight: 600; } +.calibration-saved-values .calibration-selector-block { margin-top: .7rem; } +.calibration-clear-row { + display: flex; flex-wrap: wrap; align-items: stretch; gap: .5rem; + margin-top: .7rem; +} +.calibration-saved-values .calibration-selector-block + .calibration-clear-row { margin-top: 0; } +.calibration-clear-row .calibration-option-grid { flex: 1 1 260px; } +.calibration-clear-row .calibration-route-option { justify-content: center; } +.calibration-page .calibration-clear-action { flex: 0 0 auto; margin: 0; } +.calibration-page .calibration-clear-action button { height: 100%; } +.calibration-clear-reason { margin: .45rem 0 0; color: var(--muted); font-size: .72rem; } +.calibration-clear-reason::before { content: '\24D8\00A0'; } +.calibration-clear-reason.blocked { color: var(--warn); } +/* Undo bar after a clear. */ +.calibration-undo { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: .5rem .8rem; margin: 0 0 1rem; padding: .65rem .8rem; + color: var(--muted); background: var(--card); + border: 1px solid var(--border); border-left: 3px solid var(--warn); + border-radius: 5px; font-size: .78rem; line-height: 1.45; +} +.calibration-undo.done { border-left-color: var(--ok); } +.calibration-undo strong { display: block; color: var(--fg); } +.calibration-undo > div { min-width: 0; flex: 1 1 220px; } +.calibration-page .calibration-undo.row { margin: 0 0 1rem; } +/* Clear confirm dialog. */ +.dialog.calibration-clear-dialog { width: min(500px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; } +.calibration-clear-dialog .calibration-clear-table { margin: .8rem 0; } +.calibration-clear-dialog .calibration-warning, +.calibration-clear-dialog .calibration-notice { margin: .6rem 0 0; font-size: .78rem; } +.calibration-clear-dialog .calibration-notice strong { display: inline; } +/* Destructive confirm: the existing danger colour, filled. */ +.calibration-clear-dialog button.calibration-clear-confirm { + color: #fff; background: var(--danger); border-color: var(--danger); font-weight: 600; +} +.calibration-clear-dialog button.calibration-clear-confirm:hover:not(:disabled) { filter: brightness(1.12); } +.calibration-clear-dialog button.calibration-clear-confirm:disabled { opacity: .5; filter: none; } +@media (max-width: 560px) { + .calibration-clear-row { flex-direction: column; } + .calibration-clear-row .calibration-option-grid { flex: none; } + .calibration-page .calibration-clear-action, + .calibration-page .calibration-clear-action button { width: 100%; } + .calibration-clear-table th, .calibration-clear-table td { padding: .32rem .25rem; } + .calibration-undo > button { width: 100%; } +} +@media (max-width: 760px) { + .calibration-steps div { font-size: .68rem; overflow-wrap: anywhere; } + .calibration-glossary { text-align: left; } + .calibration-glossary dl { grid-template-columns: 1fr; } +} +/* Phone width: the route runs top to bottom, so the tube lengths sit beside + the tubes, and the results table drops what does not fit. */ +@media (max-width: 560px) { + .calibration-route-tube { height: 34px; } + .calibration-route-tube small { + top: 50%; left: 14px; transform: translateY(-50%); + } + .calibration-metrics.two { grid-template-columns: 1fr; } + .calibration-review th:nth-child(2), .calibration-review td:nth-child(2), + .calibration-review td small { display: none; } + .calibration-review th, .calibration-review td { padding: .4rem .3rem; } + .calibration-review input { width: 3.6rem; } + .calibration-recovery .row { width: 100%; } +} /* Control tab (Print Hub): camera, print panel, console. */ .control-tab { display: flex; flex-direction: column; gap: 1rem; max-width: 1100px; padding-top: 1rem; } diff --git a/tests/unit/test_calibration.py b/tests/unit/test_calibration.py new file mode 100644 index 0000000..347b2e0 --- /dev/null +++ b/tests/unit/test_calibration.py @@ -0,0 +1,691 @@ +"""Bowden-path calibration (ace.py ACE_CALIBRATION_*), driven with the real +MultiAce methods against a fake ACE. + +The fake ACE models what the review of PR #168 ran into on hardware: motion +starts late, 'ready' is reported late, the V2 lane encoder restarts with +every motion command, and on V1 (ACE Pro) `stop_feed_filament` stops a feed +but not an unwind - that takes `stop_unwind_filament`. A fake can also +ignore stops altogether, which is what the calibration has to survive when +a firmware does not know the stop command it is sent. +""" +import importlib +import os +import sys +import types + +import pytest + +EXTRAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', + 'multiace', 'klipper', 'extras') +PKG = 'multiace_calibration_extras' + +START_LATENCY = 0.8 # s between a motion command and the first motion +READY_LAG = 1.0 # s between the end of motion and status 'ready' +STOP_READY_LAG = 0.2 # s between an obeyed stop and status 'ready' +DECODER_RESET_LAG = 0.02 +REPLY_LAG = 0.03 +SPEED = 30 + + +def load_ace(): + name = PKG + '.ace' + if name in sys.modules: + return sys.modules[name] + if 'serial' not in sys.modules: + try: + importlib.import_module('serial') + except ImportError: + stub = types.ModuleType('serial') + stub.SerialException = Exception + sys.modules['serial'] = stub + pkg = types.ModuleType(PKG) + pkg.__path__ = [EXTRAS] + sys.modules[PKG] = pkg + return importlib.import_module(name) + + +class Reactor: + NEVER = 9e99 + NOW = 0.0 + + def __init__(self): + self.t = 100.0 + self.callbacks = [] + + def monotonic(self): + return self.t + + def register_timer(self, callback, when): + return object() + + def unregister_timer(self, timer): + pass + + def register_callback(self, callback, when=0.0): + self.callbacks.append((when, callback)) + + def pause(self, waketime): + while self.t < waketime: + self.stepper(0.01) + return self.t + + def run_due(self): + due = sorted((c for c in self.callbacks if c[0] <= self.t), + key=lambda c: c[0]) + self.callbacks = [c for c in self.callbacks if c[0] > self.t] + for _, callback in due: + callback(self.t) + + +class GCmd: + class error(Exception): + pass + + def __init__(self, **params): + self.params = params + + def get(self, key, default=None): + return self.params.get(key, default) + + def get_int(self, key, default=None): + value = self.params.get(key, default) + return None if value is None else int(value) + + +class FakePrinter: + command_error = Exception + + def lookup_object(self, name, default=None): + return default + + +HEARTBEAT_S = 1.0 # the status poll interval of ace.py's heartbeat +HEARTBEAT_LAG = 0.6 # s between sampling the unit and the report landing + + +def make_ace(true_mm, proto, stop_mode='honest', start_latency=START_LATENCY, + ready_before_motion=False, enc_scale=1.0, slip=(1.0, 1.0)): + """A MultiAce wired to one fake ACE (index 0, slot 0, head 0) whose + toolhead sensor trips once the filament tip reaches `true_mm`. + + The status the calibration reads behaves like ace.py's: every motor + command (stops included) stamps 'busy' on the cached report, and a + fresh report replaces it once per HEARTBEAT_S, reflecting the unit as it + was HEARTBEAT_LAG earlier. `ready_before_motion` makes the unit answer + 'ready' between a motion command and the first motion, as a V1 that has + not started yet may. `enc_scale` is the V2 lane encoder's counts per mm + the unit actually moves (a real unit read 1.03 on 2026-10-27). `slip` + is the filament moved per commanded mm (feed, pull-back); the encoder + counts that filament exactly. A real unit moved 1.02 feeding and 1.035 + pulling back.""" + MultiAce = load_ace().MultiAce + a = MultiAce.__new__(MultiAce) + a.reactor = Reactor() + a.printer = FakePrinter() + a.unload_decoder_gate = True + a._connected_per_ace = {0: True} + a._info_per_ace = {0: {'status': 'ready'}} + a._active_device_index = 0 + a._calibration_prev_ace = None + a._calibration_timer = None + a._calibration_move = None + a._calibration_seq = 1 + a._swap_in_progress = False + a.logs = [] + a.log_always = a.logs.append + a.log_error = lambda message: a.logs.append('ERR ' + message) + a.get_retract_speed = lambda idx: SPEED + a.get_feed_speed = lambda idx: SPEED + a._set_active_idx = lambda idx: None + a._ensure_ace_available = lambda idx: True + a.switched = [] + a._switch_ace_for_head_target = lambda idx: a.switched.append(idx) or True + a._disable_feed_assist_all = lambda: None + a.wait_ace_ready_on = lambda idx: None + ace = {'pos': 0.0, 'move': None, 'dec': 0.0, 'dec_reset_at': None, + 'ready_at': None, 'replies': [], 'sent': [], 'stuck_busy': False, + 'next_beat': a.reactor.t + 0.37, 'reports': []} + a.fake = ace + + def truth(now): + if ace['stuck_busy']: + return 'busy' + move = ace['move'] + if move is not None: + if ready_before_motion and now < move['go_at']: + return 'ready' + return 'busy' + return 'busy' if ace['ready_at'] is not None else 'ready' + + def obeys(method): + if stop_mode == 'ignore' or ace['move'] is None: + return False + if proto == 'v2': + return method == 'stop_feed_filament' + direction = ace['move']['dir'] + return ((method == 'stop_feed_filament' and direction > 0) + or (method == 'stop_unwind_filament' and direction < 0)) + + def send_request_to(idx, request, callback): + method = request['method'] + params = request.get('params', {}) + now = a.reactor.t + ace['sent'].append((method, params.get('length'))) + if method in ('feed_filament', 'unwind_filament'): + ace['move'] = {'dir': 1 if method == 'feed_filament' else -1, + 'left': float(params['length']), + 'speed': float(params['speed']), + 'go_at': now + start_latency} + ace['dec_reset_at'] = now + DECODER_RESET_LAG + ace['ready_at'] = None + a._info_per_ace[0]['status'] = 'busy' + elif method in ('stop_feed_filament', 'stop_unwind_filament'): + a._info_per_ace[0]['status'] = 'busy' # ace.py stamps stops too + if obeys(method): + ace['move'] = None + ace['ready_at'] = now + STOP_READY_LAG + elif method == 'get_feed_info': + ace['replies'].append((now + REPLY_LAG, callback)) + a.send_request_to = send_request_to + a._calibration_sensor = lambda head: ace['pos'] >= true_mm - 1e-6 + + def step(dt): + a.reactor.t += dt + now = a.reactor.t + if ace['dec_reset_at'] is not None and now >= ace['dec_reset_at']: + ace['dec'] = 0.0 + ace['dec_reset_at'] = None + move = ace['move'] + if move and now >= move['go_at']: + dist = min(move['left'], move['speed'] * dt) + moved = dist * (slip[0] if move['dir'] > 0 else slip[1]) + ace['pos'] += move['dir'] * moved + ace['dec'] += move['dir'] * moved * enc_scale + move['left'] -= dist + if move['left'] <= 1e-9: + ace['move'] = None + ace['ready_at'] = now + READY_LAG + if ace['ready_at'] is not None and now >= ace['ready_at']: + ace['ready_at'] = None + if now >= ace['next_beat']: + ace['next_beat'] += HEARTBEAT_S + ace['reports'].append((now + HEARTBEAT_LAG, truth(now))) + while ace['reports'] and ace['reports'][0][0] <= now: + # a fresh report replaces the cached dict wholesale + a._info_per_ace[0] = {'status': ace['reports'].pop(0)[1]} + due = [r for r in ace['replies'] if r[0] <= now] + ace['replies'] = [r for r in ace['replies'] if r[0] > now] + for _, callback in due: + row = {'index': 0} + if proto == 'v2': + value = int(round(ace['dec'])) + row['decoder'] = value + (1 << 64) if value < 0 else value + callback(a, {'result': {'feed_info': [row]}}) + a.reactor.run_due() + a.step = step + a.reactor.stepper = step + # what ACE_CALIBRATION_START reads besides the session + a._ace_devices = [object()] + a._ace_mode = 'head' + a._gate_status_per_ace = {0: [load_ace().GATE_AVAILABLE] * 4} + a._protocols = {} + a.head_is_manual = lambda head: False + a.head_uses_ace = lambda head: True + a.head_ace_for = lambda head: 0 + a.get_load_length = lambda idx, slot: true_mm + 150 + a.get_swap_retract_length = lambda idx, slot: 400 + a.get_retract_length = lambda idx, slot: true_mm + return a + + +def _tick(a, st): + timer = a._calibration_timer + if timer is None: + st['timer'] = None + return + if timer is not st.get('timer'): + st['timer'] = timer + st['next'] = a.reactor.t + if a.reactor.t >= st['next']: + st['next'] = a._calibration_timer_tick(a.reactor.t) + + +def run_for(a, seconds): + end = a.reactor.t + seconds + st = {} + while a.reactor.t < end: + a.step(0.01) + _tick(a, st) + + +def pump(a, until, limit_s=600): + """Advance until the session rests in one of `until` (or fails).""" + st = {} + end = a.reactor.t + limit_s + while a.reactor.t < end: + a.step(0.01) + _tick(a, st) + c = a._calibration + if (c['state'] in until and a._calibration_timer is None + and a._calibration_move is None and not a.reactor.callbacks): + return c['state'] + if c['state'] in ('failed', 'cancelled'): + return c['state'] + return 'timeout:' + a._calibration['state'] + + +def phys(a): + return a.fake['pos'] + + +def at_sensor(a): + """A measuring session fed to the toolhead sensor.""" + c = a._calibration_idle_state() + c.update({'state': 'prepared', 'session_id': 1, 'ace': 0, 'slot': 0, + 'head': 0, 'protocol': a.proto if hasattr(a, 'proto') else '', + 'speed_mm_s': SPEED, 'max_feed_mm': 3000, 'tip_position_mm': 0}) + a._calibration = c + a.cmd_ACE_CALIBRATION_FEED(GCmd()) + assert pump(a, ('at_sensor',)) == 'at_sensor' + return c + + +def new_session(true_mm, proto, stop_mode='honest'): + a = make_ace(true_mm, proto, stop_mode) + a.proto = proto + c = at_sensor(a) + # a unit that ignores stops keeps feeding past the sensor; the next + # calibration move is only accepted once it has finished + run_until(a, lambda: a._info_per_ace[0]['status'] == 'ready') + return a, c + + +# ---- V1 unwinds (review point 2) ------------------------------------------ + +def test_v1_stop_unwind_stops(): + a, c = new_session(1543, 'v1') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + run_for(a, 6) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + assert pump(a, ('retract_ready',)) == 'retract_ready' + run_for(a, 60) # an unwind that was not stopped would run on here + assert phys(a) > 1000, 'the unwind kept running after the stop' + assert abs(c['tip_position_mm'] - phys(a)) <= 60 + assert ('stop_unwind_filament', None) in a.fake['sent'] + + +def test_v1_stop_ignored_books_full_length(): + a, c = new_session(1543, 'v1', stop_mode='ignore') + load = c['load_length_mm'] + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=load)) + run_for(a, 6) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert c['commanded_retract_mm'] == load + assert c['tip_position_mm'] == 0 + with pytest.raises(Exception, match='no calibration filament'): + a.cmd_ACE_CALIBRATION_HOME(GCmd()) + # only the V1 time-estimate bias of the measurement itself, no second + # unwind on top of the first one + assert phys(a) >= -60 + + +def test_v1_early_stop_not_falsely_confirmed(): + a, c = new_session(1543, 'v1', stop_mode='ignore') + load = c['load_length_mm'] + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=load)) + run_for(a, 0.3) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert c['commanded_retract_mm'] == load + + +def test_v1_stop_confirm_disconnect_fails(): + a, c = new_session(1543, 'v1', stop_mode='ignore') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + run_for(a, 3) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + run_for(a, 0.5) + a._connected_per_ace[0] = False + run_for(a, 5) + assert c['state'] == 'failed' + assert 'disconnected' in (c['error'] or '') + + +def test_v2_stop_unchanged(): + a, c = new_session(1543, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + run_for(a, 8) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert abs(c['tip_position_mm'] - phys(a)) <= 10 + + +def complete_session(true_mm, proto, stop_mode='honest', swap=400, + **fake): + """A finished measurement parked at the ACE, ready for verification.""" + a = make_ace(true_mm, proto, stop_mode, **fake) + a.proto = proto + c = a._calibration_idle_state() + c.update({'state': 'complete', 'session_id': 1, 'ace': 0, 'slot': 0, + 'head': 0, 'protocol': proto, 'speed_mm_s': SPEED, + 'load_length_mm': true_mm, 'retract_length_mm': true_mm, + 'swap_retract_length_mm': swap, 'tip_position_mm': 0, + 'verify_position_mm': 0}) + a._calibration = c + return a, c + + +def run_until(a, predicate, limit_s=300): + st = {} + end = a.reactor.t + limit_s + while a.reactor.t < end and not predicate(): + a.step(0.01) + _tick(a, st) + assert predicate(), 'condition not reached' + + +def test_v1_verify_pause_ignored_stop_books_full_move(): + a, c = complete_session(1543, 'v1', stop_mode='ignore') + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='start')) + run_until(a, lambda: c['state'] == 'verifying_splitter' + and a._calibration_move is not None) + run_for(a, 3) + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='pause')) + assert pump(a, ('verify_paused',)) == 'verify_paused' + run_for(a, 60) + assert abs(c['verify_position_mm'] - phys(a)) <= 60 + + +# ---- clean reset, no blocking, plain errors (review point 3) --------------- + +def test_retract_refused_while_ace_busy(): + a, c = new_session(1543, 'v1') + a._info_per_ace[0]['status'] = 'busy' # still running a lost move + a.fake['stuck_busy'] = True + a.fake['reports'].clear() # nothing 'ready' still in flight + started = a.reactor.t + with pytest.raises(Exception, match='still moving'): + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=100)) + assert a.reactor.t - started <= 3, 'the command blocked on a busy ACE' + assert c['state'] == 'at_sensor' + + +def test_home_refused_while_ace_busy(): + a, c = new_session(1543, 'v2') + c['state'] = 'cancelled' + a._info_per_ace[0]['status'] = 'busy' + a.fake['stuck_busy'] = True + a.fake['reports'].clear() # nothing 'ready' still in flight + with pytest.raises(Exception, match='still moving'): + a.cmd_ACE_CALIBRATION_HOME(GCmd()) + assert c['state'] == 'cancelled' + + +def test_mark_at_park_position_explains(): + a, c = new_session(1543, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert c['commanded_retract_mm'] == c['load_length_mm'] + with pytest.raises(Exception, match='all the way back'): + a.cmd_ACE_CALIBRATION_MARK(GCmd(MARK='swap')) + + +def test_start_after_failed_run_is_clean(): + a, c = new_session(1543, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + assert pump(a, ('retract_ready',)) == 'retract_ready' + a._calibration_fail('test failure') # tip is back at 0 mm + a.cmd_ACE_CALIBRATION_START(GCmd(ACE=0, SLOT=0, HEAD=0)) + fresh = a._calibration + assert fresh['state'] == 'prepared' + assert fresh['commanded_retract_mm'] == 0 + assert fresh['load_length_mm'] is None + assert fresh['swap_retract_length_mm'] is None + + +def test_moonraker_error_text(): + import ast + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', + 'multiace', 'web', 'backend', 'main.py') + tree = ast.parse(open(path, encoding='utf-8').read()) + fn = next(n for n in tree.body if isinstance(n, ast.FunctionDef) + and n.name == '_moonraker_error_text') + ns = {'json': __import__('json')} + exec(compile(ast.Module(body=[fn], type_ignores=[]), path, 'exec'), ns) + text = ns['_moonraker_error_text'] + raw = ns['json'].dumps({'error': { + 'code': 400, + 'message': '[multiACE] splitter checkpoint must be before the ACE ' + 'preload reference', + 'traceback': 'Traceback (most recent call last):\n File ...'}}) + assert text(raw) == ('[multiACE] splitter checkpoint must be before the ' + 'ACE preload reference') + assert text('plain text') == 'plain text' + assert text('{"error": "boom"}') == 'boom' + + +# ---- drag handle: long bounded moves (review point 4) --------------------- + +def test_feed_jog_long_and_bounded(): + a, c = new_session(1543, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=1300)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + retract = c['commanded_retract_mm'] + a.cmd_ACE_CALIBRATION_FEED_JOG(GCmd(LENGTH=1200)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert c['commanded_retract_mm'] == retract - 1200 + with pytest.raises(Exception, match='toolhead sensor start'): + a.cmd_ACE_CALIBRATION_FEED_JOG(GCmd(LENGTH=c['commanded_retract_mm'] + 50)) + + +# ---- verify the values that will be saved (review point 5) --------------- + +def _verify_to_hub(a, c, **values): + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='start', **values)) + run_until(a, lambda: c['state'] == 'verify_splitter') + + +def test_verify_uses_given_values(): + a, c = complete_session(1543, 'v2', swap=1300) + _verify_to_hub(a, c, LOAD=1693, SWAP=1400, RETRACT=1543) + assert c['verify_search_mm'] == 150 + assert abs(c['verify_position_mm'] - 143) <= 2 + assert abs(phys(a) - 143) <= 10 + assert c['swap_retract_length_mm'] == 1300 + + +def test_verify_rejects_bad_values(): + a, c = complete_session(1543, 'v2', swap=1300) + with pytest.raises(Exception, match='SWAP < RETRACT'): + a.cmd_ACE_CALIBRATION_VERIFY( + GCmd(ACTION='start', LOAD=1693, SWAP=1600, RETRACT=1543)) + assert c['state'] == 'complete' + with pytest.raises(Exception, match='go together'): + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='start', LOAD=1693)) + assert c['state'] == 'complete' + + +def test_hub_jog_not_written_into_measurement(): + a, c = complete_session(1543, 'v2', swap=1300) + _verify_to_hub(a, c, LOAD=1693, SWAP=1400, RETRACT=1543) + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='hub_jog', LENGTH=25)) + run_until(a, lambda: c['state'] == 'verify_splitter' + and a._calibration_move is None) + assert c['verify_hub_extra_mm'] == 25 + assert c['verify_swap_mm'] == 1425 + assert c['swap_retract_length_mm'] == 1300 + + +def test_hub_extra_kept_across_verify_runs(): + a, c = complete_session(1543, 'v2', swap=1300) + _verify_to_hub(a, c, LOAD=1693, SWAP=1400, RETRACT=1543) + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='hub_jog', LENGTH=25)) + run_until(a, lambda: c['state'] == 'verify_splitter' + and a._calibration_move is None) + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='continue')) + run_until(a, lambda: c['state'] == 'verified') + # the page now sends the hub clearance incl. the extra it was shown + _verify_to_hub(a, c, LOAD=1693, SWAP=1425, RETRACT=1543) + assert c['verify_hub_extra_mm'] == 25 + assert c['verify_swap_mm'] == 1425 + assert abs(c['verify_position_mm'] - 118) <= 2 + + +# ---- final review: status latency, sensor stop, refusal before switching --- + +def _stop_after_heartbeat(a): + """STOP right after the unit was sampled for a status report, so the + stop shows up only in the report after next: a full heartbeat plus its + lag later - the slowest the calibration can learn of it.""" + sampled = len(a.fake['reports']) + beat = a.fake['next_beat'] + run_until(a, lambda: a.fake['next_beat'] != beat) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + + +def test_v1_honest_stop_not_misread_as_ignored(): + a, c = new_session(1543, 'v1') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + run_for(a, 6) + _stop_after_heartbeat(a) + assert pump(a, ('retract_ready',)) == 'retract_ready' + run_for(a, 60) + assert phys(a) > 1000 + assert abs(c['tip_position_mm'] - phys(a)) <= 60 + + +def test_v1_stale_ready_before_motion_not_confirmation(): + a = make_ace(1543, 'v1', stop_mode='ignore', start_latency=1.4, + ready_before_motion=True) + a.proto = 'v1' + c = at_sensor(a) + run_until(a, lambda: a._info_per_ace[0]['status'] == 'ready') + load = c['load_length_mm'] + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=load)) + run_for(a, 0.3) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert c['commanded_retract_mm'] == load + + +def test_feed_jog_stops_at_toolhead_sensor(): + a, c = new_session(1543, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=300)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + c['commanded_retract_mm'] = 600 # bookkeeping too far back + a.cmd_ACE_CALIBRATION_FEED_JOG(GCmd(LENGTH=600)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert phys(a) <= 1543 + 20, 'the forward move ran past the sensor' + assert c['commanded_retract_mm'] == 0 + assert c['tip_position_mm'] == c['load_length_mm'] + + +def test_refused_verify_does_not_switch_the_ace(): + a, c = complete_session(1543, 'v2', swap=1300) + a._calibration_prev_ace = None + a.fake['stuck_busy'] = True + a.fake['reports'].clear() # nothing 'ready' still in flight + a._info_per_ace[0]['status'] = 'busy' + with pytest.raises(Exception, match='still moving'): + a.cmd_ACE_CALIBRATION_VERIFY( + GCmd(ACTION='start', LOAD=1693, SWAP=1400, RETRACT=1543)) + assert a.switched == [] + assert a._calibration_prev_ace is None + + +def test_refused_home_does_not_switch_the_ace(): + a, c = new_session(1543, 'v2') + c['state'] = 'cancelled' + a.switched.clear() + a._calibration_prev_ace = None + a.fake['stuck_busy'] = True + a.fake['reports'].clear() # nothing 'ready' still in flight + a._info_per_ace[0]['status'] = 'busy' + with pytest.raises(Exception, match='still moving'): + a.cmd_ACE_CALIBRATION_HOME(GCmd()) + assert a.switched == [] + assert a._calibration_prev_ace is None + + +# ---- encoder scale: bookkeeping stays in commanded mm --------------------- + +def test_encoder_scale_measure_and_return(): + a = make_ace(1400, 'v2', enc_scale=1.03) + a.proto = 'v2' + c = at_sensor(a) + run_until(a, lambda: a._info_per_ace[0]['status'] == 'ready') + assert abs(c['load_length_mm'] - 1400) <= 10 + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=1100)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + a.cmd_ACE_CALIBRATION_MARK(GCmd(MARK='swap')) + a.cmd_ACE_CALIBRATION_RETURN(GCmd()) + assert pump(a, ('complete',)) == 'complete' + assert abs(phys(a)) <= 15, 'the return did not end at the park position' + + +def test_encoder_scale_verify_confirms(): + a = make_ace(1400, 'v2', enc_scale=1.03) + a.proto = 'v2' + c = at_sensor(a) + run_until(a, lambda: a._info_per_ace[0]['status'] == 'ready') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=1100)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + a.cmd_ACE_CALIBRATION_MARK(GCmd(MARK='swap')) + a.cmd_ACE_CALIBRATION_RETURN(GCmd()) + assert pump(a, ('complete',)) == 'complete' + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='start')) + run_until(a, lambda: c['state'] == 'verify_splitter') + assert c['verify_remeasured_mm'] is None + assert abs(c['verify_feed_delta_mm']) <= 10 + assert abs(c['load_length_mm'] - 1400) <= 10 + + +def test_encoder_scale_stop_books_commanded_mm(): + a = make_ace(1400, 'v2', enc_scale=1.03) + a.proto = 'v2' + c = at_sensor(a) + run_until(a, lambda: a._info_per_ace[0]['status'] == 'ready') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=c['load_length_mm'])) + run_for(a, 15) + a.cmd_ACE_CALIBRATION_STOP(GCmd()) + assert pump(a, ('retract_ready',)) == 'retract_ready' + assert abs(c['tip_position_mm'] - phys(a)) <= 15 + + +# ---- repeated checks: return to park by encoder, 2% tolerance ------------- + +def test_repeated_checks_return_to_park(): + # sensor 1428 mm of filament away = 1400 commanded feed mm + a, c = complete_session(1428, 'v2', slip=(1.02, 1.035)) + c['load_length_mm'] = c['retract_length_mm'] = 1400 + for _ in range(3): + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='start')) + run_until(a, lambda: c['state'] == 'verify_splitter') + assert c['verify_remeasured_mm'] is None + a.cmd_ACE_CALIBRATION_VERIFY(GCmd(ACTION='continue')) + run_until(a, lambda: c['state'] == 'verified') + assert abs(phys(a)) <= 5, 'the check did not end at park' + + +def test_check_tolerance_two_percent(): + a, c = complete_session(1420, 'v2') + c['load_length_mm'] = c['retract_length_mm'] = 1400 + _verify_to_hub(a, c) + assert c['verify_remeasured_mm'] is None + assert abs(c['verify_feed_delta_mm'] - 20) <= 3 + a, c = complete_session(1440, 'v2') + c['load_length_mm'] = c['retract_length_mm'] = 1400 + _verify_to_hub(a, c) + assert c['verify_remeasured_mm'] is not None + + +def test_forward_jog_reports_tip_live(): + a, c = new_session(1400, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=600)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + start = c['tip_position_mm'] + a.cmd_ACE_CALIBRATION_FEED_JOG(GCmd(LENGTH=300)) + run_until(a, lambda: c['state'] == 'feed_jogging' + and phys(a) > start + 120) + assert c['tip_position_mm'] > start + 60 diff --git a/tests/unit/test_config_file_access.py b/tests/unit/test_config_file_access.py new file mode 100644 index 0000000..e05d73f --- /dev/null +++ b/tests/unit/test_config_file_access.py @@ -0,0 +1,54 @@ +"""Files the web backend writes for Klipper stay readable by Klipper. + +The backend runs as root; started from a shell with umask 0077 a plain +write + os.replace left ace.cfg root-owned 0600 and Klipper (user lava) +failed with "Unable to open config file". +""" +import os + +from .test_mock_mode import _load_main # noqa: F401 + + +def _record_access(monkeypatch, euid): + calls = {'chmod': [], 'chown': []} + monkeypatch.setattr(os, 'chmod', + lambda p, mode: calls['chmod'].append((str(p), mode))) + monkeypatch.setattr(os, 'chown', + lambda p, uid, gid: calls['chown'].append( + (str(p), uid, gid)), raising=False) + monkeypatch.setattr(os, 'geteuid', lambda: euid, raising=False) + return calls + + +def test_config_write_is_readable_and_owned_like_its_directory( + monkeypatch, tmp_path): + main = _load_main(monkeypatch, tmp_path, mock=False) + cfg = tmp_path / 'ace.cfg' + cfg.write_text('[ace]\n', encoding='utf-8') + calls = _record_access(monkeypatch, euid=0) + main._write_cfg_atomic(cfg, '[ace]\nfoo: 1\n') + assert cfg.read_text(encoding='utf-8') == '[ace]\nfoo: 1\n' + tmp = str(cfg.with_suffix('.cfg.tmp')) + assert calls['chmod'] and calls['chmod'][-1][0] == tmp + assert calls['chmod'][-1][1] & 0o444 == 0o444 + owner = tmp_path.stat() + assert calls['chown'] == [(tmp, owner.st_uid, owner.st_gid)] + + +def test_config_write_as_user_does_not_chown(monkeypatch, tmp_path): + main = _load_main(monkeypatch, tmp_path, mock=False) + cfg = tmp_path / 'ace.cfg' + calls = _record_access(monkeypatch, euid=1000) + main._write_cfg_atomic(cfg, '[ace]\n') + assert calls['chmod'][-1][1] & 0o444 == 0o444 + assert calls['chown'] == [] + + +def test_slot_overrides_write_is_readable(monkeypatch, tmp_path): + main = _load_main(monkeypatch, tmp_path, mock=False) + target = tmp_path / 'slot_overrides.json' + monkeypatch.setattr(main, 'OVERRIDE_FILE', str(target)) + calls = _record_access(monkeypatch, euid=0) + main._save_overrides_to_disk() + assert target.exists() + assert calls['chmod'] and calls['chmod'][-1][1] & 0o444 == 0o444