From 65f0fd6238690b4c37da4d6f6c094e3ac6ad98ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janar=20S=C3=B6=C3=B6t?= Date: Mon, 20 Aug 2018 13:15:12 +0300 Subject: [PATCH] display menu module for klipper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Janar Sööt --- config/example-extras.cfg | 27 + config/example-menu.cfg | 181 ++++ klippy/extras/display/display.py | 8 + klippy/extras/display/menu.cfg | 933 +++++++++++++++++++ klippy/extras/display/menu.py | 1437 ++++++++++++++++++++++++++++++ 5 files changed, 2586 insertions(+) create mode 100644 config/example-menu.cfg create mode 100644 klippy/extras/display/menu.cfg create mode 100644 klippy/extras/display/menu.py diff --git a/config/example-extras.cfg b/config/example-extras.cfg index 6cd67131..c4728457 100644 --- a/config/example-extras.cfg +++ b/config/example-extras.cfg @@ -650,6 +650,33 @@ #a0_pin # The pins connected to an uc1701 type lcd. These parameters must be # provided when using an uc1701 display. +#menu_root: +# Entry point for menu, root menu container name. If this parameter +# is not provided then default menu root is used. When provided +# menu entry is 'deck' type then it'll be initiated immediately at startup. +# Description of menu items is located in example-menu.cfg file. +#menu_timeout: +# Timeout for menu. Being inactive this amount of seconds will trigger +# menu exit or return to root menu when having autorun enabled. +# The default is 0 seconds (disabled) +#encoder_pins: +# The pins connected to encoder. 2 pins must be provided when +# using encoder. This parameter must be provided when using menu. +#click_pin: +# The pin connected to 'enter' button or encoder 'click'. This parameter +# must be provided when using menu. +#back_pin: +# The pin connected to 'back' button. This parameter is optional, menu +# can be used without it. +#up_pin: +# The pin connected to 'up' button. This parameter must be provided +# when using menu without encoder. +#down_pin: +# The pin connected to 'down' button. This parameter must be provided +# when using menu without encoder. +#kill_pin: +# The pin connected to 'kill' button. This button will call +# emergency stop. # Custom thermistors (one may define any number of sections with a # "thermistor" prefix). A custom thermistor may be used in the diff --git a/config/example-menu.cfg b/config/example-menu.cfg new file mode 100644 index 00000000..0e26a986 --- /dev/null +++ b/config/example-menu.cfg @@ -0,0 +1,181 @@ +# This file serves as documentation for config parameters. One may +# copy and edit this file to configure a new menu layout. +# The snippets in this file may be copied into the main printer.cfg file. +# See the "example.cfg" file for description of common config parameters. + +# Available menu elements: +# item - purely visual element +# command - same like 'item' but with gcode trigger +# input - same like 'command' but has value changing capabilities +# list - menu element container, with entry and exit gcode triggers +# vsdcard - same as 'list' but will append files from virtual sdcard +# deck - special container for custom screens (cards) has entry and exit gcode triggers. +# card - special content card for custom screens. Can only be used in 'deck'! + +#[menu item1] +#type: item +# Type will determine menu item properties and behaviours: +#name: +# This is mandatory attribute for every menu element. +# You can use Python output formatting for parameter and transform values. +# Quotes can be used in the beginning and end of name. +#cursor: +# It allows to change cursor character for selected menu element. +# The default is > +# This parameter is optional. +#width: +# This attribute accepts integer value. Element name is cut to this width. +# This parameter is optional. +#scroll: +# This attribute accepts static boolean value. You can use it together with 'width'. +# When this is enabled then names longer than width are scrolled back and forth. +# The default is disabled. This parameter is optional. +#enable: +# This attribute accepts static boolean values and parameters (converted to boolean). +# It accepts multiple logical expressions. Values separated by comma will return True if all elements are true. +# Values on different lines will return True if any element is true. +# You can use logical negation by using character ! as parameter prefix. +#parameter: +# This attribute accepts float values or special variables. Multiple values are delimited by comma. +# All available parameter variables can be listed by 'MENU DO=dump' gcode, menu itself must be running. +# This value is available for output formatting as {0}..{n} Where n is count of parameters. +#transform: +# This attribute allows to transform parameters value to something else. +# More than one transformation can be added. Each transformation must be on separate line. +# These transformed values are available for output formatting as {n+1}..{x} +# Where n is count of parameters and x is count of transformations. +# In order to transform the value of a particular parameter, you must add +# an parameter index as prefix. Like this "transform: 1.choose('OFF','ON')" +# If the index is not set then the default index 0 is used. +# +# map(fromLow,fromHigh,toLow,toHigh) - interpolate re-maps a parameter value from one range to another. +# Output value type is taken from toHigh. It can be int or float. +# +# choose(e1,e2) - boolean chooser, converts the value of the parameter to the boolean type (0 and 1), +# and selects the corresponding value by the index from the list. +# +# choose(e1,e2,...) - int chooser, converts the value of the parameter to the int type +# and selects the corresponding value by the index from the list. +# +# choose({key:value,..}) - special dictionary chooser, parameter value cast type by first key type. +# Selects the corresponding value by the key from the dictionary. +# +# int(), float(), bool(), str(), abs(), bin(), hex(), oct(), days(), hours(), minutes(), seconds() +# These will convert parameter value to the special form. +# int,float,bool,str,abs,bin,hex and oct are python functions. +# days,hours,minutes,seconds will convert parameter value (it's taken as seconds) to time specific value +# +# scale(xx) - Multiplies parameter value by this xx. Pure interger or float value is excpected. + + +#[menu command1] +#type:command +#name: +#cursor: +#width: +#scroll: +#enable: +#parameter: +#transform: +#gcode: +# When menu element is clicked then gcodes on this attribute will be executed. +# Can have multiline gcode script and supports output formatting for parameter and transform values. +#action: +# Special action can be executed. Supports [back, exit] menu commands +# and [respond response_info] command. Respond command will send '// response_info' to host. + +#[menu input1] +#name: +#cursor: +#width: +#enable: +#transform: +#parameter: +# Value from parameter (always index 0) is taken as input value when in edit mode. +#gcode: +# This will be triggered in realtime or on exit from edit mode. +#reverse: +# This attribute accepts static boolean value. +# When enabled it will reverse increment and decrement directions for input. +# The default is False. This parameter is optional. +#readonly: +# This attribute accepts same logical expression as 'enable'. +# When true then input element is readonly like 'item' and cannot enter to edit mode. +# The default is False. This parameter is optional. +#realtime: +# This attribute accepts static boolean value. +# When enabled it will execute gcode after each value change. +# The default is False. This parameter is optional. +#input_min: +# It accepts integer or float value. Will set minimal bound for edit value. +# The default is 2.2250738585072014e-308. This parameter is optional. +#input_max: +# It accepts integer or float value. Will set maximal bound for edit value. +# The default is 1.7976931348623157e+308. This parameter is optional. +#input_step: +# This is mandatory attribute for input. +# It accepts positive integer or float value. Will determine increment +# and decrement steps for edit value. + +#[menu list1] +#type:list or vsdcard +#name: +#cursor: +#width: +#scroll: +#enable: +#enter_gcode: +# Will trigger gcode script when entering to this menu container. +# This parameter is optional. +#leave_gcode: +# Will trigger gcode script when leaving from this menu container. +# This parameter is optional. +#show_back: +# This attribute accepts static boolean value. +# Show back [..] as first element. +# The default is True. This parameter is optional. +#show_title: +# This attribute accepts static boolean value. +# Show container name next to back [..] element. +# The default is True. This parameter is optional. +#items: +# Menu elements listed in this container. +# Each element must be on separate line. +# Elements can be grouped on same line by separating them with comma +# +# When element name stars with . then menu system will add parent +# container config name as prefix to element name (delimited by space) + +#[menu infodeck] +#type: deck +#name: +#cursor: +#width: +#scroll: +#enable: +#enter_gcode +#leave_gcode +#longpress_menu: +# Entry point to menu container. When this attribute is set then +# long press > 1s will initiate this menu container if not in edit mode. +# The default is disabled. This parameter is optional. +#items: +# It accepts only 'card' elements. You are able to switch between different card screens +# by using encoder or up/down buttons. + +#[menu card1] +#type: card +#name: +#content: +# Card screen content. Each line represents display line. +# Quotes can be used in the beginning and end of line. +# Rendered elements are available for output formatting as {0}..{x}. It's always string type. +#items: +# List of elements in card. Each line represents a single index for content formatting. +# It's possible to show multiple elements in one place by separating them with comma on single line. +# If first element is integer then timed cycle is used (integer value is cycle time in seconds) +# If no integer element then first enabled element is shown. +# In cycler multiple elements can be grouped into one postition by separating them with | +# This way only simple menu items can be grouped. +# Example: 5,prt_time, prt_progress - elements prt_time and prt_progress are switched after 5s +# Example: msg,xpos|ypos - elements xpos and ypos are grouped and showed together when msg is disabled. diff --git a/klippy/extras/display/display.py b/klippy/extras/display/display.py index 80a4515c..607f23e5 100644 --- a/klippy/extras/display/display.py +++ b/klippy/extras/display/display.py @@ -7,6 +7,7 @@ # This file may be distributed under the terms of the GNU GPLv3 license. import logging import hd44780, st7920, uc1701, icons +import menu LCD_chips = { 'st7920': st7920.ST7920, 'hd44780': hd44780.HD44780, 'uc1701' : uc1701.UC1701 } M73_TIMEOUT = 5. @@ -17,6 +18,8 @@ class PrinterLCD: self.reactor = self.printer.get_reactor() self.lcd_chip = config.getchoice('lcd_type', LCD_chips)(config) self.lcd_type = config.get('lcd_type') + # menu + self.menu = menu.MenuManager(config, self.lcd_chip) # printer objects self.gcode = self.toolhead = self.sdcard = None self.fan = self.extruder0 = self.extruder1 = self.heater_bed = None @@ -89,6 +92,11 @@ class PrinterLCD: self.lcd_chip.write_graphics(x, y, 15, [0xff]*width) # Screen updating def screen_update_event(self, eventtime): + # update menu component + ret = self.menu.screen_update_event(eventtime) + if ret: + return ret + # update all else self.lcd_chip.clear() if self.lcd_type == 'hd44780': self.screen_update_hd44780(eventtime) diff --git a/klippy/extras/display/menu.cfg b/klippy/extras/display/menu.cfg new file mode 100644 index 00000000..c944ac15 --- /dev/null +++ b/klippy/extras/display/menu.cfg @@ -0,0 +1,933 @@ +# This file serves as default menu structure. +# See the "example-menu.cfg" file for description of common config parameters. + +### DEFAULT MENU ### + +### menu main ### +[menu __main] +type: list +name: Main Menu +items: + __octoprint + __sdcard + __control + __temp + __filament + __prepare + __test + +### menu octoprint ### +[menu __octoprint] +type: list +name: OctoPrint +items: + .__pause + .__resume + .__abort + +[menu __octoprint __pause] +type: command +enable: toolhead.is_printing +name: Pause printing +action: respond action:pause +gcode: + +[menu __octoprint __resume] +type: command +enable: toolhead.is_printing +name: Resume printing +action: respond action:resume +gcode: + +[menu __octoprint __abort] +type: command +enable: toolhead.is_printing +name: Abort printing +action: respond action:cancel +gcode: + +### menu virtual sdcard ### +[menu __sdcard] +type: vsdcard +name: SD Card +items: + .__start + .__resume + .__pause + +[menu __sdcard __start] +type: command +enable: !toolhead.is_printing +name: Start printing +gcode: M24 + +[menu __sdcard __resume] +type: command +enable: toolhead.is_printing +name: Resume printing +gcode: M24 + +[menu __sdcard __pause] +type: command +enable: toolhead.is_printing +name: Pause printing +gcode: M25 + +### menu control ### +[menu __control] +type: list +name: Control +items: + .__home + .__homez + .__homexy + .__move_10mm + .__move_1mm + .__move_01mm + .__disable + .__fanonoff + .__fanspeed + .__caselightonoff + .__caselightpwm + +[menu __control __home] +type: command +name: Home All +gcode: G28 +enable: !toolhead.is_printing + +[menu __control __homez] +type: command +enable: !toolhead.is_printing +name: Home Z +gcode: G28 Z + +[menu __control __homexy] +type: command +enable: !toolhead.is_printing +name: Home X/Y +gcode: G28 X Y + +[menu __control __disable] +type: command +name: Disable steppers +gcode: + M84 + M18 + +[menu __control __fanonoff] +type: input +enable: fan.is_enabled +name: Fan {1:3s} +parameter: fan.speed +transform: + choose('OFF','ON') + choose(0,255) +input_min: 0 +input_max: 1 +input_step: 1 +gcode: M106 S{2:d} + +[menu __control __fanspeed] +type: input +enable: fan.is_enabled +name: Fan speed: {1:3d}% +parameter: fan.speed +transform: + map(0,1,0,100) + map(0,1,0,255) +input_min: 0 +input_max: 1 +input_step: 0.01 +gcode: M106 S{2:d} + +[menu __control __caselightonoff] +type: input +enable: output_pin.caselight.is_enabled +name: Case light: {1:3s} +parameter: output_pin.caselight.value +transform: + choose('OFF','ON') + choose(0,1) +input_min: 0 +input_max: 1 +input_step: 1 +gcode: SET_PIN PIN=caselight VALUE={2} + +[menu __control __caselightpwm] +type: input +enable: output_pin.caselight.is_enabled +name: Case light: {0:4.0%} +parameter: output_pin.caselight.value +input_min: 0.0 +input_max: 1.0 +input_step: 0.01 +gcode: SET_PIN PIN=caselight VALUE={0:.2f} + +### menu move 10mm ### +[menu __control __move_10mm] +type: list +enable: !toolhead.is_printing +name: Move 10mm +items: + .__axis_z + .__axis_x, .__axis_y + .__axis_e + +[menu __control __move_10mm __axis_x] +type: input +name: "X:{0:05.1f} " +parameter: toolhead.xpos +input_min: 0 +input_max: 200.0 +input_step: 10.0 +gcode: G1 X{0:.1f} + +[menu __control __move_10mm __axis_y] +type: input +name: "Y:{0:05.1f} " +parameter: toolhead.ypos +input_min: 0 +input_max: 200.0 +input_step: 10.0 +gcode: G1 Y{0:.1f} + +[menu __control __move_10mm __axis_z] +type: input +enable: !toolhead.is_printing +name: "Move Z:{0:05.1f}" +parameter: toolhead.zpos +input_min: 0 +input_max: 200.0 +input_step: 10.0 +gcode: G1 Z{0:.1f} + +[menu __control __move_10mm __axis_e] +type: input +enable: !toolhead.is_printing +name: "Move E:{0:+06.1f}" +parameter: 0 +input_min: -250.0 +input_max: 250.0 +input_step: 10.0 +gcode: G1 E{0:.1f} F240 + +### menu move 1mm ### +[menu __control __move_1mm] +type: list +enable: !toolhead.is_printing +name: Move 1mm +items: + .__axis_z + .__axis_x, .__axis_y + .__axis_e + +[menu __control __move_1mm __axis_x] +type: input +name: "X:{0:05.1f} " +parameter: toolhead.xpos +input_min: 0 +input_max: 100.0 +input_step: 1.0 +gcode: G1 X{0:.1f} + +[menu __control __move_1mm __axis_y] +type: input +name: "Y:{0:05.1f} " +parameter: toolhead.ypos +input_min: 0 +input_max: 100.0 +input_step: 1.0 +gcode: G1 Y{0:.1f} + +[menu __control __move_1mm __axis_z] +type: input +enable: !toolhead.is_printing +name: "Move Z:{0:05.1f}" +parameter: toolhead.zpos +input_min: 0 +input_max: 100.0 +input_step: 1.0 +gcode: G1 Z{0:.1f} + +[menu __control __move_1mm __axis_e] +type: input +enable: !toolhead.is_printing +name: "Move E:{0:+06.1f}" +parameter: 0 +input_min: -100.0 +input_max: 100.0 +input_step: 1.0 +gcode: G1 E{0:.1f} F240 + +### menu move 0.1mm ### +[menu __control __move_01mm] +type: list +enable: !toolhead.is_printing +name: Move 0.1mm +items: + .__axis_z + .__axis_x, .__axis_y + .__axis_e + +[menu __control __move_01mm __axis_x] +type: input +name: "X:{0:05.1f} " +parameter: toolhead.xpos +input_min: 0 +input_max: 50.0 +input_step: 0.1 +gcode: G1 X{0:.1f} + +[menu __control __move_01mm __axis_y] +type: input +name: "Y:{0:05.1f} " +parameter: toolhead.ypos +input_min: 0 +input_max: 50.0 +input_step: 0.1 +gcode: G1 Y{0:.1f} + +[menu __control __move_01mm __axis_z] +type: input +enable: !toolhead.is_printing +name: "Move Z:{0:05.1f}" +parameter: toolhead.zpos +input_min: 0 +input_max: 50.0 +input_step: 0.1 +gcode: G1 Z{0:.1f} + +[menu __control __move_01mm __axis_e] +type: input +enable: !toolhead.is_printing +name: "Move E:{0:+06.1f}" +parameter: 0 +input_min: -50.0 +input_max: 50.0 +input_step: 0.1 +gcode: G1 E{0:.1f} F240 + +### menu temperature ### +[menu __temp] +type: list +name: Temperature +items: + .__hotend0_current, .__hotend0_target + .__hotend1_current, .__hotend1_target + .__hotbed_current, .__hotbed_target + .__preheat_pla + .__preheat_abs + .__cooldown + +[menu __temp __hotend0_current] +type: item +enable: extruder0.is_enabled +name: "Ex0:{0:4.0f} T" +parameter: extruder0.temperature + +[menu __temp __hotend0_target] +type: input +enable: extruder0.is_enabled +name: "{0:4.0f}" +parameter: extruder0.target +input_min: 0 +input_max: 250 +input_step: 1 +gcode: M104 T0 S{0:.0f} + +[menu __temp __hotend1_current] +type: item +enable: extruder1.is_enabled +name: "Ex1:{0:4.0f} T" +parameter: extruder1.temperature + +[menu __temp __hotend1_target] +type: input +enable: extruder1.is_enabled +name: "{0:4.0f}" +parameter: extruder1.target +input_min: 0 +input_max: 250 +input_step: 1 +gcode: M104 T1 S{0:.0f} + +[menu __temp __hotbed_current] +type: item +enable: heater_bed.is_enabled +name: "Bed:{0:4.0f} T" +parameter: heater_bed.temperature + +[menu __temp __hotbed_target] +type: input +enable: heater_bed.is_enabled +name: "{0:4.0f}" +parameter: heater_bed.target +input_min: 0 +input_max: 130 +input_step: 1 +gcode: M140 S{0:.0f} + +[menu __temp __preheat_pla] +type: list +name: Preheat PLA +items: + .__all + .__hotend + .__hotbed + +[menu __temp __preheat_pla __all] +type: command +enable: extruder0.is_enabled,heater_bed.is_enabled +name: Preheat all +gcode: + M140 S60 + M104 S200 + +[menu __temp __preheat_pla __hotend] +type: command +enable: extruder0.is_enabled +name: Preheat hotend +gcode: M104 S200 + +[menu __temp __preheat_pla __hotbed] +type: command +enable: heater_bed.is_enabled +name: Preheat hotbed +gcode: M140 S60 + +[menu __temp __preheat_abs] +type: list +name: Preheat ABS +items: + .__all + .__hotend + .__hotbed + +[menu __temp __preheat_abs __all] +type: command +enable: extruder0.is_enabled,heater_bed.is_enabled +name: Preheat all +gcode: + M140 S110 + M104 S245 + +[menu __temp __preheat_abs __hotend] +type: command +enable: extruder0.is_enabled +name: Preheat hotend +gcode: M104 S245 + +[menu __temp __preheat_abs __hotbed] +type: command +enable: heater_bed.is_enabled +name: Preheat hotbed +gcode: M140 S110 + +[menu __temp __cooldown] +type: list +name: Cooldown +items: + .__all + .__hotend + .__hotbed + +[menu __temp __cooldown __all] +type: command +enable: extruder0.is_enabled,heater_bed.is_enabled +name: Cooldown all +gcode: + M104 S0 + M140 S0 + +[menu __temp __cooldown __hotend] +type: command +enable: extruder0.is_enabled +name: Cooldown hotend +gcode: M104 S0 + +[menu __temp __cooldown __hotbed] +type: command +enable: heater_bed.is_enabled +name: Cooldown hotbed +gcode: M140 S0 + +### menu filament ### + +[menu __filament] +type: list +name: Filament +items: + __temp __hotend0_current, __temp __hotend0_target + .__unload + .__load + .__feed + +[menu __filament __load] +type: command +name: Load Filament +gcode: + G1 E200 F1000 + G1 E100 F300 + +[menu __filament __unload] +type: command +name: Unload Filament +gcode: + G1 E-200 F1000 + G1 E-200 F1800 + +[menu __filament __feed] +type: input +name: Feed Filament: {0:.1f} +parameter: toolhead.epos +input_step: 0.1 +gcode: G1 E{0:.1f} F30 + +### menu prepare ### +[menu __prepare] +type: list +enable: !toolhead.is_printing +name: Prepare +items: + .__delta_calib + .__bedprobe + .__hotend_pid_tuning + .__hotbed_pid_tuning + .__host_restart + .__firmware_restart + +[menu __prepare __host_restart] +type: command +enable: !toolhead.is_printing +name: Restart host +gcode: RESTART + +[menu __prepare __firmware_restart] +type: command +enable: !toolhead.is_printing +name: Restart FW +gcode: FIRMWARE_RESTART + +[menu __prepare __delta_calib] +type: command +enable: !toolhead.is_printing +name: Delta calibrate +gcode: DELTA_CALIBRATE + +[menu __prepare __bedprobe] +type: command +enable: !toolhead.is_printing +name: Bed probe +gcode: PROBE + +[menu __prepare __hotend_pid_tuning] +type: command +enable: !toolhead.is_printing, extruder0.is_enabled +name: Tune Hotend PID +gcode: PID_CALIBRATE HEATER=extruder TARGET=210 WRITE_FILE=1 + +[menu __prepare __hotbed_pid_tuning] +type: command +enable: !toolhead.is_printing, heater_bed.is_enabled +name: Tune Hotbed PID +gcode: PID_CALIBRATE HEATER=heater_bed TARGET=60 WRITE_FILE=1 + +### test menu ### +[menu __test] +type: list +name: Test menu +items: + .__scroll + .__dump + +[menu __test __scroll] +type: item +name: This is very long item name +width: 18 +scroll: true + +[menu __test __dump] +type: command +name: Dump parameters +gcode: MENU DO=dump + +### info screens ### +[menu __screen2004_static] +type: deck +name: Deck (static) +longpress_menu: __main +items: __card2004_static + +[menu __screen2004_input] +type: deck +name: Deck (input) +longpress_menu: __main +items: __card2004_input + +[menu __screen1604_static] +type: deck +name: Deck (static) +longpress_menu: __main +items: __card1604_static + +[menu __screen1604_input] +type: deck +name: Deck (input) +longpress_menu: __main +items: __card1604_input + +[menu __screen_test] +type: deck +name: Deck (test) +longpress_menu: __main +items: __card1, __card2, __card3 + +### deck cards ### + +### special characters for hd44780 +# \x00 - thermometer +# \x01 - heater_bed +# \x02 - speed_factor +# \x03 - clock +# \x04 - degrees +# \x05 - usb +# \x06 - sdcard +# \x7e - right arrow + +[menu __card2004_static] +type: card +name: Card 20x04 +content: + "\x00{0:3s}\x04\x7e{1:3s}\x04 {2}" + "\x01{3:3s}\x04\x7e{4:3s}\x04 \xa5{8}" + "fn{6} \x02{5} {7}" + "{9}" +items: + __card_hotend0_current + __card_hotend0_target + __card_zpos + __card_hotbed_current + __card_hotbed_target + __card_frpeed + __card_fnspeed + 5,__card_prt_time, __card_usb_progress, __card_sdcard_progress + __card_status + __card_msg,__card_xpos|__card_ypos|__card_epos + +[menu __card1604_static] +type: card +name: Card 16x04 +content: + "E{0:3s}/{1:3s} {2}" + "B{3:3s}/{4:3s} fr{5}" + "fn{6} {7}" + "[{8}] {9}" +items: + __card_hotend0_current + __card_hotend0_target + __card_zpos + __card_hotbed_current + __card_hotbed_target + __card_frpeed + __card_fnspeed + 5,__card_prt_time_2, __card_usb_progress_2, __card_sdcard_progress_2 + __card_status_slim + __card_msg_slim,__card_xpos|__card_ypos + +[menu __card2004_input] +type: card +name: Card 20x04 +content: + "\x00{0:3s}\x04\x7e{1:3s}\x04 {2}" + "\x01{3:3s}\x04\x7e{4:3s}\x04 \xa5{8}" + "fn{6} \x02{5} {7}" + "{9}" +items: + __card_hotend0_current + __card_hotend0_target_in + __card_zpos + __card_hotbed_current + __card_hotbed_target_in + __card_frpeed_in + __card_fnspeed + 5,__card_prt_time, __card_usb_progress, __card_sdcard_progress + __card_status + __card_msg,__card_xpos|__card_ypos|__card_epos + +[menu __card1604_input] +type: card +name: Card 16x04 +content: + "E{0:3s}/{1:3s} {2}" + "B{3:3s}/{4:3s} fr{5}" + "fn{6} {7}" + "[{8}] {9}" +items: + __card_hotend0_current + __card_hotend0_target_in + __card_zpos + __card_hotbed_current + __card_hotbed_target_in + __card_frpeed_in + __card_fnspeed + 5,__card_prt_time_2, __card_usb_progress_2, __card_sdcard_progress_2 + __card_status_slim + __card_msg,__card_xpos|__card_ypos + +[menu __card1] +type: card +name: Card 1 +content: + "E0:{0:3s}/{1:3s} B:{4:3s}/{5:3s}" + "E1:{2:3s}/{3:3s} {6}" + "Fr{7}Fn{8} {9}" + "{10}" + +items: + __card_hotend0_current + __card_hotend0_target + __card_hotend1_current + __card_hotend1_target + __card_hotbed_current + __card_hotbed_target + __card_zpos + __card_frpeed_in + __card_fnspeed_in + 5,__card_prt_time, __card_usb_progress, __card_sdcard_progress + __card_msg + +[menu __card2] +type: card +name: Card 2 +content: + "E:{0:3s}/{1:3s} B:{2:3s}/{3:3s}" + "{4}{5}{6}" + "Fr{7} {8} T{9}" + "{10}" +items: + __card_hotend0_current + __card_hotend0_target + __card_hotbed_current + __card_hotbed_target + __card_xpos + __card_ypos + __card_zpos + __card_frpeed_in + __card_usb_progress_2, __card_sdcard_progress_2 + __card_prt_time_2 + __card_msg + +[menu __card3] +type: card +name: Card 3 +content: + "Example card :)" + "Try this menu." + "({0})" +items: + __test + +### card items ### + +[menu __card_hotend0_current] +type: item +enable: extruder0.is_enabled +name: "{1:3.0f}" +parameter: extruder0.temperature +transform: abs() + +[menu __card_hotend0_target] +type: item +enable: extruder0.is_enabled +name: "{1:3.0f}" +parameter: extruder0.target +transform: abs() + +[menu __card_hotend0_target_in] +type: input +enable: extruder0.is_enabled +name: "{1:3.0f}" +parameter: extruder0.target +transform: abs() +input_min: 0 +input_max: 250 +input_step: 1 +gcode: M104 T0 S{0:.0f} + +[menu __card_hotend1_current] +type: item +enable: extruder1.is_enabled +name: "{1:3.0f}" +parameter: extruder1.temperature +transform: abs() + +[menu __card_hotend1_target] +type: item +enable: extruder1.is_enabled +name: "{1:3.0f}" +parameter: extruder1.target +transform: abs() + +[menu __card_hotend1_target_in] +type: input +enable: extruder1.is_enabled +name: "{1:3.0f}" +parameter: extruder1.target +transform: abs() +input_min: 0 +input_max: 250 +input_step: 1 +gcode: M104 T1 S{0:.0f} + +[menu __card_hotbed_current] +type: item +enable: heater_bed.is_enabled +name: "{1:3.0f}" +parameter: heater_bed.temperature +transform: abs() + +[menu __card_hotbed_target] +type: item +enable: heater_bed.is_enabled +name: "{1:3.0f}" +parameter: heater_bed.target +transform: abs() + +[menu __card_hotbed_target_in] +type: input +enable: heater_bed.is_enabled +name: "{1:3.0f}" +parameter: heater_bed.target +transform: abs() +input_min: 0 +input_max: 250 +input_step: 1 +gcode: M140 S{0:.0f} + +[menu __card_zpos] +type: item +name: "Z{0:06.2f}" +parameter: toolhead.zpos + +[menu __card_xpos] +type: item +name: "X{0:05.1f}{2}" +parameter: toolhead.xpos, menu.is20xx +transform: + 1.choose('',' ') + +[menu __card_ypos] +type: item +name: "Y{0:05.1f}{2}" +parameter: toolhead.ypos, menu.is20xx +transform: + 1.choose('',' ') + +[menu __card_epos] +type: item +name: "E{0:05.1f}{2}" +parameter: toolhead.epos, menu.is20xx +transform: + 1.choose('',' ') + +[menu __card_frpeed] +type: item +name: "{0:4.0%}" +parameter: gcode.speed_factor + +[menu __card_fnspeed] +type: item +name: "{0:4.0%}" +parameter: fan.speed + +[menu __card_frpeed_in] +type: input +name: "{0:4.0%}" +parameter: gcode.speed_factor +transform: + map(0,1,0,255) +input_min: 0 +input_max: 1 +input_step: 0.01 +gcode: M220 S{1:d} + +[menu __card_fnspeed_in] +type: input +enable: fan.is_enabled +name: "{0:4.0%}" +parameter: fan.speed +transform: + map(0,1,0,255) +input_min: 0 +input_max: 1 +input_step: 0.01 +gcode: M106 S{1:d} + +[menu __card_prt_time] +type: item +name: "\x03{1:02d}:{2:02d}" +transform: + minutes() + seconds() +parameter: toolhead.printing_time + +[menu __card_prt_time_2] +type: item +name: "T{1:02d}:{2:02d}" +transform: + minutes() + seconds() +parameter: toolhead.printing_time + +[menu __card_usb_progress] +type: item +enable: !virtual_sdcard.progress, display.progress +name: "\x05{0:3.0f}%" +parameter: display.progress + +[menu __card_sdcard_progress] +type: item +enable: virtual_sdcard.is_enabled, virtual_sdcard.progress +name: "\x06{0:3.0f}%" +parameter: virtual_sdcard.progress + +[menu __card_usb_progress_2] +type: item +#enable: !virtual_sdcard.progress,display.progress +name: "pr{0:3.0f}%" +parameter: display.progress + +[menu __card_sdcard_progress_2] +type: item +enable: virtual_sdcard.is_enabled, virtual_sdcard.progress +name: "pr{0:3.0f}%" +parameter: virtual_sdcard.progress + +[menu __card_msg] +enable: display.message +type: item +scroll: true +width: 20 +name: "{0}" +parameter: display.message + +[menu __card_msg_slim] +enable: display.message +type: item +scroll: true +width: 12 +name: "{0}" +parameter: display.message + +[menu __card_status] +type: item +name: "{0}" +width: 5 +parameter: toolhead.status + +[menu __card_status_slim] +type: item +name: "{0}" +width: 1 +parameter: toolhead.status diff --git a/klippy/extras/display/menu.py b/klippy/extras/display/menu.py new file mode 100644 index 00000000..da7de43f --- /dev/null +++ b/klippy/extras/display/menu.py @@ -0,0 +1,1437 @@ +# -*- coding: utf-8 -*- +# Basic LCD menu support +# +# Based on the RaspberryPiLcdMenu from Alan Aufderheide, February 2013 +# Copyright (C) 2018 Janar Sööt +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import os, ConfigParser, logging +import sys, ast, re +import klippy + + +class error(Exception): + pass + + +# static class for cursor +class MenuCursor: + NONE = ' ' + SELECT = '>' + EDIT = '*' + + +# Menu element baseclass +class MenuElement(object): + def __init__(self, manager, config, namespace=''): + self.cursor = config.get('cursor', MenuCursor.SELECT) + self._namespace = namespace + self._manager = manager + self._width = self._asint(config.get('width', '0')) + self._scroll = self._asbool(config.get('scroll', 'false')) + self._enable = self._aslist(config.get('enable', 'true'), + flatten=False) + self._name = self._asliteral(config.get('name')) + self.__scroll_offs = 0 + self.__scroll_diff = 0 + self.__scroll_dir = None + self.__last_state = True + if len(self.cursor) < 1: + raise error("Cursor with unexpected length, expecting 1.") + + # override + def _render(self): + return self._name + + # override + def _second_tick(self, eventtime): + pass + + # override + def is_editing(self): + return False + + # override + def is_readonly(self): + return True + + # override + def is_scrollable(self): + return True + + # override + def is_enabled(self): + return self._parse_bool(self._enable) + + def init(self): + self.__clear_scroll() + + def heartbeat(self, eventtime): + state = bool(int(eventtime) & 1) + if self.__last_state ^ state: + self.__last_state = state + if not self.is_editing(): + self._second_tick(eventtime) + self.__update_scroll(eventtime) + + def __clear_scroll(self): + self.__scroll_dir = None + self.__scroll_diff = 0 + self.__scroll_offs = 0 + + def __update_scroll(self, eventtime): + if self.__scroll_dir == 0 and self.__scroll_diff > 0: + self.__scroll_dir = 1 + self.__scroll_offs = 0 + elif self.__scroll_dir and self.__scroll_diff > 0: + self.__scroll_offs += self.__scroll_dir + if self.__scroll_offs >= self.__scroll_diff: + self.__scroll_dir = -1 + elif self.__scroll_offs <= 0: + self.__scroll_dir = 1 + else: + self.__clear_scroll() + + def __render_scroll(self, s): + if self.__scroll_dir is None: + self.__scroll_dir = 0 + self.__scroll_offs = 0 + return s[ + self.__scroll_offs:self._width + self.__scroll_offs + ].ljust(self._width) + + def render(self, scroll=False): + s = str(self._render()) + if self._width > 0: + self.__scroll_diff = len(s) - self._width + if (scroll and self._scroll is True and self.is_scrollable() + and self.__scroll_diff > 0): + s = self.__render_scroll(s) + else: + self.__clear_scroll() + s = s[:self._width].ljust(self._width) + else: + self.__clear_scroll() + return s + + def _parse_bool(self, lst): + try: + return any([ + all([ + self._lookup_bool(l2) for l2 in self._words_aslist(l1) + ]) for l1 in lst + ]) + except Exception: + logging.exception("Boolean parsing error") + return False + + def _lookup_bool(self, b): + if not self._asbool(b): + if b[0] == '!': # logical negation: + return not (not not self._lookup_parameter(b[1:])) + else: + return not not self._lookup_parameter(b) + return True + + def _lookup_parameter(self, literal): + if self._isfloat(literal): + return float(literal) + else: + # only 2 level dot notation + keys = literal.rsplit('.', 1) + name = keys[0] if keys[0:1] else None + attr = keys[1] if keys[1:2] else None + if isinstance(self._manager.parameters, dict): + return (self._manager.parameters.get(name) or {}).get(attr) + else: + logging.error("Parameter storage is not dictionary") + return None + + def _asliteral(self, s): + s = str(s).strip() + if s.startswith(('"', "'")): + s = s[1:] + if s.endswith(('"', "'")): + s = s[:-1] + return s + + def _asbool(self, s, default=False): + if s is None: + return default + if isinstance(s, bool): + return s + s = str(s).strip() + return s.lower() in ('y', 'yes', 't', 'true', 'on', '1') + + def _asint(self, s, default=0): + if s is None: + return default + if isinstance(s, (int, float)): + return int(s) + s = str(s).strip() + return int(float(s)) if self._isfloat(s) else int(default) + + def _asfloat(self, s, default=0.0): + if s is None: + return default + if isinstance(s, (int, float)): + return float(s) + s = str(s).strip() + return float(s) if self._isfloat(s) else float(default) + + def _lines_aslist(self, value, default=[]): + if isinstance(value, str): + value = filter(None, [x.strip() for x in value.splitlines()]) + try: + return list(value) + except Exception: + logging.exception("Lines as list parsing error") + return list(default) + + def _words_aslist(self, value, sep=',', default=[]): + if isinstance(value, str): + value = filter(None, [x.strip() for x in value.split(sep)]) + try: + return list(value) + except Exception: + logging.exception("Words as list parsing error") + return list(default) + + def _aslist(self, value, flatten=True, default=[]): + values = self._lines_aslist(value) + if not flatten: + return values + result = [] + for value in values: + subvalues = self._words_aslist(value, sep=',') + result.extend(subvalues) + return result + + def _isfloat(self, value): + try: + float(value) + return True + except ValueError: + return False + + @property + def namespace(self): + return self._namespace + + @namespace.setter + def namespace(self, ns): + self._namespace = ns + + +# menu container baseclass +class MenuContainer(MenuElement): + def __init__(self, manager, config, namespace=''): + super(MenuContainer, self).__init__(manager, config, namespace) + self._show_back = self._asbool(config.get('show_back', 'true')) + self._show_title = self._asbool(config.get('show_title', 'true')) + self._allitems = [] + self._items = [] + # recursive guard + self._parents = [] + + # overload + def _names_aslist(self): + return [] + + # overload + def is_accepted(self, item): + return isinstance(item, MenuElement) + + def is_readonly(self): + return False + + def is_editing(self): + return any([item.is_editing() for item in self._items]) + + def _lookup_item(self, item): + if isinstance(item, str): + s = item.strip() + if s.startswith('.'): + s = ' '.join([self.namespace, s[1:]]) + item = self._manager.lookup_menuitem(s) + return item + + def find_item(self, item): + index = None + if item in self._items: + index = self._items.index(item) + else: + for con in self._items: + if isinstance(con, MenuContainer) and item in con: + index = self._items.index(con) + return index + + def add_parents(self, parents): + if isinstance(parents, list): + self._parents.extend(parents) + else: + self._parents.append(parents) + + def assert_recursive_relation(self, parents=None): + assert self not in (parents or self._parents), \ + "Recursive relation of '%s' container" % (self.namespace,) + + def append_item(self, s): + item = self._lookup_item(s) + if item is not None: + if not self.is_accepted(item): + raise error("Menu item '%s'is not accepted!" % str(type(item))) + if isinstance(item, (MenuContainer)): + item.add_parents(self._parents) + item.add_parents(self) + item.assert_recursive_relation() + item.populate_items() + self._allitems.append(item) + + def populate_items(self): + self._allitems = [] # empty list + if self._show_back is True: + name = '[..]' + if self._show_title: + name += ' %s' % str(self._name) + self.append_item(MenuCommand(self._manager, { + 'name': name, 'gcode': '', 'action': 'back'}, self.namespace)) + for name in self._names_aslist(): + self.append_item(name) + self.update_items() + + def update_items(self): + self._items = [item for item in self._allitems if item.is_enabled()] + + def __iter__(self): + return iter(self._items) + + def __len__(self): + return len(self._items) + + def __getitem__(self, key): + return self._items[key] + + +class MenuItem(MenuElement): + def __init__(self, manager, config, namespace=''): + super(MenuItem, self).__init__(manager, config, namespace) + self.parameter = config.get('parameter', '') + self.transform = config.get('transform', '') + + def _parse_transform(self, t): + flist = { + 'int': int, + 'float': float, + 'bool': bool, + 'str': str, + 'abs': abs, + 'bin': bin, + 'hex': hex, + 'oct': oct + } + + def mapper(left_min, left_max, right_min, right_max, cast_fn, index=0): + # interpolate + left_span = left_max - left_min + right_span = right_max - right_min + scale_factor = float(right_span) / float(left_span) + + def map_fn(values): + return cast_fn( + right_min + (values[index] - left_min) * scale_factor + ) + return map_fn + + def scaler(scale_factor, cast_fn, index=0): + def scale_fn(values): + return cast_fn(values[index] * scale_factor) + return scale_fn + + def chooser(choices, cast_fn, index=0): + def choose_fn(values): + return choices[cast_fn(values[index])] + return choose_fn + + def timerizer(key, index=0): + time = {} + + def time_fn(values): + try: + seconds = int(values[index]) + except Exception: + logging.exception("Seconds parsing error") + seconds = 0 + + time['days'], time['seconds'] = divmod(seconds, 86400) + time['hours'], time['seconds'] = divmod(time['seconds'], 3600) + time['minutes'], time['seconds'] = divmod(time['seconds'], 60) + + if key in time: + return time[key] + else: + return 0 + return time_fn + + def functionizer(key, index=0): + def func_fn(values): + if key in flist and callable(flist[key]): + return flist[key](values[index]) + else: + logging.error("Unknown function: '%s'" % str(key)) + return values[index] + return func_fn + + fn = None + t = str(t).strip() + # transform: idx.func(a,b,...) + m = re.search(r"^(\d*)(?:\.?)([\S]+)(\(.*\))$", t) + if m is not None: + index = int(m.group(1) or 0) + fname = str(m.group(2)).lower() + try: + o = ast.literal_eval(m.group(3)) + if (fname == 'map' and isinstance(o, tuple) and len(o) == 4 + and isinstance(o[3], (float, int))): + # mapper (interpolate), cast type by last parameter type + fn = mapper(o[0], o[1], o[2], o[3], type(o[3]), index) + elif (fname == 'choose' and isinstance(o, tuple) + and len(o) == 2): + # boolean chooser for 2 size tuple + fn = chooser(o, bool, index) + elif fname == 'choose' and isinstance(o, tuple) and len(o) > 2: + # int chooser for list + fn = chooser(o, int, index) + elif (fname == 'choose' and isinstance(o, dict) and o.keys() + and isinstance(o.keys()[0], (int, float, str))): + # chooser, cast type by first key type + fn = chooser(o, type(o.keys()[0]), index) + elif fname == 'scale' and isinstance(o, (float, int)): + # scaler, cast type depends from scale factor type + fn = scaler(o, type(o), index) + elif fname in ('days', 'hours', 'minutes', 'seconds'): + fn = timerizer(fname, index) + elif fname in flist: + fn = functionizer(fname, index) + else: + logging.error( + "Unknown transform function: '%s'" % str(m.group(0))) + except Exception: + logging.exception("Transform parsing error") + else: + logging.error( + "Invalid transform parameter: '%s'" % str(t)) + return fn + + def _transform_aslist(self): + return list(filter(None, ( + self._parse_transform(t) for t in self._aslist( + self.transform, flatten=False) + ))) + + def _parameter_aslist(self): + lst = [] + for p in self._words_aslist(self.parameter): + lst.append(self._lookup_parameter(p)) + if lst[-1] is None: + logging.error("Parameter '%s' not found" % str(p)) + return list(lst) + + def _prepare_values(self, value=None): + values = [] + for i, v in enumerate(self._parameter_aslist()): + values += [value if i == 0 and value is not None else v] + if values: + try: + values += [t(list(values)) for t in self._transform_aslist()] + except Exception: + logging.exception("Transformation execution failed") + return tuple(values) + + def _get_formatted(self, literal, val=None): + values = self._prepare_values(val) + if isinstance(literal, str) and len(values) > 0: + try: + literal = literal.format(*values) + except Exception: + logging.exception("Literal formatting failed") + return literal + + def _render(self): + return self._get_formatted(self._name) + + +class MenuCommand(MenuItem): + def __init__(self, manager, config, namespace=''): + super(MenuCommand, self).__init__(manager, config, namespace) + self._gcode = config.get('gcode') + self._action = config.get('action', None) + + def is_readonly(self): + return False + + def get_gcode(self): + return self._get_formatted(self._gcode) + + def __call__(self): + if self._action is not None: + try: + fmt = self._get_formatted(self._action) + args = fmt.split() + self._manager.run_action(args[0], *args[1:]) + except Exception: + logging.exception("Action formatting failed") + + +class MenuInput(MenuCommand): + def __init__(self, manager, config, namespace=''): + super(MenuInput, self).__init__(manager, config, namespace) + self._reverse = self._asbool(config.get('reverse', 'false')) + self._realtime = self._asbool(config.get('realtime', 'false')) + self._readonly = self._aslist( + config.get('readonly', 'false'), flatten=False) + self._input_value = None + self.__last_value = None + self._input_min = config.getfloat('input_min', sys.float_info.min) + self._input_max = config.getfloat('input_max', sys.float_info.max) + self._input_step = config.getfloat('input_step', above=0.) + + def is_scrollable(self): + return False + + def is_readonly(self): + return self._parse_bool(self._readonly) + + def _render(self): + return self._get_formatted(self._name, self._input_value) + + def get_gcode(self): + return self._get_formatted(self._gcode, self._input_value) + + def is_editing(self): + return self._input_value is not None + + def _onchange(self): + self._manager.run_script(self.get_gcode()) + + def init_value(self): + self._input_value = None + self.__last_value = None + if not self.is_readonly(): + args = self._prepare_values() + if len(args) > 0 and self._isfloat(args[0]): + self._input_value = float(args[0]) + if self._realtime: + self._onchange() + else: + logging.error("Cannot init input value") + + def reset_value(self): + self._input_value = None + + def inc_value(self): + last_value = self._input_value + if self._input_value is None: + return + + if(self._reverse is True): + self._input_value -= abs(self._input_step) + else: + self._input_value += abs(self._input_step) + self._input_value = min(self._input_max, max( + self._input_min, self._input_value)) + + if self._realtime and last_value != self._input_value: + self._onchange() + + def dec_value(self): + last_value = self._input_value + if self._input_value is None: + return + + if(self._reverse is True): + self._input_value += abs(self._input_step) + else: + self._input_value -= abs(self._input_step) + self._input_value = min(self._input_max, max( + self._input_min, self._input_value)) + + if self._realtime and last_value != self._input_value: + self._onchange() + + +class MenuGroup(MenuContainer): + def __init__(self, manager, config, namespace='', sep=','): + super(MenuGroup, self).__init__(manager, config, namespace) + self._sep = sep + self._show_back = False + self.selected = None + self.items = config.get('items') + + def is_accepted(self, item): + return (super(MenuGroup, self).is_accepted(item) + and type(item) is not MenuCard) + + def is_scrollable(self): + return False + + def is_enabled(self): + return not not len(self) + + def is_readonly(self): + return all([item.is_readonly() for item in self._items]) + + def _names_aslist(self): + return self._words_aslist(self.items, sep=self._sep) + + def init(self): + super(MenuGroup, self).init() + for item in self._items: + item.init() + + def _render_item(self, item, selected=False, scroll=False): + name = "%s" % str(item.render(scroll)) + if selected and not self.is_editing(): + name = name if self._manager.blink_slow_state else ' '*len(name) + elif selected and self.is_editing(): + name = name if self._manager.blink_fast_state else ' '*len(name) + return name + + def _render(self): + s = "" + if self.selected is not None: + self.selected = ( + (self.selected % len(self)) if len(self) > 0 else None) + + for i, item in enumerate(self): + s += self._render_item(item, (i == self.selected), True) + return s + + def _call_selected(self, method=None): + res = None + if self.selected is not None: + try: + if method is None: + res = self[self.selected] + else: + res = getattr(self[self.selected], method)() + except Exception: + logging.exception("Call selected error") + return res + + def is_editing(self): + return self._call_selected('is_editing') + + def inc_value(self): + self._call_selected('inc_value') + + def dec_value(self): + self._call_selected('dec_value') + + def selected_item(self): + return self._call_selected() + + def find_next_item(self): + if self.selected is None: + self.selected = 0 + elif self.selected < len(self) - 1: + self.selected += 1 + else: + self.selected = None + # skip readonly + while (self.selected is not None + and self.selected < len(self) + and self._call_selected('is_readonly')): + if self.selected < len(self) - 1: + self.selected = (self.selected + 1) + else: + self.selected = None + return self.selected + + def find_prev_item(self): + if self.selected is None: + self.selected = len(self) - 1 + elif self.selected > 0: + self.selected -= 1 + else: + self.selected = None + # skip readonly + while (self.selected is not None + and self.selected >= 0 + and self._call_selected('is_readonly')): + self.selected = (self.selected - 1) if self.selected > 0 else None + return self.selected + + +class MenuItemGroup(MenuGroup): + def __init__(self, manager, config, namespace='', sep='|'): + super(MenuItemGroup, self).__init__(manager, config, namespace, sep) + + def is_readonly(self): + return True + + def is_accepted(self, item): + return type(item) is MenuItem + + +class MenuCycler(MenuGroup): + def __init__(self, manager, config, namespace='', sep=','): + super(MenuCycler, self).__init__(manager, config, namespace, sep) + self._interval = 0 + self.__interval_cnt = 0 + self.__alllen = 0 + self._curr_idx = 0 + + def is_readonly(self): + return True + + def is_accepted(self, item): + return type(item) in (MenuItem, MenuItemGroup) + + def _lookup_item(self, item): + if isinstance(item, str) and '|' in item: + item = MenuItemGroup(self._manager, { + 'name': ' '.join([self._name, 'ItemGroup']), + 'items': item + }, self.namespace, '|') + elif isinstance(item, str) and item.isdigit(): + try: + self._interval = max(0, int(item)) + except Exception: + logging.exception("Interval parsing error") + item = None + return super(MenuCycler, self)._lookup_item(item) + + def _second_tick(self, eventtime): + super(MenuCycler, self)._second_tick(eventtime) + if self._interval > 0: + self.__interval_cnt = (self.__interval_cnt + 1) % self._interval + if self.__interval_cnt == 0 and self.__alllen > 0: + self._curr_idx = (self._curr_idx + 1) % self.__alllen + else: + self._curr_idx = 0 + + def heartbeat(self, eventtime): + super(MenuCycler, self).heartbeat(eventtime) + for item in self._items: + item.heartbeat(eventtime) + + def update_items(self): + items = [item for item in self._allitems if item.is_enabled()] + self.__alllen = len(items) + if self.__alllen > 0: + self._curr_idx = self._curr_idx % self.__alllen + self._items = [items[self._curr_idx]] + else: + self._curr_idx = 0 + self._items = [] + + +class MenuList(MenuContainer): + def __init__(self, manager, config, namespace=''): + super(MenuList, self).__init__(manager, config, namespace) + self._enter_gcode = config.get('enter_gcode', None) + self._leave_gcode = config.get('leave_gcode', None) + self.items = config.get('items') + + def is_accepted(self, item): + return (super(MenuList, self).is_accepted(item) + and type(item) is not MenuCard) + + def _names_aslist(self): + return self._lines_aslist(self.items) + + def _lookup_item(self, item): + if isinstance(item, str) and ',' in item: + item = MenuGroup(self._manager, { + 'name': ' '.join([self._name, 'Group']), + 'items': item + }, self.namespace, ',') + return super(MenuList, self)._lookup_item(item) + + def update_items(self): + super(MenuList, self).update_items() + for item in self._items: + if isinstance(item, MenuGroup) and not item.is_editing(): + item.update_items() + + def get_enter_gcode(self): + return self._enter_gcode + + def get_leave_gcode(self): + return self._leave_gcode + + +class MenuVSDCard(MenuList): + def __init__(self, manager, config, namespace=''): + super(MenuVSDCard, self).__init__(manager, config, namespace) + + def _populate_files(self): + sdcard = self._manager.objs.get('virtual_sdcard') + if sdcard is not None: + files = sdcard.get_file_list() + for fname, fsize in files: + gcode = [ + 'M23 /%s' % str(fname) + ] + self.append_item(MenuCommand(self._manager, { + 'name': '%s' % str(fname), + 'cursor': '+', + 'gcode': "\n".join(gcode) + })) + + def populate_items(self): + super(MenuVSDCard, self).populate_items() + self._populate_files() + + +class MenuCard(MenuGroup): + def __init__(self, manager, config, namespace=''): + super(MenuCard, self).__init__(manager, config, namespace) + self.content = config.get('content') + + def _names_aslist(self): + return self._lines_aslist(self.items) + + def _content_aslist(self): + return filter(None, [ + self._asliteral(s) for s in self._lines_aslist(self.content) + ]) + + def update_items(self): + self._items = self._allitems[:] + for item in self._items: + if isinstance(item, MenuGroup) and not item.is_editing(): + item.update_items() + + def _lookup_item(self, item): + if isinstance(item, str) and ',' in item: + item = MenuCycler(self._manager, { + 'name': ' '.join([self._name, 'Cycler']), + 'items': item + }, self.namespace, ',') + return super(MenuCard, self)._lookup_item(item) + + def render_content(self, eventtime): + if self.selected is not None: + self.selected = ( + (self.selected % len(self)) if len(self) > 0 else None) + + items = [] + for i, item in enumerate(self): + name = '' + if item.is_enabled(): + item.heartbeat(eventtime) + name = self._render_item(item, (i == self.selected), True) + items.append(name) + lines = [] + for line in self._content_aslist(): + try: + lines.append(str(line).format(*items)) + except Exception: + logging.exception('Card rendering error') + return lines + + def _render(self): + return self._name + + +class MenuDeck(MenuList): + def __init__(self, manager, config, namespace=''): + super(MenuDeck, self).__init__(manager, config, namespace) + self._menu = config.get('longpress_menu', None) + self.menu = None + self._show_back = False + self._show_title = False + + def _populate_menu(self): + self.menu = None + if self._menu is not None: + menu = self._manager.lookup_menuitem(self._menu) + if isinstance(menu, MenuContainer): + menu.assert_recursive_relation(self._parents) + menu.populate_items() + self.menu = menu + + def populate_items(self): + super(MenuDeck, self).populate_items() + self._populate_menu() + + def _names_aslist(self): + return self._aslist(self.items) + + def is_accepted(self, item): + return type(item) is MenuCard + + def _render(self): + return self._name + + +menu_items = { + 'item': MenuItem, + 'command': MenuCommand, + 'input': MenuInput, + 'list': MenuList, + 'vsdcard': MenuVSDCard, + 'deck': MenuDeck, + 'card': MenuCard +} +# Default dimensions for lcds (rows, cols) +LCD_dims = {'st7920': (4, 16), 'hd44780': (4, 20), 'uc1701': (4, 16)} + +MENU_UPDATE_DELAY = .100 +TIMER_DELAY = .200 +BLINK_FAST_SEQUENCE = (True, True, False, False) +BLINK_SLOW_SEQUENCE = (True, True, True, True, False, False, False) + + +class MenuManager: + def __init__(self, config, lcd_chip): + self.running = False + self.menuitems = {} + self.menustack = [] + self._autorun = False + self.top_row = 0 + self.selected = 0 + self.blink_fast_state = True + self.blink_slow_state = True + self.blink_fast_idx = 0 + self.blink_slow_idx = 0 + self.timeout_idx = 0 + self.lcd_chip = lcd_chip + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.parameters = {} + self.objs = {} + self.root = None + self._root = config.get('menu_root', '__main') + dims = config.getchoice('lcd_type', LCD_dims) + self.rows = config.getint('rows', dims[0]) + self.cols = config.getint('cols', dims[1]) + self.timeout = config.getint('menu_timeout', 0) + self.timer = 0 + # buttons + self.encoder_pins = config.get('encoder_pins', None) + self.click_pin = config.get('click_pin', None) + self.back_pin = config.get('back_pin', None) + self.up_pin = config.get('up_pin', None) + self.down_pin = config.get('down_pin', None) + self.kill_pin = config.get('kill_pin', None) + self._last_click_press = 0 + # printer objects + self.buttons = self.printer.try_load_module(config, "buttons") + # register itself for a printer_state callback + config.get_printer().add_object('menu', self) + # register buttons & encoder + if self.buttons: + if self.encoder_pins: + try: + pin1, pin2 = self.encoder_pins.split(',') + except Exception: + raise config.error("Unable to parse encoder_pins") + self.buttons.register_rotary_encoder( + pin1.strip(), pin2.strip(), + self.encoder_cw_callback, self.encoder_ccw_callback) + if self.click_pin: + self.buttons.register_buttons( + [self.click_pin], self.click_callback) + if self.back_pin: + self.buttons.register_button_push( + self.back_pin, self.back_callback) + if self.up_pin: + self.buttons.register_button_push( + self.up_pin, self.up_callback) + if self.down_pin: + self.buttons.register_button_push( + self.down_pin, self.down_callback) + if self.kill_pin: + self.buttons.register_button_push( + self.kill_pin, self.kill_callback) + + # Add MENU commands + self.gcode.register_mux_command("MENU", "DO", 'dump', self.cmd_DO_DUMP, + desc=self.cmd_DO_help) + + # Parse local config file in same directory as current module + fileconfig = ConfigParser.RawConfigParser() + localname = os.path.join(os.path.dirname(__file__), 'menu.cfg') + fileconfig.read(localname) + localconfig = klippy.ConfigWrapper(self.printer, fileconfig, {}, None) + + # Load items from local config + self.load_menuitems(localconfig) + # Load items from main config + self.load_menuitems(config) + + # Load menu root + if self._root is not None: + self.root = self.lookup_menuitem(self._root) + if isinstance(self.root, MenuDeck): + self._autorun = True + + def printer_state(self, state): + if state == 'ready': + # Load all available printer objects + for cfg_name in self.printer.objects: + obj = self.printer.lookup_object(cfg_name, None) + if obj is not None: + name = ".".join(str(cfg_name).split()) + self.objs[name] = obj + logging.debug("Load module '%s' -> %s" % ( + str(name), str(obj.__class__))) + # start timer + reactor = self.printer.get_reactor() + reactor.register_timer(self.timer_event, reactor.NOW) + + def timer_event(self, eventtime): + # take next from sequence + self.blink_fast_idx = ( + (self.blink_fast_idx + 1) % len(BLINK_FAST_SEQUENCE) + ) + self.blink_slow_idx = ( + (self.blink_slow_idx + 1) % len(BLINK_SLOW_SEQUENCE) + ) + self.timeout_idx = (self.timeout_idx + 1) % 5 # 0.2*5 = 1s + self.blink_fast_state = ( + not not BLINK_FAST_SEQUENCE[self.blink_fast_idx] + ) + self.blink_slow_state = ( + not not BLINK_SLOW_SEQUENCE[self.blink_slow_idx] + ) + if self.timeout_idx == 0: + self.timeout_check(eventtime) + + return eventtime + TIMER_DELAY + + def timeout_check(self, eventtime): + # check timeout + if (self.is_running() and self.timeout > 0 + and not self._timeout_autorun_root()): + if self.timer >= self.timeout: + self.exit() + self.timer += 1 + else: + self.timer = 0 + + def _timeout_autorun_root(self): + return (self._autorun is True and self.root is not None + and self.stack_peek() is self.root and self.selected == 0) + + def is_running(self): + return self.running + + def begin(self, eventtime): + self.menustack = [] + self.top_row = 0 + self.selected = 0 + self.timer = 0 + if isinstance(self.root, MenuContainer): + self.update_parameters(eventtime) + self.root.populate_items() + self.stack_push(self.root) + self.running = True + return + elif self.root is not None: + logging.error("Invalid root '%s', menu stopped!" % str(self._root)) + + self.running = False + + def get_status(self, eventtime): + return { + 'eventtime': eventtime, + 'timeout': self.timeout, + 'autorun': self._autorun, + 'isRunning': self.running, + 'is2004': (self.rows == 4 and self.cols == 20), + 'is2002': (self.rows == 2 and self.cols == 20), + 'is1604': (self.rows == 4 and self.cols == 16), + 'is1602': (self.rows == 2 and self.cols == 16), + 'is20xx': (self.cols == 20), + 'is16xx': (self.cols == 16) + } + + def update_parameters(self, eventtime): + self.parameters = {} + # getting info this way is more like hack + # all modules should have special reporting method (maybe get_status) + # for available parameters + # Only 2 level dot notation + for name in self.objs.keys(): + try: + if self.objs[name] is not None: + class_name = str(self.objs[name].__class__.__name__) + get_status = getattr(self.objs[name], "get_status", None) + if callable(get_status): + self.parameters[name] = get_status(eventtime) + else: + self.parameters[name] = {} + + self.parameters[name].update({'is_enabled': True}) + # get additional info + if class_name == 'ToolHead': + pos = self.objs[name].get_position() + self.parameters[name].update({ + 'xpos': pos[0], + 'ypos': pos[1], + 'zpos': pos[2], + 'epos': pos[3] + }) + self.parameters[name].update({ + 'is_printing': ( + self.parameters[name]['status'] == "Printing"), + 'is_ready': ( + self.parameters[name]['status'] == "Ready"), + 'is_idle': ( + self.parameters[name]['status'] == "Idle") + }) + elif class_name == 'PrinterExtruder': + info = self.objs[name].get_heater().get_status( + eventtime) + self.parameters[name].update(info) + elif class_name == 'PrinterLCD': + self.parameters[name].update({ + 'progress': self.objs[name].progress or 0, + 'message': self.objs[name].message or '', + 'is_enabled': True + }) + elif class_name == 'PrinterHeaterFan': + info = self.objs[name].fan.get_status(eventtime) + self.parameters[name].update(info) + elif class_name in ('PrinterOutputPin', 'PrinterServo'): + self.parameters[name].update({ + 'value': self.objs[name].last_value + }) + else: + self.parameters[name] = {'is_enabled': False} + except Exception: + logging.exception("Parameter '%s' update error" % str(name)) + + def stack_push(self, container): + if not isinstance(container, MenuContainer): + raise error("Wrong type, expected MenuContainer") + top = self.stack_peek() + if top is not None: + self.run_script(top.get_leave_gcode()) + self.run_script(container.get_enter_gcode()) + if not container.is_editing(): + container.update_items() + self.menustack.append(container) + + def stack_pop(self): + container = None + if self.stack_size() > 0: + container = self.menustack.pop() + if not isinstance(container, MenuContainer): + raise error("Wrong type, expected MenuContainer") + top = self.stack_peek() + if top is not None: + if not isinstance(container, MenuContainer): + raise error("Wrong type, expected MenuContainer") + if not top.is_editing(): + top.update_items() + self.run_script(container.get_leave_gcode()) + self.run_script(top.get_enter_gcode()) + else: + self.run_script(container.get_leave_gcode()) + return container + + def stack_size(self): + return len(self.menustack) + + def stack_peek(self, lvl=0): + container = None + if self.stack_size() > lvl: + container = self.menustack[self.stack_size() - lvl - 1] + return container + + def _unescape_cchars(self, text): + def fixup(m): + text = str(m.group(0)) + if text[:2] == "\\x": + try: + return "%c" % (int(text[2:], 16),) + except ValueError: + logging.exception('Custom character unescape error') + else: + return text + return re.sub(r'\\x[0-9a-f]{2}', fixup, str(text), flags=re.IGNORECASE) + + def render(self, eventtime): + lines = [] + self.update_parameters(eventtime) + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + container.heartbeat(eventtime) + # clamps + self.top_row = max(0, min( + self.top_row, len(container) - self.rows)) + self.selected = max(0, min( + self.selected, len(container) - 1)) + if isinstance(container, MenuDeck): + if not container.is_editing(): + container.update_items() + container[self.selected].heartbeat(eventtime) + lines = container[self.selected].render_content(eventtime) + else: + for row in range(self.top_row, self.top_row + self.rows): + s = "" + if row < len(container): + selected = (row == self.selected) + current = container[row] + if selected: + current.heartbeat(eventtime) + if (isinstance(current, (MenuInput, MenuGroup)) + and current.is_editing()): + s += MenuCursor.EDIT + elif isinstance(current, MenuElement): + s += current.cursor + else: + s += MenuCursor.SELECT + else: + s += MenuCursor.NONE + + name = "%s" % str(current.render(selected)) + i = len(s) + if isinstance(current, MenuList): + s += name[:self.cols-i-1].ljust(self.cols-i-1) + s += '>' + else: + s += name[:self.cols-i].ljust(self.cols-i) + lines.append(s.ljust(self.cols)) + return lines + + def screen_update_event(self, eventtime): + if self.is_running(): + self.lcd_chip.clear() + for y, line in enumerate(self.render(eventtime)): + self.lcd_chip.write_text(0, y, self._unescape_cchars(line)) + self.lcd_chip.flush() + return eventtime + MENU_UPDATE_DELAY + elif not self.is_running() and self._autorun is True: + # lets start and populate the menu items + self.begin(eventtime) + return eventtime + MENU_UPDATE_DELAY + else: + return 0 + + def up(self): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + self.timer = 0 + current = container[self.selected] + if (isinstance(current, (MenuInput, MenuGroup)) + and current.is_editing()): + current.dec_value() + elif (isinstance(current, MenuGroup) + and current.find_prev_item() is not None): + pass + else: + if self.selected == 0: + return + if self.selected > self.top_row: + self.selected -= 1 + else: + self.top_row -= 1 + self.selected -= 1 + # init element + if isinstance(container[self.selected], MenuElement): + container[self.selected].init() + # wind up group last item or init item + if isinstance(container[self.selected], MenuGroup): + container[self.selected].find_prev_item() + + def down(self): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + self.timer = 0 + current = container[self.selected] + if (isinstance(current, (MenuInput, MenuGroup)) + and current.is_editing()): + current.inc_value() + elif (isinstance(current, MenuGroup) + and current.find_next_item() is not None): + pass + else: + if self.selected >= len(container) - 1: + return + if self.selected < self.top_row + self.rows - 1: + self.selected += 1 + else: + self.top_row += 1 + self.selected += 1 + # init element + if isinstance(container[self.selected], MenuElement): + container[self.selected].init() + # wind up group first item + if isinstance(container[self.selected], MenuGroup): + container[self.selected].find_next_item() + + def back(self): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + self.timer = 0 + current = container[self.selected] + if (isinstance(current, (MenuInput, MenuGroup)) + and current.is_editing()): + return + parent = self.stack_peek(1) + if isinstance(parent, MenuContainer): + self.stack_pop() + index = parent.find_item(container) + if index is not None and index < len(parent): + self.top_row = index + self.selected = index + else: + self.top_row = 0 + self.selected = 0 + # init element + if isinstance(parent[self.selected], MenuElement): + parent[self.selected].init() + # wind up group first item or init item + if isinstance(parent[self.selected], MenuGroup): + parent[self.selected].find_next_item() + else: + self.stack_pop() + self.running = False + + def select(self): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + self.timer = 0 + current = container[self.selected] + if isinstance(current, MenuGroup): + current = current.selected_item() + if isinstance(current, MenuList): + self.stack_push(current) + self.top_row = 0 + self.selected = 0 + elif isinstance(current, MenuInput): + if current.is_editing(): + self.run_script(current.get_gcode()) + current.reset_value() + else: + current.init_value() + elif isinstance(current, MenuCommand): + current() + self.run_script(current.get_gcode()) + + def exit(self, force=False): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + current = container[self.selected] + if (not force and isinstance(current, (MenuInput, MenuGroup)) + and current.is_editing()): + return + self.run_script(container.get_leave_gcode()) + self.running = False + + def run_action(self, action, *args): + try: + action = str(action).strip().lower() + if action == 'back': + self.back() + elif action == 'exit': + self.exit() + elif action == 'respond': + self.gcode.respond_info("{}".format(' '.join(map(str, args)))) + else: + logging.error("Unknown action %s" % (action)) + except Exception: + logging.exception("Malformed action call") + + def run_script(self, script): + if script is not None: + try: + self.gcode.run_script(script) + except Exception: + logging.exception("Script running error") + + def add_menuitem(self, name, menu): + if name in self.menuitems: + logging.info( + "Declaration of '%s' hides " + "previous menuitem declaration" % (name,)) + self.menuitems[name] = menu + + def lookup_menuitem(self, name): + if name is None: + return None + if name not in self.menuitems: + raise self.printer.config_error( + "Unknown menuitem '%s'" % (name,)) + return self.menuitems[name] + + def load_menuitems(self, config): + for cfg in config.get_prefix_sections('menu '): + name = " ".join(cfg.get_name().split()[1:]) + item = cfg.getchoice('type', menu_items)(self, cfg, name) + self.add_menuitem(name, item) + + cmd_DO_help = "Menu do things" + + def cmd_DO_DUMP(self, params): + for key1 in self.parameters: + if type(self.parameters[key1]) == dict: + for key2 in self.parameters[key1]: + msg = "{0}.{1} = {2}".format( + key1, key2, + self.parameters[key1].get(key2) + ) + logging.info(msg) + self.gcode.respond_info(msg) + else: + msg = "{0} = {1}".format(key1, self.parameters.get(key1)) + logging.info(msg) + self.gcode.respond_info(msg) + + # buttons & encoder callbacks + def encoder_cw_callback(self, eventtime): + self.up() + + def encoder_ccw_callback(self, eventtime): + self.down() + + def click_callback(self, eventtime, state): + if self.click_pin: + if state: + self._last_press = eventtime + else: + if eventtime - self._last_press > 1.0: + # long click + if not self.is_running(): + # lets start and populate the menu items + self.begin(eventtime) + else: + container = self.stack_peek() + if isinstance(container, MenuDeck): + menu = container.menu + if (isinstance(menu, MenuList) + and not container.is_editing() + and menu is not container): + self.stack_push(menu) + self.top_row = 0 + self.selected = 0 + else: + # short click + if self.is_running(): + self.select() + else: + # lets start and populate the menu items + self.begin(eventtime) + + def back_callback(self, eventtime): + if self.back_pin: + self.back() + + def up_callback(self, eventtime): + if self.up_pin: + self.up() + + def down_callback(self, eventtime): + if self.down_pin: + self.down() + + def kill_callback(self, eventtime): + if self.kill_pin: + # Emergency Stop + self.printer.invoke_shutdown("Shutdown due to kill button!")