aboutsummaryrefslogtreecommitdiffstats
path: root/klippy/extras/display/menu.py
diff options
context:
space:
mode:
Diffstat (limited to 'klippy/extras/display/menu.py')
-rw-r--r--klippy/extras/display/menu.py1437
1 files changed, 1437 insertions, 0 deletions
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 <janar.soot@gmail.com>
+#
+# 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!")