Skip to content

Commit 93a5c5b

Browse files
committed
Backend of Happy-Hare and Spoolman is integrated
1 parent c3f4edb commit 93a5c5b

5 files changed

Lines changed: 385 additions & 27 deletions

File tree

BlocksScreen/devices/amu/config_toggler.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import logging
22
import re
3-
43
from pathlib import Path
54

65
logger = logging.getLogger(__name__)
@@ -34,7 +33,8 @@ def _detect_state(self) -> bool:
3433
return False
3534
try:
3635
text = self._path.read_text()
37-
return all(re.search(rf"^\[include {re.escape(f)}\]", text, re.MULTILINE)
36+
return all(
37+
re.search(rf"^\[include {re.escape(f)}\]", text, re.MULTILINE)
3838
for f in AMU_FILES
3939
)
4040
except OSError:
@@ -57,10 +57,10 @@ def toggle(self, activate: bool) -> bool:
5757
return True
5858
except OSError as e:
5959
logger.error(
60-
"ConfigToggler.toggle(activate=%s): read/write failed: %s", activate,e
60+
"ConfigToggler.toggle(activate=%s): read/write failed: %s", activate, e
6161
)
6262
return False
63-
63+
6464
def is_configured(self) -> bool:
6565
"""Return True if AMU includes are currently uncommented"""
6666
return self._state

BlocksScreen/devices/amu/manager.py

Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import logging
45
import typing
56
from pathlib import Path
@@ -15,6 +16,15 @@
1516

1617
logger: 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+
1828
CONFIG_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"""

BlocksScreen/devices/amu/models.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,12 @@ class GateInfo:
4444
color: str
4545
color_rgb: tuple[float, float, float]
4646
spool_id: int
47+
filament_name: str = ""
48+
temperature: float | None = None
4749
weight_g: float | None = None
4850
mid_usage: bool = False
51+
remaining_weight: float | None = None
52+
bed_temp: int | None = None
4953

5054
@property
5155
def is_available(self) -> bool:
@@ -69,6 +73,10 @@ class MMUState:
6973
spoolman_support: SpoolmanSupport
7074
gates: tuple[GateInfo, ...]
7175
ttg_map: tuple[int, ...]
76+
pending_spool_id: int = -1
77+
operation: str = ""
78+
sensors: dict = dataclasses.field(default_factory=dict)
79+
gate_speed_override: tuple[float, ...] = dataclasses.field(default_factory=tuple)
7280

7381
@property
7482
def is_paused(self) -> bool:
@@ -99,6 +107,9 @@ def from_status(cls, data: dict) -> "MMUState":
99107
colors = data.get("gate_color", [""] * num_gates)
100108
rgbs = data.get("gate_color_rgb", [(0.0, 0.0, 0.0)] * num_gates)
101109
spool_ids = data.get("gate_spool_id", [-1] * num_gates)
110+
filament_name = data.get("gate_filament_name", [""] * num_gates)
111+
temperature = data.get("gate_temperature", [None] * num_gates)
112+
speed_override = data.get("gate_speed_override", [])
102113

103114
gates: tuple[GateInfo, ...] = tuple(
104115
GateInfo(
@@ -108,6 +119,8 @@ def from_status(cls, data: dict) -> "MMUState":
108119
color=colors[i],
109120
color_rgb=tuple(rgbs[i]),
110121
spool_id=spool_ids[i],
122+
filament_name=filament_name[i],
123+
temperature=temperature[i],
111124
)
112125
for i in range(num_gates)
113126
)
@@ -123,9 +136,15 @@ def from_status(cls, data: dict) -> "MMUState":
123136
print_state=data.get("print_state", ""),
124137
reason_for_pause=data.get("reason_for_pause", ""),
125138
has_bypass=data.get("has_bypass", False),
126-
spoolman_support=SpoolmanSupport(data.get("spoolman_support", SpoolmanSupport.OFF)),
139+
spoolman_support=SpoolmanSupport(
140+
data.get("spoolman_support", SpoolmanSupport.OFF)
141+
),
127142
gates=gates,
128143
ttg_map=tuple(data.get("ttg_map", [])),
144+
pending_spool_id=data.get("pending_spool_id", -1),
145+
operation=data.get("operation", ""),
146+
sensors=dict(data.get("sensors", {})),
147+
gate_speed_override=tuple(speed_override),
129148
)
130149

131150
def gate_for_tool(self, tool: int) -> int:
@@ -149,6 +168,9 @@ def apply_diff(self, diff: dict) -> "MMUState":
149168
"gate_color",
150169
"gate_color_rgb",
151170
"gate_spool_id",
171+
"gate_filament_name",
172+
"gate_temperature",
173+
"gate_speed_override",
152174
}
153175
if gate_keys.isdisjoint(diff):
154176
# No gate array changes
@@ -158,9 +180,13 @@ def apply_diff(self, diff: dict) -> "MMUState":
158180
if "ttg_map" in scalar_fields:
159181
scalar_fields["ttg_map"] = tuple(scalar_fields["ttg_map"])
160182
if "filament_pos" in scalar_fields:
161-
scalar_fields["filament_pos"] = FilamentPos(scalar_fields["filament_pos"])
183+
scalar_fields["filament_pos"] = FilamentPos(
184+
scalar_fields["filament_pos"]
185+
)
162186
if "spoolman_support" in scalar_fields:
163-
scalar_fields["spoolman_support"] = SpoolmanSupport(scalar_fields["spoolman_support"])
187+
scalar_fields["spoolman_support"] = SpoolmanSupport(
188+
scalar_fields["spoolman_support"]
189+
)
164190
return dataclasses.replace(self, **scalar_fields)
165191
# Gate arrays changed — need full rebuild, but we lost the raw arrays
166192
# Pass current gate data + diff into from_status
@@ -170,6 +196,9 @@ def apply_diff(self, diff: dict) -> "MMUState":
170196
"gate_color": [g.color for g in self.gates],
171197
"gate_color_rgb": [g.color_rgb for g in self.gates],
172198
"gate_spool_id": [g.spool_id for g in self.gates],
199+
"gate_filament_name": [g.filament_name for g in self.gates],
200+
"gate_speed_override": list(self.gate_speed_override),
201+
"gate_temperature": [g.temperature for g in self.gates],
173202
}
174203
merged = {**dataclasses.asdict(self), **gate_data, **diff}
175204
return MMUState.from_status(merged)

0 commit comments

Comments
 (0)