Skip to content

Commit 45e1fea

Browse files
committed
refactor(amu): drop heavy-spool speed override, refuse load/unload Happy-Hare would reject
1 parent ba8f03b commit 45e1fea

4 files changed

Lines changed: 63 additions & 195 deletions

File tree

‎BlocksScreen/devices/amu/manager.py‎

Lines changed: 20 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,6 @@
2424

2525
logger: logging.Logger = logging.getLogger(__name__)
2626

27-
HEAVY_SPOOL_THRESHOLD_G: float = 1000.0
28-
HEAVY_SPEED_MM_S: float = 100.0
29-
# Must match mmu_gear max_velocity in printer.cfg.
30-
BASE_GEAR_SPEED_MM_S: float = 300.0
31-
_HEAVY_SPEED_PERCENT: int = max(1, round(HEAVY_SPEED_MM_S / BASE_GEAR_SPEED_MM_S * 100))
32-
_DEFAULT_SPEED_PERCENT: int = 100
33-
3427
CONFIG_PATH: Path = Path("~/printer_data/config/printer.cfg").expanduser()
3528

3629

@@ -61,17 +54,13 @@ class AMUManager(QtCore.QObject):
6154
pre_gate_changed: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
6255
int, bool, name="pre-gate-changed"
6356
)
64-
spool_fetched: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
65-
int, dict, name="spool-fetched"
66-
)
6757

6858
def __init__(self, ws: MoonWebSocket, parent: QtCore.QObject | None = None) -> None:
6959
super().__init__(parent)
7060
self._config_toggler = ConfigToggler(CONFIG_PATH)
7161
self._ws = ws
7262
self._mmu_state: MMUState | None = None
7363
self._pre_gate_sensors: dict[int, bool] = {}
74-
self.spool_fetched.connect(self._apply_spool_data)
7564

7665
def _gate_map(self, gate: int, **params: object) -> bool:
7766
"""Emit MMU_GATE_MAP for one gate; False when no gate is selected yet."""
@@ -88,27 +77,6 @@ def _gate_map(self, gate: int, **params: object) -> bool:
8877
self.run_gcode_signal.emit(f"MMU_GATE_MAP GATE={gate}{args} QUIET=1")
8978
return True
9079

91-
def _apply_spool_data(self, gate: int, data: dict) -> None:
92-
"""Set the gate's gear speed from a Spoolman payload's remaining weight."""
93-
if data.get("id", -1) == -1:
94-
logger.warning("Spool payload for gate %d carries no id", gate)
95-
return
96-
remaining = data.get("remaining_weight")
97-
if remaining is not None:
98-
self._apply_speed_profile(gate, float(remaining))
99-
100-
def _apply_speed_profile(self, gate: int, remaining_weight: float) -> None:
101-
"""Slow the gear for a heavy spool and restore it once the spool is light."""
102-
if self._mmu_state is None or not 0 <= gate < len(self._mmu_state.gates):
103-
return
104-
wanted = (
105-
_HEAVY_SPEED_PERCENT
106-
if remaining_weight > HEAVY_SPOOL_THRESHOLD_G
107-
else _DEFAULT_SPEED_PERCENT
108-
)
109-
if self._mmu_state.gates[gate].speed_override != wanted:
110-
self._gate_map(gate, SPEED=wanted)
111-
11280
def toggle_amu_system(self, activate: bool) -> None:
11381
"""Swap the printer.cfg variant, restarting on change; emits amu_toggled."""
11482
result = self._config_toggler.toggle(activate)
@@ -133,21 +101,6 @@ def is_amu_active(self) -> bool:
133101
state = self._mmu_state
134102
return self.is_amu_configured() and state is not None and state.enabled
135103

136-
def fetch_spool(self, gate: int, spool_id: int) -> None:
137-
"""Fetch *spool_id* from Spoolman and emit spool_fetched; no-op when off."""
138-
if self._mmu_state is None:
139-
return
140-
if self._mmu_state.spoolman_support is SpoolmanSupport.OFF:
141-
return
142-
if spool_id == -1:
143-
return
144-
145-
def _on_result(result: dict | None) -> None:
146-
if result is not None:
147-
self.spool_fetched.emit(gate, result)
148-
149-
self._ws.api.get_spool(spool_id, _on_result)
150-
151104
def set_gate_info(
152105
self,
153106
gate: int,
@@ -184,22 +137,20 @@ def set_gate_color(self, gate: int, color: str) -> None:
184137
self._gate_map(gate, COLOR=color.lstrip("#"))
185138

186139
def set_gate_spool(self, gate: int, spool_id: int) -> None:
187-
"""Bind *gate* to a Spoolman spool (-1 unbinds) and fetch its data."""
188-
if self._gate_map(gate, SPOOLID=spool_id) and spool_id != -1:
189-
self.fetch_spool(gate, spool_id)
140+
"""Bind *gate* to a Spoolman spool; -1 unbinds."""
141+
self._gate_map(gate, SPOOLID=spool_id)
190142

191143
def assign_spool(self, gate: int, spool: dict) -> None:
192-
"""Map *gate* from a Spoolman spool payload and apply it without a refetch."""
144+
"""Map *gate* from a Spoolman spool payload."""
193145
filament = spool.get("filament") or {}
194-
if self.set_gate_info(
146+
self.set_gate_info(
195147
gate,
196148
filament.get("material") or "",
197149
filament.get("color_hex") or "",
198150
spool.get("id", -1),
199151
filament_name=filament.get("name") or "",
200152
temperature=filament.get("settings_extruder_temp"),
201-
):
202-
self._apply_spool_data(gate, spool)
153+
)
203154

204155
def update_spool_weight(self, gate: int, used_weight: float) -> None:
205156
"""Push *used_weight* to Spoolman for the spool at *gate*."""
@@ -238,22 +189,34 @@ def reset_mmu(self) -> None:
238189
self.run_gcode_signal.emit("MMU_RESET CONFIRM=1")
239190

240191
def _selected_gate_unloaded(self) -> GateInfo | None:
241-
"""Return the selected gate when it is a real gate with nothing loaded."""
192+
"""Return the selected gate when the MMU is enabled, homed and unloaded."""
242193
state = self._mmu_state
243-
if state is None or state.filament_pos != FilamentPos.UNLOADED:
194+
if (
195+
state is None
196+
or not (state.enabled and state.is_homed)
197+
or state.filament_pos != FilamentPos.UNLOADED
198+
):
244199
return None
245200
return state.current_gate_info
246201

247202
def load_gate(self) -> bool:
248203
"""Load filament from the selected gate by sending MMU_LOAD; True if sent."""
249204
if self._selected_gate_unloaded() is None:
250-
logger.warning("Ignoring MMU_LOAD: no unloaded gate selected")
205+
logger.warning("Ignoring MMU_LOAD: no homed, unloaded gate selected")
251206
return False
252207
self.run_gcode_signal.emit("MMU_LOAD")
253208
return True
254209

255210
def unload(self) -> bool:
256211
"""Unload the currently loaded filament by sending MMU_UNLOAD; True if sent."""
212+
state = self._mmu_state
213+
if (
214+
state is None
215+
or not state.enabled
216+
or state.filament_pos == FilamentPos.UNLOADED
217+
):
218+
logger.warning("Ignoring MMU_UNLOAD: MMU disabled or nothing loaded")
219+
return False
257220
self.run_gcode_signal.emit("MMU_UNLOAD")
258221
return True
259222

‎BlocksScreen/lib/panels/widgets/FilamentTab/basicFilamentPanel.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,12 @@ def filament_state(self):
354354
@filament_state.setter
355355
def filament_state(self, update: FilamentStates) -> None:
356356
self._filament_state = update
357-
self.Basic_fp_load_btn.setEnabled(update is not self.FilamentStates.LOADED)
357+
# Happy-Hare only loads from UNLOADED; a sensorless non-MMU printer stays UNKNOWN.
358+
self.Basic_fp_load_btn.setEnabled(
359+
update is self.FilamentStates.UNLOADED
360+
if self.mmu_configured
361+
else update is not self.FilamentStates.LOADED
362+
)
358363
self.Basic_fp_unload_btn.setEnabled(update is not self.FilamentStates.UNLOADED)
359364
if self._lbl_pos.select_option(update.name.capitalize()):
360365
self._pos_committed = self._lbl_pos.text()

‎tests/amu/test_manager_unit.py‎

Lines changed: 29 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,16 @@ def test_load_gate(self, manager, qtbot) -> None:
354354
assert manager.load_gate() is True
355355
assert blocker.args == ["MMU_LOAD"]
356356

357+
# Happy-Hare drops MMU_LOAD when disabled or unhomed (mmu.py:6936).
357358
@pytest.mark.parametrize(
358-
"overrides", [None, {"filament_pos": 10}, {"filament_pos": 0, "gate": -1}]
359+
"overrides",
360+
[
361+
None,
362+
{"filament_pos": 10},
363+
{"filament_pos": 0, "gate": -1},
364+
{"filament_pos": 0, "enabled": False},
365+
{"filament_pos": 0, "is_homed": False},
366+
],
359367
)
360368
def test_load_gate_refused(self, manager, qtbot, overrides) -> None:
361369
if overrides is not None:
@@ -364,10 +372,21 @@ def test_load_gate_refused(self, manager, qtbot, overrides) -> None:
364372
assert manager.load_gate() is False
365373

366374
def test_unload(self, manager, qtbot) -> None:
375+
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
367376
with qtbot.waitSignal(manager.run_gcode_signal) as blocker:
368377
assert manager.unload() is True
369378
assert blocker.args == ["MMU_UNLOAD"]
370379

380+
# Happy-Hare drops MMU_UNLOAD when disabled or already unloaded (mmu.py:6981).
381+
@pytest.mark.parametrize(
382+
"overrides", [None, {"filament_pos": 0}, {"enabled": False}]
383+
)
384+
def test_unload_refused(self, manager, qtbot, overrides) -> None:
385+
if overrides is not None:
386+
manager.update_mmu_state(_status(**overrides))
387+
with qtbot.assertNotEmitted(manager.run_gcode_signal):
388+
assert manager.unload() is False
389+
371390
def test_eject_gate(self, manager, qtbot) -> None:
372391
manager.update_mmu_state(_status(filament_pos=0))
373392
with qtbot.waitSignal(manager.run_gcode_signal) as blocker:
@@ -398,7 +417,13 @@ def test_check_gate_allows_an_empty_gate(self, manager, qtbot) -> None:
398417

399418
# MMU_CHECK_GATE runs a full unload first when filament is loaded.
400419
@pytest.mark.parametrize(
401-
"overrides", [None, {"filament_pos": 10}, {"filament_pos": 0, "gate": -2}]
420+
"overrides",
421+
[
422+
None,
423+
{"filament_pos": 10},
424+
{"filament_pos": 0, "gate": -2},
425+
{"filament_pos": 0, "is_homed": False},
426+
],
402427
)
403428
def test_check_gate_refused(self, manager, qtbot, overrides) -> None:
404429
if overrides is not None:
@@ -582,44 +607,6 @@ def test_false_when_mmu_disabled(self, manager_with_cfg, qtbot) -> None:
582607
assert mgr.is_amu_active() is False
583608

584609

585-
class TestSpoolManFetch:
586-
def test_noop_when_mmu_state_none(self, manager, qtbot) -> None:
587-
with qtbot.assertNotEmitted(manager.spool_fetched):
588-
manager.fetch_spool(0, 42)
589-
manager._ws.api.get_spool.assert_not_called()
590-
591-
def test_noop_when_spoolman_off(self, manager, qtbot) -> None:
592-
status = {**_FULL_STATUS_WITH_SPOOLMAN, "spoolman_support": "off"}
593-
manager.update_mmu_state(status)
594-
with qtbot.assertNotEmitted(manager.spool_fetched):
595-
manager.fetch_spool(0, 42)
596-
manager._ws.api.get_spool.assert_not_called()
597-
598-
def test_emit_spool_fetched_on_sucess(self, manager, qtbot) -> None:
599-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
600-
spool_data = {"id": 42, "filament": {"name": "PLA"}, "used_weight": 50.0}
601-
manager.fetch_spool(0, 42)
602-
callback = manager._ws.api.get_spool.call_args.args[1]
603-
with qtbot.waitSignal(manager.spool_fetched) as blocker:
604-
callback(spool_data)
605-
assert blocker.args[0] == 0
606-
assert blocker.args[1] == spool_data
607-
608-
def test_no_emit_on_ws_failure(self, manager, qtbot) -> None:
609-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
610-
manager.fetch_spool(0, 42)
611-
callback = manager._ws.api.get_spool.call_args.args[1]
612-
with qtbot.assertNotEmitted(manager.spool_fetched):
613-
callback(None)
614-
615-
# -1 is Happy-Hare's "no spool", not a Spoolman id.
616-
def test_noop_when_spool_id_unset(self, manager, qtbot) -> None:
617-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
618-
with qtbot.assertNotEmitted(manager.spool_fetched):
619-
manager.fetch_spool(0, -1)
620-
manager._ws.api.get_spool.assert_not_called()
621-
622-
623610
class TestUpdateSpoolWeight:
624611
# gates[-1] wraps, so an unselected gate hit the last gate's spool.
625612
@pytest.mark.parametrize("gate", [-1, -2, 2, 99])
@@ -689,18 +676,7 @@ def test_load_cell_updates_are_ignored(self, manager, qtbot) -> None:
689676
assert not hasattr(manager, "gate_weight_updated")
690677

691678

692-
class TestApplySpoolData:
693-
# Happy-Hare's _spoolman_sync owns the gate map; writing it here races it.
694-
def test_emits_no_gcode(self, manager, qtbot) -> None:
695-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
696-
with qtbot.assertNotEmitted(manager.run_gcode_signal):
697-
manager._apply_spool_data(0, _SPOOL_DATA)
698-
699-
# A spool callback can land after on_klippy_state() dropped the state.
700-
def test_noop_when_mmu_state_none(self, manager) -> None:
701-
manager._apply_spool_data(0, _SPOOL_DATA)
702-
assert manager.get_state() is None
703-
679+
class TestGcodeQuoting:
704680
# User-typed Spoolman text must survive Klipper's shlex parse as one value.
705681
@pytest.mark.parametrize(
706682
("name", "color"),
@@ -726,51 +702,6 @@ def test_emitted_gcode_survives_klipper_parser(
726702
assert params["COLOR"] == color.lstrip("#")
727703
assert params["SPOOLID"] == "42"
728704

729-
def test_triggered_by_spool_fetched_signal(self, manager, qtbot) -> None:
730-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
731-
manager.fetch_spool(0, 42)
732-
callback = manager._ws.api.get_spool.call_args.args[1]
733-
with qtbot.waitSignal(manager.run_gcode_signal) as blocker:
734-
callback({**_SPOOL_DATA, "remaining_weight": 1500.0})
735-
assert blocker.args == ["MMU_GATE_MAP GATE=0 SPEED=33 TEMP=0 QUIET=1"]
736-
737-
def test_payload_without_id_is_dropped(self, manager, qtbot) -> None:
738-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
739-
with qtbot.assertNotEmitted(manager.run_gcode_signal):
740-
manager._apply_spool_data(0, {"remaining_weight": 1500.0})
741-
742-
@staticmethod
743-
def _speed_gcode(manager, remaining_weight, speed_override) -> list[str]:
744-
manager.update_mmu_state(_status(gate_speed_override=[speed_override, 100]))
745-
data = {k: v for k, v in _SPOOL_DATA.items() if k != "remaining_weight"}
746-
if remaining_weight is not None:
747-
data["remaining_weight"] = remaining_weight
748-
signals = []
749-
manager.run_gcode_signal.connect(signals.append)
750-
manager._apply_spool_data(0, data)
751-
return signals
752-
753-
@pytest.mark.parametrize("remaining_weight", [1000.1, 1500.0, 5000.0])
754-
def test_heavy_spool_slows_the_gear(self, manager, remaining_weight) -> None:
755-
assert self._speed_gcode(manager, remaining_weight, 100) == [
756-
"MMU_GATE_MAP GATE=0 SPEED=33 TEMP=0 QUIET=1"
757-
]
758-
759-
@pytest.mark.parametrize("remaining_weight", [0.0, 950.0, 1000.0])
760-
def test_light_spool_restores_full_speed(self, manager, remaining_weight) -> None:
761-
assert self._speed_gcode(manager, remaining_weight, 33) == [
762-
"MMU_GATE_MAP GATE=0 SPEED=100 TEMP=0 QUIET=1"
763-
]
764-
765-
@pytest.mark.parametrize(
766-
("remaining_weight", "speed_override"),
767-
[(None, 100), (None, 33), (950.0, 100), (1500.0, 33)],
768-
)
769-
def test_speed_already_right_emits_nothing(
770-
self, manager, remaining_weight, speed_override
771-
) -> None:
772-
assert self._speed_gcode(manager, remaining_weight, speed_override) == []
773-
774705

775706
class TestAssignSpool:
776707
@staticmethod
@@ -787,50 +718,11 @@ def test_maps_gate_from_payload(self, manager) -> None:
787718
"SPOOLID=42 TEMP=215 QUIET=1"
788719
]
789720

790-
# The picker already holds the payload, so no Spoolman round trip.
791-
def test_applies_payload_without_refetch(self, manager) -> None:
792-
self._gcode(manager, 1, _SPOOL_DATA)
793-
manager._ws.api.get_spool.assert_not_called()
794-
795-
def test_heavy_spool_slows_the_gear(self, manager) -> None:
796-
gcode = self._gcode(manager, 1, {**_SPOOL_DATA, "remaining_weight": 1500.0})
797-
assert gcode[1:] == ["MMU_GATE_MAP GATE=1 SPEED=33 TEMP=0 QUIET=1"]
798-
799721
# Spoolman sends null for an unset extruder temp.
800722
def test_null_temp_lets_happy_hare_default_it(self, manager) -> None:
801723
filament = {**_SPOOL_DATA["filament"], "settings_extruder_temp": None}
802724
(gcode,) = self._gcode(manager, 1, {**_SPOOL_DATA, "filament": filament})
803725
assert "TEMP" not in gcode
804726

805727
def test_unselected_gate_emits_nothing(self, manager) -> None:
806-
heavy = {**_SPOOL_DATA, "remaining_weight": 1500.0}
807-
assert self._gcode(manager, -1, heavy) == []
808-
809-
810-
class TestSetGateSpoolAutoFetch:
811-
def test_calls_fetch_spool_when_activate(self, manager) -> None:
812-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
813-
manager.set_gate_spool(0, 42)
814-
manager._ws.api.get_spool.assert_called_once()
815-
assert manager._ws.api.get_spool.call_args.args[0] == 42
816-
817-
def test_skips_fetch_when_spool_id_minus_one(self, manager) -> None:
818-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
819-
manager.set_gate_spool(0, -1)
820-
manager._ws.api.get_spool.assert_not_called()
821-
822-
def test_skips_fetch_when_no_state(self, manager) -> None:
823-
manager.set_gate_spool(0, 42)
824-
manager._ws.api.get_spool.assert_not_called()
825-
826-
def test_skips_fetch_when_spoolman_off(self, manager) -> None:
827-
status = {**_FULL_STATUS_WITH_SPOOLMAN, "spoolman_support": "off"}
828-
manager.update_mmu_state(status)
829-
manager.set_gate_spool(0, 42)
830-
manager._ws.api.get_spool.assert_not_called()
831-
832-
# No MMU_GATE_MAP was sent, so caching a spool for it would lie.
833-
def test_skips_fetch_when_gate_unselected(self, manager) -> None:
834-
manager.update_mmu_state(_FULL_STATUS_WITH_SPOOLMAN)
835-
manager.set_gate_spool(-1, 42)
836-
manager._ws.api.get_spool.assert_not_called()
728+
assert self._gcode(manager, -1, _SPOOL_DATA) == []

0 commit comments

Comments
 (0)