11from __future__ import annotations
22
3+ import dataclasses
34import logging
45import typing
56from pathlib import Path
1516
1617logger : logging .Logger = logging .getLogger (__name__ )
1718
19+ # Spool Weight threshold for heavy-filament speed profile (grams)
20+ HEAVY_SPOOL_THRESHOLD_G : float = 1000.0
21+ # Absolute target speed (mm/s) for heavy spools - used to calculate SPEED % for MMU_GATE_MAP
22+ HEAVY_SPEED_MM_S : float = 100.0
23+ # Base gear stepper max_velocity (mm/s) - must match mmu_gear max_velocity in printer.cfg
24+ BASE_GEAR_SPEED_MM_S : float = 300.0
25+ # Precomputed speed percentage for heavy spools (avoids repeated division at runtime)
26+ _HEAVY_SPEED_PERCENT : int = max (1 , round (HEAVY_SPEED_MM_S / BASE_GEAR_SPEED_MM_S * 100 ))
27+
1828CONFIG_PATH : Path = Path ("~/printer_data/config/printer.cfg" ).expanduser ()
1929
2030
@@ -39,14 +49,74 @@ class AMUManager(QtCore.QObject):
3949 spool_fetched : typing .ClassVar [QtCore .pyqtSignal ] = QtCore .pyqtSignal (
4050 int , dict , name = "spool-fetched"
4151 )
52+ gate_weight_updated : typing .ClassVar [QtCore .pyqtSignal ] = QtCore .pyqtSignal (
53+ int , float , name = "gate-weight-updated"
54+ )
4255
4356 def __init__ (self , ws : MoonWebSocket , parent : QtCore .QObject | None = None ) -> None :
4457 super ().__init__ (parent )
4558 self ._config_toggler = ConfigToggler (CONFIG_PATH )
46- self ._amu_state = False
4759 self ._ws = ws
4860 self ._mmu_state : MMUState | None = None
4961 self ._pre_gate_sensors : dict [int , bool ] = {}
62+ self .spool_fetched .connect (self ._apply_spool_data )
63+
64+ def _apply_spool_data (self , gate : int , data : dict ) -> None :
65+ """Apply Spoolman spool data to local gate state and sync to Klipper.
66+
67+ Extracts material, color, weight from the Spoolman response dict,
68+ emits MMU_GATE_MAP gcode to sync Klipper, and updates the local GateInfo weight.
69+ """
70+ filament = data .get ("filament" , {})
71+ material = filament .get ("material" , "" )
72+ color = filament .get ("color_hex" , "" )
73+ filament_name = filament .get ("name" , "" )
74+ temperature = filament .get ("settings_extruder_temp" )
75+ bed_temp = filament .get ("settings_bed_temp" )
76+ spool_id = data .get ("id" , - 1 )
77+ weight = data .get ("used_weight" )
78+ remaining_weight = data .get ("remaining_weight" )
79+ self .set_gate_info (
80+ gate ,
81+ material ,
82+ color ,
83+ spool_id ,
84+ filament_name = filament_name ,
85+ temperature = temperature ,
86+ )
87+ if self ._mmu_state is not None :
88+ if gate >= len (self ._mmu_state .gates ):
89+ logger .warning (
90+ "Gate index %d out of range (%d gates)" ,
91+ gate ,
92+ len (self ._mmu_state .gates ),
93+ )
94+ return
95+ gates = list (self ._mmu_state .gates )
96+ updates = {}
97+ if weight is not None :
98+ updates ["weight_g" ] = float (weight )
99+ if remaining_weight is not None :
100+ updates ["remaining_weight" ] = float (remaining_weight )
101+ self ._emit_speed_gcode (gate , remaining_weight )
102+ if filament_name :
103+ updates ["filament_name" ] = filament_name
104+ if temperature is not None :
105+ updates ["temperature" ] = float (temperature )
106+ if bed_temp is not None :
107+ updates ["bed_temp" ] = int (bed_temp )
108+ if updates :
109+ gates [gate ] = dataclasses .replace (gates [gate ], ** updates )
110+ self ._mmu_state = dataclasses .replace (
111+ self ._mmu_state , gates = tuple (gates )
112+ )
113+
114+ def _emit_speed_gcode (self , gate : int , remaining_weight : float ) -> None :
115+ """Emit MMU_GATE_MAP SPEED=x for the gate based on the spool weight profile."""
116+ if remaining_weight > HEAVY_SPOOL_THRESHOLD_G :
117+ self .run_gcode_signal .emit (
118+ f"MMU_GATE_MAP gate={ gate } SPEED={ _HEAVY_SPEED_PERCENT } "
119+ )
50120
51121 def toggle_amu_system (self , activate : bool ) -> None :
52122 """Enable or disable the AMU system by commenting/uncommenting config includes.
@@ -88,12 +158,15 @@ def fetch_spool(self, gate: int, spool_id: int) -> None:
88158 """Request spool data from Moonraker via WebSocket.
89159
90160 No-op if MMU state not received or spoolman_support is OFF.
91- Emits spool_fetched(gate, data) on sucess ; logs and emits nothing on error.
161+ Emits spool_fetched(gate, data) on success ; logs and emits nothing on error.
92162 """
163+
93164 if self ._mmu_state is None :
94165 return
95166 if self ._mmu_state .spoolman_support is SpoolmanSupport .OFF :
96167 return
168+ if spool_id == - 1 :
169+ return
97170
98171 def _on_result (result : dict | None ) -> None :
99172 if result is not None :
@@ -102,7 +175,13 @@ def _on_result(result: dict | None) -> None:
102175 self ._ws .api .get_spool (spool_id , _on_result )
103176
104177 def set_gate_info (
105- self , gate : int , material : str , color : str , spool_id : int
178+ self ,
179+ gate : int ,
180+ material : str ,
181+ color : str ,
182+ spool_id : int ,
183+ filament_name : str = "" ,
184+ temperature : int | None = None ,
106185 ) -> None :
107186 """Sets all gate attributes for a single MMU_GATE
108187
@@ -111,10 +190,15 @@ def set_gate_info(
111190 material (str): Filament material name, e.g. ``"PLA"``.
112191 color (str): Filament color as hex string, e.g. ``"ff56e0"``.
113192 spool_id (int): Spoolman spool ID, or -1 if not tracked.
193+ filament_name (str): Filament display name from Spoolman.
194+ temperature (int | None): Extruder temperature, omitted if None.
114195 """
115- self .run_gcode_signal .emit (
116- f"MMU_GATE_MAP gate={ gate } MATERIAL={ material } COLOR={ color } SPOOLID={ spool_id } "
117- )
196+ gcode = f"MMU_GATE_MAP gate={ gate } MATERIAL={ material } COLOR={ color } SPOOLID={ spool_id } "
197+ if filament_name :
198+ gcode = f"MMU_GATE_MAP gate={ gate } NAME={ filament_name } MATERIAL={ material } COLOR={ color } SPOOLID={ spool_id } "
199+ if temperature is not None :
200+ gcode += f" TEMP={ temperature } "
201+ self .run_gcode_signal .emit (gcode )
118202
119203 def set_gate_material (self , gate : int , material : str ) -> None :
120204 """Set the `material` at the gate `gate`
@@ -142,6 +226,8 @@ def set_gate_spool(self, gate: int, spool_id: int) -> None:
142226 spool_id (int): Spoolman spool ID, or -1 to clear.
143227 """
144228 self .run_gcode_signal .emit (f"MMU_GATE_MAP gate={ gate } SPOOLID={ spool_id } " )
229+ if spool_id != - 1 :
230+ self .fetch_spool (gate , spool_id )
145231
146232 def home_mmu (self ) -> None :
147233 """Home the MMU selector by sending MMU_HOME."""
@@ -188,18 +274,6 @@ def select_tool(self, tool: int) -> None:
188274 """
189275 self .run_gcode_signal .emit (f"MMU_CHANGE_TOOL TOOL={ tool } " )
190276
191- def on_pre_gate_update (self , values : dict , name : str ) -> None :
192- if not name .startswith ("Mmu Pre Gate " ):
193- return
194- try :
195- gate = int (name .removeprefix ("Mmu Pre Gate " ))
196- except ValueError :
197- logger .error ("Failed to parse Pre-Gate: %s" , name )
198- return
199- detected = bool (values .get ("filament_detected" , False ))
200- self ._pre_gate_sensors [gate ] = detected
201- self .pre_gate_changed .emit (gate , detected )
202-
203277 def update_mmu_state (self , data : dict , name : str = "" ) -> None :
204278 """Receive an MMU status dict from Moonraker and update internal state.
205279
@@ -223,8 +297,45 @@ def on_object_updated(
223297 """Route object_updated signal from Printer to the appropriate handler."""
224298 if object_type == "mmu" :
225299 self .update_mmu_state (values )
226- elif object_type == "filament_switch_detected " :
300+ elif object_type == "filament_switch_sensor " :
227301 self .on_pre_gate_update (values , object_name )
302+ elif object_type == "load_cell" :
303+ self .on_load_cell_update (values , object_name )
304+
305+ def on_pre_gate_update (self , values : dict , name : str ) -> None :
306+ if not name .startswith ("Mmu Pre Gate " ):
307+ return
308+ try :
309+ gate = int (name .removeprefix ("Mmu Pre Gate " ))
310+ except ValueError :
311+ logger .error ("Failed to parse Pre-Gate: %s" , name )
312+ return
313+ detected = bool (values .get ("filament_detected" , False ))
314+ self ._pre_gate_sensors [gate ] = detected
315+ self .pre_gate_changed .emit (gate , detected )
316+
317+ def on_load_cell_update (self , values : dict , name : str ) -> None :
318+ """Update gate weight from a Klipper load_cell sensor reading"""
319+ if self ._mmu_state is None :
320+ return
321+ try :
322+ gate = int (name .removeprefix ("load_cell_mmu_" ))
323+ except ValueError :
324+ logger .error ("Failed parsing %s Load cell" , name )
325+ return
326+
327+ weight = float (values .get ("force" , 0 ))
328+ if gate >= len (self ._mmu_state .gates ):
329+ logger .warning (
330+ "Gate index %d out of range (%d gates)" ,
331+ gate ,
332+ len (self ._mmu_state .gates ),
333+ )
334+ return
335+ gates = list (self ._mmu_state .gates )
336+ gates [gate ] = dataclasses .replace (gates [gate ], weight_g = weight )
337+ self ._mmu_state = dataclasses .replace (self ._mmu_state , gates = tuple (gates ))
338+ self .gate_weight_updated .emit (gate , weight )
228339
229340 def on_klippy_state (self , state : str ) -> None :
230341 """React to changes in klippy states"""
0 commit comments