-
Notifications
You must be signed in to change notification settings - Fork 7
/
KlipperSettingsPlugin.py
1499 lines (1209 loc) · 73.8 KB
/
KlipperSettingsPlugin.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# KlipperSettingsPlugin v1.0.2
# Copyright (c) 2023 J.Jarrard / JJFX
# The KlipperSettingsPlugin is released under the terms of the AGPLv3 or higher.
#
# ** CREDIT **
# Special thanks to Aldo Hoeben / fieldOfView whose previous work made this possible.
# Thanks to everyone who has provided feedback and helped test the beta.
'''
KLIPPER SETTINGS PLUGIN
-----------------------
Compatible only with Klipper firmware.
Creates new 'Klipper Settings' category at the bottom of Cura settings list.
Designed to work without the need for additional Klipper macros.
Multiple extruders are supported for compatible settings.
Ultimaker Cura compatibility tested up to version 5.2.2:
Recommended Version: 5.0.0 (SDK 8.0.0) and newer.
Minimum Supported Version: 4.0.0 (SDK 6.0.0)
-------------------------------------------------
Version | Release Notes & Features
-------------------------------------------------
v0.8.0 + Tested up to Cura version 5.0
| Pressure Advance Settings (v1.5)
| Tuning Tower Settings
| Velocity Limits Settings
v0.8.1 + Fixed custom category icon
v0.9.0 + Firmware Retraction Settings
| Input Shaper Settings
| Tuning Tower Presets feature
| Tuning Tower Suggested Settings feature
| Tuning Tower Preset: Pressure Advance
| Tuning Tower Preset: Ringing Tower
v0.9.1 + Fixed crashing in older Cura versions
| Custom icon now only enabled for Cura 5.0+
| Improved preset and backup behavior
v0.9.2 + P.A. Preset: Fixed incorrect parameter
| Preset layer height suggested from nozzle size
--------|
v1.0.0 + Support for 3 Tuning Tower User Presets
| Pressure Advance Smooth Time
| Z Offset Control
| Z Offset Layer 0 feature
| P.A. Preset: Suggested factor set automatically
| Improved UI behavior
| Experimental Features:
| - Bed Mesh Calibrate
| - Klipper UI Preheat Support
v1.0.1 + Firmware retraction multi-extruder support
| Firmware retraction uses cura values by default
| Various bug fixes
v1.0.2 + Setting definition compatibility for older versions
| Fixed duplicate setting relations
| Fixed changing machines with preset settings enabled
| Smooth time not tied to pressure advance control
| Final warnings combined into a single message
| Setting definition cleanup
'''
import os.path, json, re
import configparser # To parse settings backup in config file
from collections import OrderedDict # Ensure order of settings in all Cura versions
from typing import List, Optional, Any, Dict, Set, TYPE_CHECKING
try:
from PyQt6.QtCore import QUrl # Import custom images
except ImportError: # Older cura versions
from PyQt5.QtCore import QUrl
from cura.CuraApplication import CuraApplication
from UM.Qt.Bindings.Theme import Theme # Update theme with path to custom icon
from UM.Extension import Extension
from UM.Logger import Logger # Debug logging
from UM.Version import Version # Some features not supported in older versions
from UM.Resources import Resources # Add local path to plugin resources
from UM.Settings.SettingDefinition import SettingDefinition # Create and register setting definitions
from UM.Settings.DefinitionContainer import DefinitionContainer
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Message import Message # Display messages to user
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator # Get per-object settings
from UM.i18n import i18nCatalog # Translations
catalog = i18nCatalog("cura")
if TYPE_CHECKING:
from UM.OutputDevice.OutputDevice import OutputDevice
class KlipperSettingsPlugin(Extension):
def __init__(self, parent=None) -> None:
super().__init__()
Resources.addSearchPath(os.path.join(os.path.dirname(__file__), "resources")) # Plugin resource path
self._application = CuraApplication.getInstance()
self._cura_version = Version(self._application.getVersion())
self._i18n_catalog = None # type: Optional[i18nCatalog]
self._global_container_stack = None # type: Optional[ContainerStack]
self.comment = ";KlipperSettingsPlugin" # Plugin signature added to all new gcode commands
self._settings_dict = {} # type: Dict[str, Any]
category_icon = self._updateCategoryIcon("Klipper") # Get supported category icon
self._category_key = "klipper_settings"
self._category_dict = {
"label": "Klipper Settings",
"description": "Features and Settings Specific to Klipper Firmware",
"type": "category",
"icon": "%s" % category_icon
}
## Message box
self._active_msg_list = [] # type: List[str]
self._warning_msg = [] # type: List[str]
self._previous_msg = None
## Tuning tower
self._user_settings = {} # type: Dict[str, Any]
self._current_preset = None
self._override_on = False
# Support for 3 custom presets
self._custom_presets = {} # type: Dict[(int, str), Any]
# Current firmware retraction values
self._firmware_retract = {} # type: Dict[str, float]
try: # Get setting definitions from json
with open(os.path.join(os.path.dirname(__file__), "klipper_settings.def.json"), encoding = "utf-8") as f:
self._settings_dict = json.load(f, object_pairs_hook = OrderedDict)
except:
Logger.logException('e', "Could not load klipper settings definition")
return
else: # Modify definitions for older Cura compatibility
if self._cura_version < Version("4.7.0"):
self._fixSettingsCompatibility()
ContainerRegistry.getInstance().containerLoadComplete.connect(self._onContainerLoadComplete)
self._application.initializationFinished.connect(self._onInitialization)
def _onInitialization(self) -> None:
## Connect signals
self._application.getPreferences().preferenceChanged.connect(self._fixCategoryVisibility)
self._application.getMachineManager().globalContainerChanged.connect(self._onGlobalContainerChanged)
self._application.getOutputDeviceManager().writeStarted.connect(self._filterGcode)
## Startup actions
# Checks user settings backup in Cura config
self._user_settings = self._getBackup() # type: Dict[str, Any]
self._fixCategoryVisibility() # Ensure visibility of new settings category
self._onGlobalContainerChanged() # Connect to Cura setting changes
self._setTuningTowerPreset() # Set status of tuning tower settings
# Defines custom preset profiles
for profile_nr in [1, 2, 3]:
# Checks for preset backup in Cura config
self._custom_presets.update(self._getBackup("preset%s" % profile_nr))
def _onContainerLoadComplete(self, container_id: str) -> None:
"""Checks loaded containers for active definition containers.
Registers new Klipper Settings category and setting definitions.
"""
if not ContainerRegistry.getInstance().isLoaded(container_id):
return # Skip containers that could not be loaded
try:
container = ContainerRegistry.getInstance().findContainers(id = container_id)[0]
except IndexError:
return # Sanity check
if not isinstance(container, DefinitionContainer) or container.getMetaDataEntry('type') == "extruder":
return # Skip non-definition and extruder containers
# Create new settings category
klipper_category = SettingDefinition(self._category_key, container, None, self._i18n_catalog)
klipper_category.deserialize(self._category_dict)
container.addDefinition(klipper_category) # Register category setting definition
try: # Make sure new category actually exists
klipper_category = container.findDefinitions(key=self._category_key)[0]
except IndexError:
Logger.log('e', "Could not find settings category: '%s'", self._category_key)
return
# Adds all setting definitions to new category
for setting_key in self._settings_dict:
setting_definition = SettingDefinition(setting_key, container, klipper_category, self._i18n_catalog)
setting_definition.deserialize(self._settings_dict[setting_key])
## Restricted: Appends new setting to the existing category definition.
## No existing commands are affected in the new restricted list and simply updating the
## definition container cache/relations seems safe in relevant Cura versions.
klipper_category._children.append(setting_definition)
container._definition_cache[setting_key] = setting_definition
if setting_definition.children:
self._updateAddedChildren(container, setting_definition)
container._updateRelations(klipper_category) # Update relations for all category settings
def _updateAddedChildren(self, container: DefinitionContainer, setting_definition: SettingDefinition) -> None:
# Updates definition cache for all setting definition children
for child in setting_definition.children:
container._definition_cache[child.key] = child
if child.children:
self._updateAddedChildren(container, child)
def _updateCategoryIcon(self, icon_name: str) -> str:
"""Returns string of compatible category icon to update Cura theme.
Updates default Cura theme with custom icon for new settings category.
In Cura versions before 5.0 a default icon name is returned.
* icon_name: String for name of icon image resource without extension.
"""
category_icon = "plugin" # Existing Cura icon if new icon fails to load
if self._cura_version < Version("5.0.0"):
Logger.log('d', "Category icon not compatible with Cura version %s", self._cura_version)
else:
try:
icon_path = Resources.getPath(6, "%s.svg" % icon_name) # Resource type 6 (images)
except FileNotFoundError:
Logger.log('w', "Category icon image could not be found.")
else:
current_theme = Theme.getInstance()
category_icon = icon_name
## Restricted: Adds new custom icon to the default theme icon dict.
## The only alternative found is to load an entire cloned theme with the icon.
if icon_name not in current_theme._icons['default']:
current_theme._icons['default'][icon_name] = QUrl.fromLocalFile(icon_path)
return category_icon
def _fixSettingsCompatibility(self) -> None:
"""Update setting definitions for older Cura version compatibility.
Prior to 4.7.0 'support_meshes_present' did not exist and tree supports was an experimental option.
"""
pa_support = self._settings_dict['klipper_pressure_advance_factor'].get('children')['klipper_pressure_advance_support']
pa_support_infill = pa_support.get('children')['klipper_pressure_advance_support_infill']
pa_support_interface = pa_support.get('children')['klipper_pressure_advance_support_interface']
for definition in [pa_support, pa_support_infill, pa_support_interface]:
definition['enabled'] = str(definition['enabled']).replace("support_meshes_present", "support_tree_enable")
# Updates support setting definition
self._settings_dict['klipper_pressure_advance_factor'].get('children').update({'klipper_pressure_advance_support': pa_support})
# Updates setting children
self._settings_dict['klipper_pressure_advance_factor'].get('children')['klipper_pressure_advance_support'].get('children').update({
'klipper_pressure_advance_support_infill': pa_support_infill,
'klipper_pressure_advance_support_interface': pa_support_interface})
def _fixCategoryVisibility(self, preference: str = "general/visible_settings") -> None:
"""Ensure new category is visible at start and when visibility changes.
"""
if preference != "general/visible_settings":
return
preferences = self._application.getPreferences()
visible_settings = preferences.getValue(preference)
if not visible_settings:
return # Empty list fixed once user adds a visible setting
if self._category_key not in visible_settings:
visible_settings += ";%s" % self._category_key
preferences.setValue(preference, visible_settings) # Category added to visible settings
def _onGlobalContainerChanged(self) -> None:
"""The active machine global container stack has changed.
Signals when a property changes in global or extruder stacks.
Restores user settings if the active machine changed with preset override enabled.
"""
if self._global_container_stack: # Disconnect inactive container
self._global_container_stack.propertyChanged.disconnect(self._onGlobalSettingChanged)
for extruder in self._global_container_stack.extruderList:
extruder.propertyChanged.disconnect(self._onExtruderSettingChanged)
if self._user_settings: # Restore user settings when switching machines
try: # Get new machine ID
new_active_machine_id = self._application.getMachineManager().activeMachine.getId()
except AttributeError:
Logger.log('w', "Could not get active machine ID.")
else: # Switch to previous machine because global container already changed
self._application.getMachineManager().setActiveMachine(self._global_container_stack.getId())
self._restoreUserSettings(announce = False)
# Sets the new active machine
self._application.getMachineManager().setActiveMachine(new_active_machine_id)
self._current_preset = None
self._setTuningTowerPreset() # Set tuning tower status
self._global_container_stack = self._application.getMachineManager().activeMachine
if self._global_container_stack: # Connect active container stack
self._global_container_stack.propertyChanged.connect(self._onGlobalSettingChanged)
for extruder in self._global_container_stack.extruderList:
extruder.propertyChanged.connect(self._onExtruderSettingChanged)
def _onGlobalSettingChanged(self, setting: str, property: str) -> None:
"""Setting in the global container stack has changed.
Monitors when klipper settings in the global stack have new values.
* setting: String of the setting key that changed.
* property: String of the setting property that changed.
"""
if setting.startswith("klipper") and property in ["value", "enabled"]:
if setting.startswith("klipper_tuning"):
self._setTuningTowerPreset() # Update tuning tower presets
def _onExtruderSettingChanged(self, setting: str, property: str) -> None:
"""Setting in an extruder container stack has changed.
Monitors when certain klipper settings in the active extruder stack have new values.
Klipper retraction settings mimic Cura values until user changes are detected.
* setting: String of the setting key that changed.
* property: String of the setting property that changed.
"""
if setting.startswith("klipper") and property in ["value", "enabled"]:
is_user_value = self.settingWizard(setting, "Get hasUserValue")
if setting.startswith("klipper_retract"):
if setting == "klipper_retraction_speed" and is_user_value:
retraction_speed = self.settingWizard("klipper_retraction_speed")
for child in ["klipper_retract_speed", "klipper_retract_prime_speed"]:
values_match = (self._firmware_retract.get(setting, None) == self._firmware_retract.get(child, None))
value_changed = self.settingWizard(child, "Get hasUserValue")
# Ensures children tied to cura values follow user changes to parent setting
# TODO: Minor bug if parent value is set to default value again;
# Stop-gap until solution is found for changing the 'value' function of existing settings.
if not value_changed or values_match:
self.settingWizard(child, retraction_speed, "Set")
# Saves previously set values to compare changes
self._firmware_retract[setting] = self.settingWizard(setting)
def _forceErrorCheck(self, setting_key: str=None) -> None:
"""Force error check on current setting values.
Ensures user can't slice if Cura doesn't recognize default value as error.
May not be necessary for all Cura versions but best to play it safe.
All tuning tower settings checked if no setting_key specified.
+ setting_key: String for specific setting to check.
"""
error_checker = self._application.getMachineErrorChecker()
if setting_key:
error_checker.startErrorCheckPropertyChanged(setting_key, "value")
else:
for setting in self.__tuning_tower_setting_key.values():
error_checker.startErrorCheckPropertyChanged(setting, "value")
def _filterGcode(self, output_device: "OutputDevice") -> None:
"""Inserts command strings for enabled Klipper settings into final gcode.
Cura gcode is post-processed at the time of saving a new sliced file.
"""
scene = self._application.getController().getScene()
global_stack = self._application.getGlobalContainerStack()
extruder_manager = self._application.getExtruderManager()
used_extruder_stacks = extruder_manager.getUsedExtruderStacks()
if not global_stack or not used_extruder_stacks:
return
# Extruders currently affected by klipper settings
active_extruder_list = set() # type: Set[int]
# Mesh features for pressure advance
active_mesh_features = set() # type: Set[str]
cura_start_gcode = global_stack.getProperty('machine_start_gcode', 'value') # To search for existing commands
# Gets global state of klipper setting controls (bool)
firmware_retract_enabled = global_stack.getProperty('machine_firmware_retract', 'value')
pressure_advance_enabled = global_stack.getProperty('klipper_pressure_advance_enable', 'value')
velocity_limits_enabled = global_stack.getProperty('klipper_velocity_limits_enable', 'value')
input_shaper_enabled = global_stack.getProperty('klipper_input_shaper_enable', 'value')
tuning_tower_enabled = global_stack.getProperty('klipper_tuning_tower_enable', 'value')
smooth_time_enabled = global_stack.getProperty('klipper_smooth_time_enable', 'value')
z_offset_enabled = global_stack.getProperty('klipper_z_offset_control_enable', 'value')
# Experimental features
experimental_features_enabled = global_stack.getProperty('klipper_experimental_enable', 'value')
mesh_calibrate_enabled = global_stack.getProperty('klipper_mesh_calibrate_enable', 'value')
ui_temp_support_enabled = global_stack.getProperty('klipper_ui_temp_support_enable', 'value')
gcode_dict = getattr(scene, 'gcode_dict', {})
if not gcode_dict:
Logger.log('w', "Scene has no gcode to process")
return
gcode_changed = False
new_gcode_commands = "" # String container for all new commands
for plate_id in gcode_dict:
gcode_list = gcode_dict[plate_id]
if len(gcode_list) < 2:
Logger.log('w', "Plate %s does not contain any layers", plate_id)
continue
if ";KLIPPERSETTINGSPROCESSED\n" in gcode_list[0]: # Only process new files
Logger.log('d', "Plate %s has already been processed", plate_id)
continue
# Searches start gcode for tool change command
# Compatibility for cura versions without getInitialExtruder
initial_toolchange = re.search(r"(?m)^T([0-9])+$", gcode_list[1])
if initial_toolchange: # Set initial extruder number
start_extruder_nr = int(initial_toolchange.group(1))
else: # Set active extruder number
start_extruder_nr = int(self.settingWizard('extruder_nr'))
start_extruder_stack = extruder_manager.getExtruderStack(start_extruder_nr)
## EXPERIMENTAL FEATURES --------------------------------
if not experimental_features_enabled:
Logger.log('d', "Klipper Experimental Features Disabled")
else:
## BED MESH CALIBRATE COMMAND
if not mesh_calibrate_enabled:
Logger.log('d', "Klipper Bed Mesh Calibration is Disabled")
else:
# Search start gcode for existing command
mesh_calibrate_exists = self.gcodeSearch(cura_start_gcode, 'BED_MESH_CALIBRATE')
if mesh_calibrate_exists: # Do not add commands
self.showMessage(
"<i>Calibration command is already active in Cura start gcode.</i>",
"WARNING", "Bed Mesh Calibrate Not Applied", stack_msg = True)
else: # Add mesh calibration command sequence to gcode
preheat_bed_temp = global_stack.getProperty("material_bed_temperature_layer_0", 'value')
gcode_list[1] = "M190 S%s %s\n" % (preheat_bed_temp, self.comment) + (
"G28 %s\n" % self.comment) + (
"BED_MESH_CALIBRATE %s\n\n" % self.comment) + gcode_list[1]
gcode_changed = True
self.showMessage(
"<i>Calibration will heat bed then run before the start gcode sequence.</i>",
"NEUTRAL", "Klipper Bed Mesh Calibration Enabled")
## KLIPPER UI SUPPORT
if not ui_temp_support_enabled:
Logger.log('d', "Klipper UI Temp Support is Disabled")
else:
# Checks if M190 and M109 commands exist in start gcode
new_gcode_commands += self._gcodeUiSupport(gcode_list[1])
## FIRMWARE RETRACTION COMMAND --------------------------
if not firmware_retract_enabled:
Logger.log('d', "Klipper Firmware Retraction is Disabled")
extruder_fw_retraction = None
else:
initial_retraction_settings = {} # type: Dict[str, float]
extruder_fw_retraction = {} # type: Dict[int, Dict[str, float]]
if len(used_extruder_stacks) > 1: # Add empty dict for each extruder
for extruder_nr in range(len(used_extruder_stacks)):
extruder_fw_retraction[extruder_nr] = {} # type: Dict[str, float]
for klipper_cmd, setting in self.__firmware_retraction_setting_key.items():
# Gets initial retraction settings for the print
initial_retraction_settings[klipper_cmd] = start_extruder_stack.getProperty(setting, 'value')
if extruder_fw_retraction:
for extruder in used_extruder_stacks:
extruder_nr = int(extruder.getProperty('extruder_nr', 'value'))
# Gets settings for each extruder and updates active extruders
extruder_fw_retraction[extruder_nr].update({klipper_cmd: extruder.getProperty(setting, 'value')})
active_extruder_list.add(extruder_nr) # type: Set[int]
for extruder_nr, settings in extruder_fw_retraction.items(): # Create gcode command for each extruder
extruder_fw_retraction[extruder_nr] = self._gcodeFirmwareRetraction(settings) + self.comment # type: Dict[int, str]
try: # Add enabled commands for initial extruder to start gcode
new_gcode_commands += (self._gcodeFirmwareRetraction(initial_retraction_settings) + self.comment + "\n")
except TypeError:
Logger.log('d', "Klipper initial firmware retraction was not set.")
## VELOCITY LIMITS COMMAND ------------------------------
if not velocity_limits_enabled:
Logger.log('d', "Klipper Velocity Limit Control is Disabled")
else:
velocity_limits = {} # type: Dict[str, int]
# Get all velocity setting values
for limit_key, limit_setting in self.__velocity_limit_setting_key.items():
velocity_limits[limit_key] = global_stack.getProperty(limit_setting, 'value')
try: # Add enabled commands to gcode
new_gcode_commands += (self._gcodeVelocityLimits(velocity_limits) + self.comment + "\n")
except TypeError:
Logger.log('d', "Klipper velocity limits were not set.")
## INPUT SHAPER COMMAND ---------------------------------
if not input_shaper_enabled:
Logger.log('d', "Klipper Input Shaper Control is Disabled")
else:
shaper_settings = {} # type: Dict[str, Any]
# Get all input shaper setting values
for shaper_key, shaper_setting in self.__input_shaper_setting_key.items():
shaper_settings[shaper_key] = global_stack.getProperty(shaper_setting, 'value')
try: # Add enabled commands to gcode
new_gcode_commands += (self._gcodeInputShaper(shaper_settings) + self.comment + "\n")
except TypeError:
Logger.log('d', "Klipper input shaper settings were not set.")
## TUNING TOWER COMMAND ---------------------------------
if not tuning_tower_enabled:
Logger.log('d', "Klipper Tuning Tower is Disabled")
else:
tower_settings = OrderedDict() # type: OrderedDict[str, Any]
# Get all tuning tower setting values
for tower_key, tower_setting in self.__tuning_tower_setting_key.items():
tower_settings[tower_key] = global_stack.getProperty(tower_setting, 'value')
try: # Add tuning tower sequence to gcode
gcode_list[1] += (self._gcodeTuningTower(tower_settings) + self.comment + "\n")
gcode_changed = True
except TypeError:
Logger.log('w', "Klipper tuning tower could not be processed.")
return # Stop on error
## Z OFFSET COMMAND -------------------------------------
if not z_offset_enabled:
Logger.log('d', "Klipper Z Offset Adjustment is Disabled")
z_offset_layer_0 = 0
else:
z_offset_adjust_pattern = "SET_GCODE_OFFSET Z_ADJUST=%g " + self.comment
z_offset_set_pattern = "SET_GCODE_OFFSET Z=%g " + self.comment
z_offset_override = global_stack.getProperty('klipper_z_offset_set_enable', 'value')
z_offset_layer_0 = global_stack.getProperty('klipper_z_offset_layer_0', 'value')
if not z_offset_override:
Logger.log('d', "Klipper total z offset was not changed.")
else:
z_offset_total = global_stack.getProperty('klipper_z_offset_set_total', 'value')
# Overrides any existing z offset with new value
# This will compound with any additional first layer z offset adjustment.
gcode_list[1] += z_offset_set_pattern % z_offset_total + "\n" # Applied after start gcode
gcode_changed = True
# Add z offset override warning
self._warning_msg.insert(0, "• <i>Z Offset Override</i> is set to <b>%s mm</b>" % z_offset_total)
if not z_offset_layer_0:
Logger.log('d', "Klipper first layer z offset was not changed.")
else:
layer_0_height = global_stack.getProperty('layer_height_0', 'value')
# Matches z axis coordinate in gcode lines that haven't been processed
# Z offset only applies if z axis coordinate equals the layer 0 height;
# This is safer and necessary to avoid conflicts with settings such as z hop.
z_axis_regex = re.compile(r"^G[01]\s.*Z(%g)(?!.*%s)" % (layer_0_height, self.comment))
self._warning_msg.insert(0, "• <i>Initial Layer Z Offset</i> will <b>%s</b> nozzle by <b>%s mm</b>" % (
"lower" if z_offset_layer_0 < 0 else "raise", z_offset_layer_0)) # Add to final warning message
## PRESSURE ADVANCE COMMAND -----------------------------
if not pressure_advance_enabled and not smooth_time_enabled:
Logger.log('d', "Klipper Pressure Advance Control is Disabled")
else:
# Extruder Settings
apply_factor_per_feature = {} # type: Dict[int, bool]
extruder_factors = {} # type: Dict[(int,str), float]
current_factor = {} # type: Dict[int, float]
# Mesh Object Settings
per_mesh_factors = {} # type: Dict[(str,str), float]
non_mesh_features = [*self.__pressure_advance_setting_key][8:] # SUPPORT, SKIRT, etc.
smooth_time_factor = 0
pressure_advance_factor = -1
for extruder_stack in used_extruder_stacks: # Get settings for all active extruders
extruder_nr = int(extruder_stack.getProperty('extruder_nr', 'value'))
if not smooth_time_enabled:
Logger.log('d', "Klipper Pressure Advance Smooth Time is Disabled")
else:
smooth_time_factor = extruder_stack.getProperty('klipper_smooth_time_factor', 'value')
if not pressure_advance_enabled:
Logger.log('d', "Klipper Pressure Advance Factor is Disabled")
else:
pressure_advance_factor = extruder_stack.getProperty('klipper_pressure_advance_factor', 'value')
current_factor[extruder_nr] = pressure_advance_factor
# Gets feature settings for each extruder
for feature_key, setting_key in self.__pressure_advance_setting_key.items():
extruder_factors[(extruder_nr, feature_key)] = extruder_stack.getProperty(setting_key, 'value')
# Checks for unique feature values
if extruder_factors[(extruder_nr, feature_key)] != pressure_advance_factor:
apply_factor_per_feature[extruder_nr] = True # Flag to process gcode
try: # Add initial pressure advance command for all active extruders
new_gcode_commands += self._gcodePressureAdvance(
str(extruder_nr).strip('0'), pressure_advance_factor, smooth_time_factor) + "\n"
except TypeError:
Logger.log('w', "Klipper pressure advance values invalid: %s", str(pressure_adv_values))
return
if pressure_advance_enabled:
## Per Object Settings
# Gets printable mesh objects that are not support
nodes = [node for node in DepthFirstIterator(scene.getRoot())
if node.isSelectable()and not node.callDecoration('isNonThumbnailVisibleMesh')]
if not nodes:
Logger.log('w', "No valid objects in scene to process.")
return
for node in nodes:
mesh_name = node.getName() # Filename of mesh with extension
mesh_settings = node.callDecoration('getStack').getTop()
extruder_nr = int(node.callDecoration('getActiveExtruderPosition'))
# Get active feature settings for mesh object
for feature_key, setting_key in self.__pressure_advance_setting_key.items():
if mesh_settings.getInstance(setting_key) is not None:
mesh_setting_value = mesh_settings.getInstance(setting_key).value
else:
continue
# Save the children!
for feature in (
["WALL-OUTER", "WALL-INNER", "SKIN", "FILL"] if feature_key == "_FACTORS"
else ['WALL-OUTER', 'WALL-INNER'] if feature_key == "_WALLS"
else ['SUPPORT', 'SUPPORT-INTERFACE'] if feature_key == "_SUPPORTS"
else [feature_key]):
per_mesh_factors[(mesh_name, feature)] = mesh_setting_value
active_mesh_features.add(feature) # All per-object features
apply_factor_per_feature[extruder_nr] = True # Flag to process gcode
# Set gcode loop parameters
if any(apply_factor_per_feature.values()):
for extruder_nr in list(apply_factor_per_feature):
active_extruder_list.add(extruder_nr)
else:
pressure_advance_enabled = False
## POST-PROCESS GCODE LOOP ------------------------------
# TODO: This should eventually get reworked into a function.
if pressure_advance_enabled or (z_offset_layer_0 or extruder_fw_retraction):
extruder_nr = start_extruder_nr
current_layer_nr = -1
current_mesh = None
feature_type_error = False
for layer_nr, layer in enumerate(gcode_list):
lines = layer.split("\n")
lines_changed = False
for line_nr, line in enumerate(lines):
apply_new_factor = False
if line.startswith(";LAYER:"):
try:
current_layer_nr = int(line[7:]) # Integer for current gcode layer
except ValueError:
Logger.log('w', "Could not get layer number: %s", line)
new_layer = bool(active_mesh_features) # Sanity check for mesh features
if z_offset_layer_0 and current_layer_nr == 0:
# Matches new line with z coordinate equal to layer 0 height
z_axis_change = z_axis_regex.fullmatch(line)
if z_axis_change:
# Inserts z offset command before matched line, then instructs klipper to
# revert the offset on the next z axis change even if the print is stopped.
lines.insert(line_nr + 1, z_offset_adjust_pattern % -(z_offset_layer_0))
lines[line_nr] = line + self.comment # Append line to prevent infinite match
lines.insert(line_nr, z_offset_adjust_pattern % z_offset_layer_0)
lines_changed = True
if len(active_extruder_list) > 1:
# Sets extruder number from tool change commands (T0,T1...)
if line in ["T" + str(i) for i in active_extruder_list]:
try:
extruder_nr = int(line[1:]) # Active extruder number
except ValueError:
Logger.log('w', "Could not get extruder number: %s", line)
# Applies retraction values for the current extruder
if extruder_fw_retraction and current_layer_nr >= 0:
lines.insert(line_nr + 1, extruder_fw_retraction[extruder_nr])
lines_changed = True
if not pressure_advance_enabled:
if extruder_fw_retraction or (current_layer_nr <= 0 and z_offset_layer_0):
continue
else:
break
if line.startswith(";MESH:") and line[6:] != "NONMESH":
current_mesh = line[6:] # String for gcode mesh name
if not feature_type_error:
continue
apply_new_factor = True # Command will insert before current line
feature_type_error = False
if line.startswith(";TYPE:"):
feature_type = line[6:] # String for gcode feature
if current_layer_nr <= 0 and feature_type != "SKIRT":
feature_type = "LAYER_0"
# Fixes when MESH name is not specified prior to its feature TYPE
# Mostly an issue in older cura versions.
if new_layer and feature_type in active_mesh_features:
feature_type_error = True
continue # Error corrected at next MESH line
apply_new_factor = True
line_nr += 1 # Command will insert after current line
if apply_new_factor:
# Sets current extruder value if no mesh setting exists
pressure_advance_factor = per_mesh_factors.get((current_mesh, feature_type),
extruder_factors[(extruder_nr, feature_type)])
new_layer = False
# Sets new factor if different from the active value
if pressure_advance_factor != current_factor.get(extruder_nr, None):
current_factor[extruder_nr] = pressure_advance_factor
lines.insert(line_nr, self._gcodePressureAdvance(
str(extruder_nr).strip('0'), pressure_advance_factor))
lines_changed = True
## Restores gcode layer formatting
if lines_changed:
gcode_list[layer_nr] = "\n".join(lines)
gcode_changed = True
## Adds new commands to start of gcode
if not new_gcode_commands:
Logger.log('d', "Klipper start gcode commands were not added.")
else:
gcode_list[1] = new_gcode_commands + "\n" + gcode_list[1]
gcode_changed = True
## Finalize processed gcode
if gcode_changed:
self._showWarningMessage(60) # Display any active setting warnings
gcode_list[0] += ";KLIPPERSETTINGSPROCESSED\n"
gcode_dict[plate_id] = gcode_list
setattr(scene, 'gcode_dict', gcode_dict)
def gcodeSearch(self, gcode: str, command: str, ignore_comment: bool=False) -> bool:
"""Returns true if command exists in gcode string.
Regex multi-line search for active or inactive gcode command string.
Any characters on the line after the search string are ignored.
* gcode: String containing gcode to search in.
* command: String for gcode command to find.
+ ignore_comment: True includes commented command as a match.
"""
# Command is assumed to be the start of the line, ignoring white space;
# Technically treats any preceding character as a comment which is functionally
# identical for this purpose because the command would be invalid anyway.
result = re.search(r"(?mi)^(?P<comment>.*)(%s)[.]*" % re.escape(command), gcode)
if result:
line_match = result.group().lstrip(" \t")
if line_match == command:
Logger.log('i', "Active command found in gcode: '%s'", line_match)
result = bool(result)
elif result.group('comment') and ignore_comment:
Logger.log('i', "Inactive command found in gcode: '%s'", line_match)
result = line_match # Full line returned
else:
result = False
return result
def _gcodeUiSupport(self, gcode = List[str]) -> str:
"""Command string of commented print start temps.
Allows fluidd/mainsail to detect gcode print temps when start gcode uses
klipper macros without visible M190 and M109 gcode commands.
* gcode: String containing gcode to search in.
"""
bed_temp = self.settingWizard("material_bed_temperature_layer_0", 'value')
nozzle_temp = self.settingWizard("material_print_temperature", 'value')
nozzle_start_temp = self.settingWizard("material_print_temperature_layer_0", 'value')
bed_temp_exists = self.gcodeSearch(gcode, "M190", True)
nozzle_temp_exists = self.gcodeSearch(gcode, "M109", True)
gcode_comment = ""
if not bed_temp_exists:
gcode_comment += ";M190 S%s %s\n" % (bed_temp, self.comment)
if not nozzle_temp_exists:
nozzle_temp = nozzle_start_temp if nozzle_start_temp > 0 else nozzle_temp
gcode_comment += ";M109 S%s %s\n" % (nozzle_temp, self.comment)
if gcode_comment:
gcode_comment = ";Support for Klipper UI\n" + gcode_comment
return gcode_comment
def _gcodePressureAdvance(self, extruder_nr: str, pressure_advance: float=-1, smooth_time: float=0) -> str:
"""Returns enabled pressure advance settings as gcode command string.
"""
gcode_command = "SET_PRESSURE_ADVANCE"
if pressure_advance >= 0:
gcode_command += " ADVANCE=%g" % pressure_advance
if smooth_time > 0:
gcode_command += " SMOOTH_TIME=%g" % smooth_time
gcode_command += " EXTRUDER=extruder%s %s" % (extruder_nr, self.comment)
return gcode_command
def _gcodeVelocityLimits(self, velocity_limits: Dict[str, float]) -> str:
"""Returns enabled velocity settings as gcode command string.
"""
# Remove disabled settings
velocity_limits = {key: d for key, d in velocity_limits.items() if (
key != "square_corner_velocity" and d > 0) or (
key == "square_corner_velocity" and d >= 0)}
if velocity_limits:
gcode_command = "SET_VELOCITY_LIMIT "
for key, value in velocity_limits.items():
gcode_command += "%s=%d " % (key.upper(), value)
if not self._override_on and (key == "square_corner_velocity" and value == 0):
self._warning_msg.append("• Square Corner Velocity Limit = <b>0</b>")
return gcode_command # TypeError msg if no return
def _gcodeFirmwareRetraction(self, retraction_settings: Dict[str, float]) -> str:
"""Returns enabled firmware retraction settings as gcode command string.
"""
# Remove disabled settings
retraction_settings = {key: d for key, d in retraction_settings.items() if (
key.endswith("speed") and d > 0) or (key.endswith("length") and d >= 0)}
if retraction_settings:
gcode_command = "SET_RETRACTION "
for key, value in retraction_settings.items():
gcode_command += "%s=%g " % (key.upper(), value) # Create gcode command
return gcode_command # TypeError msg if no return
def _gcodeInputShaper(self, shaper_settings: Dict[str, Any]) -> str:
"""Returns enabled input shaper settings as gcode command string.
"""
if shaper_settings['shaper_type_x'] == shaper_settings['shaper_type_y']:
shaper_settings['shaper_type'] = shaper_settings.pop('shaper_type_x')
del shaper_settings['shaper_type_y'] # Use single command for both axes
# Remove all disabled settings
shaper_settings = {key: v for key, v in shaper_settings.items() if (
key.startswith("type", 7) and v != "disabled") or (not key.startswith("type", 7) and v >= 0)}
value_warnings = len([v for v in shaper_settings.values() if v == 0]) # Number of values set to 0
if shaper_settings:
gcode_command = "SET_INPUT_SHAPER "
for key, value in shaper_settings.items():
value = value.upper() if key.startswith("type", 7) else value
gcode_command += "%s=%s " % (key.upper(), value) # Create gcode command
if not self._override_on and value_warnings:
self._warning_msg.append("• <b>%d</b> Input Shaper setting(s) = <b>0</b>" % value_warnings)
return gcode_command # TypeError msg if no return
def _gcodeTuningTower(self, tower_settings: Dict[str, Any]) -> str:
"""Returns enabled tuning tower settings as gcode command string.
Real-time string input validation done with regex patterns in setting definitions;
Accepts only word characters but 'command' also allows spaces, 'single-quotes' and '='.
'command' allows multiple words, up to arbitrary limit of 60 approved characters.
'parameter' allows single word up to arbitrary limit of 40 word characters.
"""
used_extruder_stacks = self._application.getExtruderManager().getUsedExtruderStacks()
gcode_settings = OrderedDict() # Preserve dict order in all Cura versions
# Remove disabled and optional values
for setting, value in tower_settings.items():
if not (setting in ['skip', 'band'] and value == 0):
gcode_settings[setting] = value
# Strips any white space, quotes and '=' from ends of 'command' string
gcode_settings['command'] = gcode_settings['command'].strip(" \t'=")
# Add single quotes if 'command' has multiple words
if len(gcode_settings['command'].split()) > 1:
gcode_settings['command'] = "'%s'" % gcode_settings['command']
gcode_command = "TUNING_TOWER "
method = gcode_settings.pop('tuning_method')
for key, value in gcode_settings.items():
if method == "factor" and key in ['step_delta', 'step_height']:
continue
if method == "step" and key in ['factor', 'band']:
continue
gcode_command += "%s=%s " % (key.upper(), value)
## Final Tuning Tower Warning Message
warning_msg = "<i>Tuning Tower is Active:</i><br/>%s<br/><br/>" % gcode_command
if len(used_extruder_stacks) > 1:
warning_msg += "<b><i>Tuning Tower</i> with multiple extruders could be unpredictable!</b><br/><br/>"
warning_msg += "<i>Tuning tower calibration affects <b>all objects</b> on the build plate.</i>"
self._warning_msg.append(warning_msg)
return gcode_command # TypeError stop if no return
def _setTuningTowerPreset(self) -> None:
"""Monitors and controls changes to tuning tower preset options.
User settings overridden by a preset are preserved in config file in case Cura closes.
Support for up to 3 user presets stored in Cura config sections.
"""
if not self._global_container_stack: # Cura needs to finish loading
return
tuning_tower_enabled = self.settingWizard("klipper_tuning_tower_enable")
if not tuning_tower_enabled:
self._hideActiveMessages() # Hide any active tuning tower messages
self._restoreUserSettings() # Ensure setting override is reset
self._current_preset = None
return
elif not self._current_preset: # Tuning tower already enabled
self._restoreUserSettings(announce = False)
self._forceErrorCheck() # Default value error check
self._hideActiveMessages()
self.showMessage(
"Tuning tower calibration affects <b>all objects</b> on the build plate.",
"WARNING", "Klipper Tuning Tower is Active", 30) # Show startup warning
preset_settings = {} #type: Dict[str, Any]
apply_preset = False
new_preset = self.settingWizard("klipper_tuning_tower_preset")
override_enabled = self.settingWizard("klipper_tuning_tower_override")
preset_changed = self._current_preset not in [None, new_preset]
## Suggested Settings Override
if not override_enabled:
self._restoreUserSettings(override_enabled)
apply_preset = preset_changed
elif preset_changed: # Reset setting override so user must re-enable it
self._restoreUserSettings()
return
elif not self._override_on:
Logger.log('d', "Tuning Tower Suggested Settings Enabled")
self.hideMessageType(self._previous_msg, msg_type = 0)
apply_preset = True
# Gets integer of preset identity if any custom profiles are active
active_custom_preset = int(new_preset[-1]) if new_preset.startswith("custom") else None
## Custom Preset Control
# It is not necessary to constantly save changes to the active profile because
# current values are handled by Cura and then saved upon changing profiles.
if preset_changed:
self._hideActiveMessages(msg_type = 1) # Hide neutral messages
# Saves changes to custom preset when switching presets
if self._current_preset.startswith("custom"):
preset_key = int(self._current_preset[-1])
for setting in self.__tuning_tower_setting_key.values():
setting = (preset_key, setting)
self.settingWizard(setting, action = "SaveCustom")
# Applies current custom preset values
if active_custom_preset:
for setting, value in self._custom_presets.items():
if setting[0] == active_custom_preset: # Current preset settings