Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e4ac90b
fix(moonraker): deliver ws callbacks on the GUI thread, fail them on …
gmmcosta15 Oct 13, 2026
70ea670
fix(moonraker): keep error replies from blanking AMU gates, unify the…
gmmcosta15 Oct 13, 2026
8cd5b58
fix(amu): rebuild the MMU state model, guard Happy-Hare v3 gcode and …
gmmcosta15 Oct 13, 2026
ed5f9c5
refactor(amu): drop the unused drying_state gate field
gmmcosta15 Oct 13, 2026
b99191e
fix(spoolman): route gate fill, spool assignment and basic panel load…
gmmcosta15 Oct 13, 2026
c2b9134
fix(amu): enable gate controls only for Happy-Hare's selected gate
gmmcosta15 Oct 13, 2026
eb001ca
fix(amu): send MMU_SELECT on tap, VirtualSelector has no motion to de…
gmmcosta15 Oct 13, 2026
21e0bf1
test(amu): drop the fetch_spool error test, the backend rebuild remov…
gmmcosta15 Oct 13, 2026
585adcf
Merge branch 'fix/amu-2-backend' into fix/amu-3-spoolman-flows
gmmcosta15 Oct 13, 2026
ecfdba5
Merge branch 'fix/amu-3-spoolman-flows' into fix/amu-4-gate-controls
gmmcosta15 Oct 13, 2026
6af2139
fix(files): retry a history lookup only after a disconnect, a deleted…
gmmcosta15 Oct 13, 2026
bf10eec
Merge branch 'fix/amu-1-ws-transport' into fix/amu-2-backend
gmmcosta15 Oct 13, 2026
8c8d494
Merge branch 'fix/amu-2-backend' into fix/amu-3-spoolman-flows
gmmcosta15 Oct 13, 2026
6041f6a
Merge branch 'fix/amu-3-spoolman-flows' into fix/amu-4-gate-controls
gmmcosta15 Oct 13, 2026
843e282
Merge remote-tracking branch 'origin/dev' into fix/amu-2-backend
gmmcosta15 Oct 13, 2026
2a31c4c
Merge branch 'fix/amu-2-backend' into fix/amu-3-spoolman-flows
gmmcosta15 Oct 13, 2026
1aa24d2
Merge branch 'fix/amu-3-spoolman-flows' into fix/amu-4-gate-controls
gmmcosta15 Oct 13, 2026
f887ce0
Merge remote-tracking branch 'origin/dev' into fix/amu-2-backend
gmmcosta15 Oct 13, 2026
75159ed
Merge branch 'fix/amu-2-backend' into fix/amu-3-spoolman-flows
gmmcosta15 Oct 13, 2026
8882be8
Merge branch 'fix/amu-3-spoolman-flows' into fix/amu-4-gate-controls
gmmcosta15 Oct 13, 2026
6acd261
fix(amu): ignore the None MMU state sent on Klipper restart in Filame…
gmmcosta15 Oct 14, 2026
2eee276
Merge branch 'fix/amu-2-backend' into fix/amu-3-spoolman-flows
gmmcosta15 Oct 14, 2026
2072ad0
Merge branch 'fix/amu-3-spoolman-flows' into fix/amu-4-gate-controls
gmmcosta15 Oct 14, 2026
b42240e
Merge remote-tracking branch 'origin/dev' into fix/amu-3-spoolman-flows
gmmcosta15 Oct 14, 2026
ca36298
Merge branch 'fix/amu-3-spoolman-flows' into fix/amu-4-gate-controls
gmmcosta15 Oct 14, 2026
f392940
Merge remote-tracking branch 'origin/dev' into fix/amu-4-gate-controls
gmmcosta15 Oct 14, 2026
f2d9692
fix(amu): eject from the basic panel, show load/unload refusals, matc…
gmmcosta15 Oct 14, 2026
d8f68af
Merge branch 'dev' into fix/amu-4-gate-controls
gmmcosta15 Oct 14, 2026
c054be1
fix(amu): gate MMU actions on Happy-Hare busy state, allow check from…
gmmcosta15 Oct 14, 2026
ed740ef
Merge remote-tracking branch 'origin/dev' into fix/amu-4-gate-controls
gmmcosta15 Oct 14, 2026
0b81267
fix(amu): block only eject while printing, restore test module state
gmmcosta15 Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions BlocksScreen/devices/amu/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,32 +187,46 @@ def reset_mmu(self) -> None:
"""Erase all persisted MMU state (gate map, TTG map, statistics) and re-init."""
self.run_gcode_signal.emit("MMU_RESET CONFIRM=1")

def load_refusal(self) -> str:
"""Return why the selected gate cannot load, or "" when it is idle-ready."""
@staticmethod
def _busy_refusal(state: MMUState) -> str:
"""Return why the MMU cannot take a command now, or "" when it is idle."""
# No print guard: HH v3 parks and restores the toolhead around these mid-print.
if not state.enabled:
return "MMU is disabled."
if state.is_busy:
return f"MMU is busy ({state.action})."
return ""

def check_refusal(self) -> str:
"""Return why the selected gate cannot be checked, or "" when it can."""
state = self._mmu_state
if state is None:
return "MMU status not received yet."
if not state.enabled:
return "MMU is disabled."
if state.is_printing:
return "Not available while printing."
if reason := self._busy_refusal(state):
return reason
if not state.is_homed:
return "MMU is not homed."
if state.filament_pos != FilamentPos.UNLOADED:
return "Filament is not fully unloaded."
if state.current_gate_info is None:
return "No gate is selected."
return ""

def load_refusal(self) -> str:
"""Return why the selected gate cannot load, or "" when it is idle-ready."""
reason = self.check_refusal()
state = self._mmu_state
if reason or state is None:
return reason
if state.filament_pos != FilamentPos.UNLOADED:
return "Filament is not fully unloaded."
return ""

def unload_refusal(self) -> str:
"""Return why the filament cannot be unloaded, or "" when it can."""
state = self._mmu_state
if state is None:
return "MMU status not received yet."
if not state.enabled:
return "MMU is disabled."
if state.is_printing:
return "Not available while printing."
if reason := self._busy_refusal(state):
return reason
if state.filament_pos == FilamentPos.UNLOADED:
return "Filament is already unloaded."
return ""
Expand Down Expand Up @@ -245,7 +259,7 @@ def select_gate(self, gate: int) -> None:
if (
state is None
or not (state.enabled and state.is_homed)
or state.is_printing
or state.is_busy
or not 0 <= gate < state.num_gates
or gate == state.gate
):
Expand All @@ -259,6 +273,9 @@ def select_gate(self, gate: int) -> None:

def eject_gate(self) -> None:
"""Fully eject filament from the selected gate, the only gate that can eject."""
if self._mmu_state is not None and self._mmu_state.is_printing:
logger.warning("Ignoring MMU_EJECT: not available while printing")
return
gate = self._selected_gate_unloaded()
if gate is None or gate.status == GateStatus.EMPTY:
logger.warning("Ignoring MMU_EJECT: no unloaded, non-empty gate selected")
Expand All @@ -267,9 +284,9 @@ def eject_gate(self) -> None:

def check_gate(self) -> None:
"""Check the selected gate for filament by sending MMU_CHECK_GATE."""
# Happy-Hare runs a full unload first when not UNLOADED.
if self._selected_gate_unloaded() is None:
logger.warning("Ignoring MMU_CHECK_GATE: no unloaded gate selected")
# Happy-Hare v3 unloads first when not UNLOADED and reloads a LOADED tool after.
if reason := self.check_refusal():
logger.warning("Ignoring MMU_CHECK_GATE: %s", reason)
return
self.run_gcode_signal.emit("MMU_CHECK_GATE")

Expand Down
5 changes: 5 additions & 0 deletions BlocksScreen/devices/amu/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ def is_printing(self) -> bool:
"""Return True while a print is running; a paused print allows recovery."""
return self.print_state in ("started", "printing")

@property
def is_busy(self) -> bool:
"""Return True while an action runs; "Unknown" is Happy-Hare's error state."""
return self.action not in ("", "Idle", "Unknown")

@property
def current_gate_info(self) -> GateInfo | None:
"""Return the GateInfo for the selected gate, or None when none is selected."""
Expand Down
164 changes: 94 additions & 70 deletions BlocksScreen/lib/panels/widgets/FilamentTab/amuPage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import contextlib
import math
import typing

from devices.amu import AMUManager
from devices.amu import AMUManager, FilamentPos, MMUState
from lib.panels.widgets.FilamentTab.amuWidgets import SpoolCarousel, SpoolInfoPanel
from lib.panels.widgets.Common.basePopup import BasePopup
from lib.utils.blocks_frame import BlocksCustomFrame
Expand All @@ -12,9 +14,6 @@ class AMUpage(QtWidgets.QStackedWidget):
request_back: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
name="request_back"
)
request_gate_map: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
str, name="request-gate-map"
)
request_numpad: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
[str, int, "PyQt_PyObject"],
[str, int, "PyQt_PyObject", int, int],
Expand All @@ -37,53 +36,25 @@ def __init__(
self.current_index = -1
self.amu_manager: AMUManager = amu_manager
self.load_popup = load_popup
self.status = None
self._build_ui()

self.main_back_button.clicked.connect(self.request_back)

self.amu_manager.mmu_state_changed.connect(self.on_mmu_state_changed)
self.on_mmu_state_changed(self.amu_manager.get_state())
self.info_panel.colorSelected.connect(
lambda hx: self.amu_manager.set_gate_color(self.current_index, hx)
)
self.info_panel.colorSelected.connect(self._on_color_selected)
self.info_panel.colorSwatchClicked.connect(
lambda hx: self.request_color_wheel.emit(
hx, self.info_panel.set_selected_color
)
)
self.info_panel._lbl_mat.editingFinished.connect(
lambda: self.amu_manager.set_gate_material(
self.current_index, self.info_panel._lbl_mat.text().strip("º")
)
)
self.info_panel._lbl_temp.editingFinished.connect(
lambda: self.amu_manager.set_gate_temp(
self.current_index,
int(self.info_panel._lbl_temp.text().strip("º") or 0),
)
)
self.info_panel._lbl_temp.clicked.connect(
lambda: self.request_numpad[str, int, "PyQt_PyObject", int, int].emit(
"Temperature",
int(self.info_panel._lbl_temp.text().strip("º")),
self._on_gate_temp_change,
0,
500,
)
)
self.info_panel._lbl_mat.editingFinished.connect(self._on_material_edited)
self.info_panel._lbl_temp.editingFinished.connect(self._on_temp_edited)
self.info_panel._lbl_temp.clicked.connect(self._open_temp_numpad)
self.info_panel.request_keypad.connect(self.request_keyboard)
self.info_panel.loadRequested.connect(
lambda: {
self.amu_manager.load_gate(),
self.load_popup.show(),
}
)
self.info_panel.unloadRequested.connect(
lambda: {
self.amu_manager.unload(),
self.load_popup.show(),
}
)
self.info_panel.loadRequested.connect(self._on_load)
self.info_panel.unloadRequested.connect(self._on_unload)
self.info_panel.ejectRequested.connect(self.amu_manager.eject_gate)
self.info_panel.checkRequested.connect(self.amu_manager.check_gate)

Expand All @@ -93,59 +64,112 @@ def __init__(
@QtCore.pyqtSlot(str, float, name="on_print_stats_update")
@QtCore.pyqtSlot(str, str, name="on_print_stats_update")
def on_print_stats_update(self, field: str, value: dict | float | str) -> None:
"""Rewire the back button between "request_back" and "change to tab 0" based on print state."""
if isinstance(value, str) and "state" in field:
self.state = value
if value in ("printing", "pausing", "paused", "resuming"):
try:
self.main_back_button.clicked.disconnect()
except TypeError:
pass

self.main_back_button.clicked.connect(
lambda: self.request_change_tab.emit(0)
)

else:
try:
self.main_back_button.clicked.disconnect()
except TypeError:
pass
self.main_back_button.clicked.connect(lambda: self.request_back.emit())
"""Point the back button at tab 0 while printing, else at request_back."""
if not (isinstance(value, str) and "state" in field):
return
with contextlib.suppress(TypeError):
self.main_back_button.clicked.disconnect()
if value in ("printing", "pausing", "paused", "resuming"):
self.main_back_button.clicked.connect(
lambda: self.request_change_tab.emit(0)
)
else:
self.main_back_button.clicked.connect(self.request_back)

def on_mmu_state_changed(self, mmu_state):
"""Refresh the carousel and the info panel's selected gate from live MMU state."""
"""Sync the carousel and info panel to live MMU state."""
prev = self.status
self.status = mmu_state
if mmu_state is None:
self.current_index = -1
self.carousel.selectIndex(-1)
self.info_panel.clear_slot(can_unload=False)
return
self.status = mmu_state
for i in range(len(mmu_state.gates)):
self.carousel.addSpool(mmu_state.gates[i], mmu_state.filament_pos)
self.update()
self._on_selection(mmu_state.gate)
# apply_diff reuses the gates tuple when no gate_* key changed.
redraw = (
prev is None
or mmu_state.gates is not prev.gates
or mmu_state.filament_pos != prev.filament_pos
)
if not redraw and all(
getattr(mmu_state, f) == getattr(prev, f)
for f in ("gate", "enabled", "is_homed", "is_busy", "is_printing")
):
return
if redraw:
for gate_info in mmu_state.gates:
self.carousel.addSpool(gate_info, mmu_state.filament_pos)
self._on_selection(mmu_state)

def _select_gate(self, idx: int):
self.carousel.selectIndex(self.current_index)
self.amu_manager.select_gate(idx)

def _on_selection(self, idx: int):
if idx < 0 or idx >= len(self.carousel.buttons):
def _on_selection(self, mmu_state: MMUState) -> None:
idx = mmu_state.gate
idle = not mmu_state.is_busy
if not 0 <= idx < len(self.carousel.buttons):
self.current_index = -1
self.carousel.selectIndex(-1)
self.info_panel.clear_slot(
can_unload=mmu_state.enabled
and idle
and mmu_state.filament_pos != FilamentPos.UNLOADED
)
return
btn = self.carousel.buttons[idx]
self.current_index = idx
self.info_panel.setFilamentStatus(self.status)
self.info_panel.update_for_slot(idx, btn)
self.info_panel.update_for_slot(
btn,
enabled=mmu_state.enabled,
homed=mmu_state.is_homed,
idle=idle,
printing=mmu_state.is_printing,
)
self.carousel.selectIndex(idx)

def _gate_temp(self) -> int | None:
try:
temp = float(self.info_panel._lbl_temp.text().strip("º"))
except ValueError:
return None
return round(temp) if math.isfinite(temp) else None

def _on_gate_temp_change(self, _name: str, value: int) -> None:
self.info_panel._lbl_temp.setText(str(value))
self.info_panel._lbl_temp.editingFinished.emit()

def _open_temp_numpad(self) -> None:
self.request_numpad[str, int, "PyQt_PyObject", int, int].emit(
"Temperature", self._gate_temp() or 0, self._on_gate_temp_change, 1, 500
)

def _on_temp_edited(self) -> None:
temp = self._gate_temp()
if temp is not None:
self.amu_manager.set_gate_temp(self.current_index, temp)

def _on_material_edited(self) -> None:
material = self.info_panel._lbl_mat.text().strip()
if material != "—":
self.amu_manager.set_gate_material(self.current_index, material)

def _on_color_selected(self, hex_str: str) -> None:
self.amu_manager.set_gate_color(self.current_index, hex_str)

def _on_load(self) -> None:
if self.amu_manager.load_gate() and self.load_popup is not None:
self.load_popup.show()

def _on_unload(self) -> None:
if self.amu_manager.unload() and self.load_popup is not None:
self.load_popup.show()

def _build_ui(self):
self.setMinimumSize(700, 420)
self.setObjectName("fans_page")
self.setObjectName("amu_page")
widget = QtWidgets.QWidget(parent=self)
widget.setMinimumSize(700, 420)
self.setObjectName("temperature_page")
self.setLayoutDirection(QtCore.Qt.LayoutDirection.LeftToRight)
widget.setObjectName("filament_control_page")
self.verticalLayout = QtWidgets.QVBoxLayout()
Expand Down
Loading