{"action":"created","comment":{"url":"https://api.github.com/repos/KevinOConnor/klipper/pulls/comments/205947631","pull_request_review_id":141320959,"id":205947631,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDIwNTk0NzYzMQ==","diff_hunk":"@@ -0,0 +1,1137 @@\n+# -*- coding: utf-8 -*-\n+# Basic LCD menu support\n+#\n+# Based on the RaspberryPiLcdMenu from Alan Aufderheide, February 2013\n+# Copyright (C) 2018  Janar Sööt <janar.soot@gmail.com>\n+#\n+# This file may be distributed under the terms of the GNU GPLv3 license.\n+import logging, sys, ast\n+\n+class error(Exception):\n+    pass\n+\n+# static class for cursor\n+class MenuCursor:\n+    NONE = ' '\n+    SELECT = '>'\n+    EDIT = '*'\n+\n+# Menu element baseclass\n+class MenuElement(object):\n+    def __init__(self, menu, config):\n+        self.cursor = config.get('cursor', MenuCursor.SELECT)\n+        self._menu = menu\n+        self._width = self._asint(config.get('width', '0'))\n+        self._scroll = self._asbool(config.get('scroll', 'false'))\n+        self._enable = self._aslist(config.get('enable', 'true'), flatten=False)\n+        self._name = self._strip_quotes(config.get('name'))\n+        self.__scroll_offs = 0\n+        self.__scroll_diff = 0\n+        self.__scroll_dir = None\n+        self.__last_state = True\n+        if len(self.cursor) < 1:\n+            raise error(\"Cursor with unexpected length, expecting 1.\")\n+\n+    # override\n+    def _render(self):\n+        return self._name\n+\n+    # override\n+    def _second_tick(self, eventtime):\n+        pass\n+\n+    # override\n+    def is_editing(self):\n+        return False\n+\n+    # override\n+    def is_readonly(self):\n+        return True\n+\n+    # override\n+    def is_scrollable(self):\n+        return True\n+\n+    # override\n+    def is_enabled(self):\n+        return self._parse_bool(self._enable)\n+\n+    def init(self):\n+        self.__clear_scroll()\n+\n+    def heartbeat(self, eventtime):\n+        state = bool(int(eventtime) & 1)\n+        if self.__last_state ^ state:\n+            self.__last_state = state\n+            if not self.is_editing():\n+                self._second_tick(eventtime)\n+                self.__update_scroll(eventtime)\n+\n+    def __clear_scroll(self):\n+        self.__scroll_dir = None\n+        self.__scroll_diff = 0\n+        self.__scroll_offs = 0\n+\n+    def __update_scroll(self, eventtime):\n+        if self.__scroll_dir == 0 and self.__scroll_diff > 0:\n+            self.__scroll_dir = 1\n+            self.__scroll_offs = 0\n+        elif self.__scroll_dir and self.__scroll_diff > 0:\n+            self.__scroll_offs += self.__scroll_dir\n+            if self.__scroll_offs >= self.__scroll_diff:\n+                self.__scroll_dir = -1\n+            elif self.__scroll_offs <= 0:\n+                self.__scroll_dir = 1\n+        else:\n+            self.__clear_scroll()\n+\n+    def __render_scroll(self, s):\n+        if self.__scroll_dir is None:\n+            self.__scroll_dir = 0\n+            self.__scroll_offs = 0\n+        return s[self.__scroll_offs:self._width+self.__scroll_offs].ljust(self._width)\n+\n+    def render(self, scroll = False):\n+        s = str(self._render())\n+        if self._width > 0:\n+            self.__scroll_diff = len(s) - self._width\n+            if scroll and self._scroll is True and self.is_scrollable() and self.__scroll_diff > 0:\n+                s = self.__render_scroll(s)\n+            else:\n+                self.__clear_scroll()\n+                s = s[:self._width].ljust(self._width)\n+        else:\n+            self.__clear_scroll()\n+        return s\n+\n+    def _parse_bool(self, lst):\n+        try:\n+            return any([all([self._lookup_bool(l2) for l2 in self._aslist_split(l1)]) for l1 in lst])\n+        except:\n+            return False\n+\n+    def _lookup_bool(self, b):\n+        if not self._asbool(b):\n+            if b[0] == '!': # logical negation:\n+                return not (not not self._menu.lookup_parameter(b[1:]))\n+            else:\n+                return not not self._menu.lookup_parameter(b)\n+        return True\n+\n+    def _strip_quotes(self, s):\n+        if isinstance(s, str):\n+            s = str(s).strip()\n+            if s.startswith(('\"', \"'\")):\n+                s = s[1:]\n+            if s.endswith(('\"', \"'\")):\n+                s = s[:-1]\n+        return s\n+\n+    def _asbool(self, s, default=False):\n+        if s is None:\n+            return default\n+        if isinstance(s, bool):\n+            return s\n+        s = str(s).strip()\n+        return s.lower() in ('y','yes', 't', 'true', 'on', '1')\n+\n+    def _asint(self, s, default=0):\n+        if s is None:\n+            return default\n+        if isinstance(s, (int, float)):\n+            return int(s)\n+        s = str(s).strip()\n+        try:\n+            return int(float(s))\n+        except:\n+            return default\n+\n+    def _asfloat(self, s, default=0.0):\n+        if s is None:\n+            return default\n+        if isinstance(s, (int, float)):\n+            return float(s)\n+        s = str(s).strip()\n+        try:\n+            return float(s)\n+        except:\n+            return default\n+\n+    def _aslist_splitlines(self, value, default=[]):\n+        if isinstance(value, str):\n+            value = filter(None, [x.strip() for x in value.splitlines()])\n+        try:\n+            return list(value)\n+        except:\n+            return default\n+\n+    def _aslist_split(self, value, sep=',', default=[]):\n+        if isinstance(value, str):\n+            value = filter(None, [x.strip() for x in value.split(sep)])\n+        try:\n+            return list(value)\n+        except:\n+            return default\n+\n+    def _aslist(self, value, flatten=True, default=[]):\n+        values = self._aslist_splitlines(value)\n+        if not flatten:\n+            return values\n+        result = []\n+        for value in values:\n+            subvalues = self._aslist_split(value, sep = ',')\n+            result.extend(subvalues)\n+        return result\n+\n+# menu container baseclass\n+class MenuContainer(MenuElement):\n+    def __init__(self, menu, config):\n+        super(MenuContainer, self).__init__(menu, config)\n+        self._show_back = self._asbool(config.get('show_back', 'true'))\n+        self._show_title = self._asbool(config.get('show_title', 'true'))\n+        self._allitems = []\n+        self._items = []\n+\n+    # overload\n+    def _names_aslist(self):\n+        return []\n+\n+    # overload\n+    def is_accepted(self, item):\n+        return isinstance(item, MenuElement)\n+\n+    def is_readonly(self):\n+        return False\n+\n+    def is_editing(self):\n+        return any([item.is_editing() for item in self._items])\n+\n+    def _lookup_item(self, s):\n+        if isinstance(s, str):\n+            s = self._menu.lookup_menuitem(s.strip())\n+        return s\n+\n+    def find_item(self, item):\n+        index = None\n+        if item in self._items:\n+            index = self._items.index(item)\n+        else:\n+            for con in self._items:\n+                if isinstance(con, MenuContainer) and item in con:\n+                    index = self._items.index(con)\n+        return index\n+\n+    def append_item(self, s):\n+        item = self._lookup_item(s)\n+        if item is not None:\n+            if not self.is_accepted(item):\n+                raise error(\"Menu item '%s'is not accepted!\" % str(type(item)))\n+            if isinstance(item, (MenuContainer)):\n+                item.populate_items()\n+            self._allitems.append(item)\n+\n+    def populate_items(self):\n+        self._allitems = [] # empty list\n+        if self._show_back is True:\n+            name = '[..]'\n+            if self._show_title:\n+                name += ' %s' % str(self._name)\n+            self.append_item(MenuCommand(self._menu, {'name':name, 'gcode':'', 'action': 'back'}))\n+        for name in self._names_aslist():\n+            self.append_item(name)\n+        self.update_items()\n+\n+    def update_items(self):\n+        self._items = [item for item in self._allitems if item.is_enabled()]\n+\n+    def __iter__(self):\n+        return iter(self._items)\n+\n+    def __len__(self):\n+        return len(self._items)\n+\n+    def __getitem__(self, key):\n+        return self._items[key]\n+\n+class MenuItem(MenuElement):\n+    def __init__(self, menu, config):\n+        super(MenuItem, self).__init__(menu, config)\n+        self.parameter = config.get('parameter', '')\n+        self.transform = config.get('transform', '')\n+\n+    def _parse_transform(self, t):\n+        def mapper(left_min, left_max, right_min, right_max, cast_fn):\n+            # interpolate\n+            left_span = left_max - left_min\n+            right_span = right_max - right_min\n+            scale_factor = float(right_span) / float(left_span)\n+            def map_fn(value):\n+                return cast_fn(right_min + (value-left_min)*scale_factor)\n+            return map_fn\n+\n+        def scaler(scale_factor, cast_fn):\n+            def scale_fn(value):\n+                return cast_fn(value*scale_factor)\n+            return scale_fn\n+\n+        def chooser(choices, cast_fn):\n+            def choose_fn(value):\n+                return choices[cast_fn(value)]\n+            return choose_fn\n+\n+        def timerizer(key):\n+            time = {}\n+            def time_fn(value):\n+                try:\n+                    seconds = int(value)\n+                except:\n+                    seconds = 0\n+                time['days'], time['seconds'] = divmod(seconds, 86400)\n+                time['hours'], time['seconds'] = divmod(time['seconds'], 3600)\n+                time['minutes'], time['seconds'] = divmod(time['seconds'], 60)\n+\n+                if key in time:\n+                    return time[key]\n+                else:\n+                    return 0\n+            return time_fn\n+\n+        funs = {'int':int, 'float':float, 'bool':bool, 'str':str, 'abs':abs, 'bin':bin, 'hex':hex, 'oct':oct}\n+        fn = None\n+        t = str(t).strip()\n+        try:\n+            o = ast.literal_eval(t)\n+            if isinstance(o, tuple) and len(o) == 4 and isinstance(o[3], (float, int)):\n+                # mapper (interpolate), cast type by last parameter type\n+                fn = mapper(o[0], o[1], o[2], o[3], type(o[3]))\n+            elif isinstance(o, tuple) and len(o) == 2:\n+                # boolean chooser for 2 size tuple\n+                fn = chooser(o, bool)\n+            elif isinstance(o, list) and o:\n+                # int chooser for list\n+                fn = chooser(o, int)\n+            elif isinstance(o, str) and o:\n+                key = o.strip().lower()\n+                if key in funs:\n+                    fn = funs[key]\n+                elif key in ('days','hours','minutes','seconds'):\n+                    fn = timerizer(key)\n+                else:\n+                    logging.error(\"Unknown function: '%s'\" % str(key))\n+            elif isinstance(o, (float, int)):\n+                # scaler, cast type depends from scale factor type\n+                fn = scaler(o, type(o))\n+            elif isinstance(o, dict) and o.keys() and isinstance(o.keys()[0], (int, float, str)):\n+                # chooser, cast type by first key type\n+                fn = chooser(o, type(o.keys()[0]))\n+            else:\n+                logging.error(\"Invalid transform parameter: '%s'\" % (t,))\n+        except Exception as e:\n+            logging.error('Transform parsing exception: '+ str(e))\n+        return fn\n+\n+    def _transform_aslist(self):\n+        return list(filter(None, (self._parse_transform(t) for t in self._aslist(self.transform, flatten=False))))\n+\n+    def _prepare_values(self, value = None):\n+        values = []\n+        if self.parameter:\n+            value = self._menu.lookup_parameter(self.parameter) if value is None else value\n+            if value is not None:\n+                values += [value]\n+                try:\n+                    values += [t(value) for t in self._transform_aslist() if callable(t)]\n+                except Exception as e:\n+                    logging.error('Transformation exception: '+ str(e))\n+            else:\n+                logging.error(\"Parameter '%s' not found\" % str(self.parameter))\n+        return tuple(values)\n+\n+    def _get_formatted(self, literal, val = None):\n+        values = self._prepare_values(val)\n+        if isinstance(literal, str) and len(values) > 0:\n+            try:\n+                literal = literal.format(*values)\n+            except Exception as e:\n+                logging.error('Format exception: '+ str(e))\n+        return literal\n+\n+    def _render(self):\n+        return self._get_formatted(self._name)\n+\n+class MenuCommand(MenuItem):\n+    def __init__(self, menu, config):\n+        super(MenuCommand, self).__init__(menu, config)\n+        self._gcode = config.get('gcode')\n+        self._action = config.get('action', None)\n+\n+    def is_readonly(self):\n+        return False\n+\n+    def get_gcode(self):\n+        return self._get_formatted(self._gcode)\n+\n+    def __call__(self):\n+        if self._action is not None:\n+            try:\n+                fmt = self._get_formatted(self._action)\n+                args = fmt.split()\n+                self._menu.run_action(args[0], *args[1:])\n+            except Exception as e:\n+                logging.error('Action format exception: '+ str(e))\n+\n+class MenuInput(MenuCommand):\n+    def __init__(self, menu, config):\n+        super(MenuInput, self).__init__(menu, config)\n+        self._reverse = self._asbool(config.get('reverse', 'false'))\n+        self._readonly = self._aslist(config.get('readonly', 'false'), flatten=False)\n+        self._input_value = None\n+        self._input_min = config.getfloat('input_min', sys.float_info.min)\n+        self._input_max = config.getfloat('input_max', sys.float_info.max)\n+        self._input_step = config.getfloat('input_step', above=0.)\n+\n+    def is_scrollable(self):\n+        return False\n+\n+    def is_readonly(self):\n+        return self._parse_bool(self._readonly)\n+\n+    def _render(self):\n+        return self._get_formatted(self._name, self._input_value)\n+\n+    def get_gcode(self):\n+        return self._get_formatted(self._gcode, self._input_value)\n+\n+    def is_editing(self):\n+        return self._input_value is not None\n+\n+    def init_value(self):\n+        self._input_value = None\n+        if not self.is_readonly():\n+            args = self._prepare_values()\n+            if len(args) > 0:\n+                try:\n+                    self._input_value = float(args[0])\n+                except:\n+                    pass\n+\n+    def reset_value(self):\n+        self._input_value = None\n+\n+    def inc_value(self):\n+        if self._input_value is None:\n+            return\n+        if(self._reverse is True):\n+            self._input_value -= abs(self._input_step)\n+        else:\n+            self._input_value += abs(self._input_step)\n+        self._input_value = min(self._input_max, max(self._input_min, self._input_value))\n+\n+    def dec_value(self):\n+        if self._input_value is None:\n+            return\n+        if(self._reverse is True):\n+            self._input_value += abs(self._input_step)\n+        else:\n+            self._input_value -= abs(self._input_step)\n+        self._input_value = min(self._input_max, max(self._input_min, self._input_value))\n+\n+class MenuGroup(MenuContainer):\n+    def __init__(self, menu, config, sep = ','):\n+        super(MenuGroup, self).__init__(menu, config)\n+        self._sep = sep\n+        self._show_back = False\n+        self.selected = None\n+        self.items = config.get('items')\n+\n+    def is_accepted(self, item):\n+        return super(MenuGroup, self).is_accepted(item) and type(item) is not MenuCard\n+\n+    def is_scrollable(self):\n+        return False\n+\n+    def is_enabled(self):\n+        return not not len(self)\n+\n+    def is_readonly(self):\n+        return all([item.is_readonly() for item in self._items])\n+\n+    def _names_aslist(self):\n+        return self._aslist_split(self.items, sep=self._sep)\n+\n+    def init(self):\n+        super(MenuGroup, self).init()\n+        for item in self._items:\n+            item.init()\n+\n+    def _render_item(self, item, selected = False, scroll = False):\n+        name = \"%s\" % str(item.render(scroll))\n+        if selected and not self.is_editing():\n+            name = name if self._menu.blink_slow_state else ' '*len(name)\n+        elif selected and self.is_editing():\n+            name = name if self._menu.blink_fast_state else ' '*len(name)\n+        return name\n+\n+    def _render(self):\n+        s = \"\"\n+        if self.selected is not None:\n+            self.selected = (self.selected % len(self)) if len(self) > 0 else None\n+\n+        for i, item in enumerate(self):\n+            s += self._render_item(item, (i == self.selected), True)\n+        return s\n+\n+    def _call_selected(self, method = None):\n+        res = None\n+        try:\n+            res = (getattr(self[self.selected], method)() if method is not None else self[self.selected])\n+        except:\n+            pass\n+        return res\n+\n+    def is_editing(self):\n+        return self._call_selected('is_editing')\n+\n+    def inc_value(self):\n+        self._call_selected('inc_value')\n+\n+    def dec_value(self):\n+        self._call_selected('dec_value')\n+\n+    def selected_item(self):\n+        return self._call_selected()\n+\n+    def find_next_item(self):\n+        if self.selected is None:\n+            self.selected = 0\n+        elif self.selected < len(self) - 1:\n+            self.selected += 1\n+        else:\n+            self.selected = None\n+        # skip readonly\n+        while self.selected is not None and self.selected < len(self) and self._call_selected('is_readonly'):\n+            self.selected = (self.selected + 1) if self.selected < len(self) - 1 else None\n+        return self.selected\n+\n+    def find_prev_item(self):\n+        if self.selected is None:\n+            self.selected = len(self) - 1\n+        elif self.selected > 0:\n+            self.selected -= 1\n+        else:\n+            self.selected = None\n+        # skip readonly\n+        while self.selected is not None and self.selected >= 0 and self._call_selected('is_readonly'):\n+            self.selected = (self.selected - 1) if self.selected > 0 else None\n+        return self.selected\n+\n+class MenuItemGroup(MenuGroup):\n+    def __init__(self, menu, config, sep):\n+        super(MenuItemGroup, self).__init__(menu, config, sep)\n+\n+    def is_readonly(self):\n+        return True\n+\n+    def is_accepted(self, item):\n+        return type(item) is MenuItem\n+\n+class MenuCycler(MenuGroup):\n+    def __init__(self, menu, config, sep):\n+        super(MenuCycler, self).__init__(menu, config, sep)\n+        self._interval = 0\n+        self.__interval_cnt = 0\n+        self.__alllen = 0\n+        self._curr_idx = 0\n+\n+    def is_readonly(self):\n+        return True\n+\n+    def is_accepted(self, item):\n+        return type(item) in (MenuItem, MenuItemGroup)\n+\n+    def _lookup_item(self, item):\n+        if isinstance(item, str) and '|' in item:\n+            item = MenuItemGroup(self._menu, {'name':'ItemGroup', 'items': item}, '|')\n+        elif isinstance(item, str) and item.isdigit():\n+            try:\n+                self._interval = max(0, int(item))\n+            except:\n+                pass\n+            item = None\n+        return super(MenuCycler, self)._lookup_item(item)\n+\n+    def _second_tick(self, eventtime):\n+        super(MenuCycler, self)._second_tick(eventtime)\n+        if self._interval > 0:\n+            self.__interval_cnt = (self.__interval_cnt+1) % self._interval\n+            if self.__interval_cnt == 0 and self.__alllen > 0:\n+                self._curr_idx = (self._curr_idx+1) % self.__alllen\n+        else:\n+            self._curr_idx = 0\n+\n+    def heartbeat(self, eventtime):\n+        super(MenuCycler, self).heartbeat(eventtime)\n+        for item in self._items:\n+            item.heartbeat(eventtime)\n+\n+    def update_items(self):\n+        items = [item for item in self._allitems if item.is_enabled()]\n+        self.__alllen = len(items)\n+        if self.__alllen > 0:\n+            self._curr_idx = self._curr_idx % self.__alllen\n+            self._items = [items[self._curr_idx]]\n+        else:\n+            self._curr_idx = 0\n+            self._items = []\n+\n+class MenuList(MenuContainer):\n+    def __init__(self, menu, config):\n+        super(MenuList, self).__init__(menu, config)\n+        self._enter_gcode = config.get('enter_gcode', None)\n+        self._leave_gcode = config.get('leave_gcode', None)\n+        self.items = config.get('items')\n+\n+    def is_accepted(self, item):\n+        return super(MenuList, self).is_accepted(item) and type(item) is not MenuCard\n+\n+    def _names_aslist(self):\n+        return self._aslist_splitlines(self.items)\n+\n+    def _lookup_item(self, item):\n+        if isinstance(item, str) and ',' in item:\n+            item = MenuGroup(self._menu, {'name':'Group', 'items': item}, ',')\n+        return super(MenuList, self)._lookup_item(item)\n+\n+    def update_items(self):\n+        super(MenuList, self).update_items()\n+        for item in self._items:\n+            if isinstance(item, MenuGroup) and not item.is_editing():\n+                item.update_items()\n+\n+    def get_enter_gcode(self):\n+        return self._enter_gcode\n+\n+    def get_leave_gcode(self):\n+        return self._leave_gcode\n+\n+class MenuVSDCard(MenuList):\n+    def __init__(self, menu, config):\n+        super(MenuVSDCard, self).__init__(menu, config)\n+\n+    def _populate_files(self):\n+        sdcard = self._menu.objs['virtual_sdcard']\n+        if sdcard is not None:\n+            files = sdcard.get_file_list()\n+            for fname, fsize in files:\n+                gcode = [\n+                    'M23 /%s' % str(fname)\n+                ]\n+                self.append_item(MenuCommand(self._menu, {'name': '%s' % str(fname), 'cursor':'+', 'gcode': \"\\n\".join(gcode)}))\n+\n+    def populate_items(self):\n+        super(MenuVSDCard, self).populate_items()\n+        self._populate_files()\n+\n+class MenuCard(MenuGroup):\n+    def __init__(self, menu, config):\n+        super(MenuCard, self).__init__(menu, config)\n+        self.content = config.get('content')\n+\n+    def _names_aslist(self):\n+        return self._aslist_splitlines(self.items)\n+\n+    def _content_aslist(self):\n+        return filter(None, [self._strip_quotes(s) for s in self._aslist_splitlines(self.content)])\n+\n+    def update_items(self):\n+        self._items = self._allitems[:]\n+        for item in self._items:\n+            if isinstance(item, MenuGroup) and not item.is_editing():\n+                item.update_items()\n+\n+    def _lookup_item(self, item):\n+        if isinstance(item, str) and ',' in item:\n+            item = MenuCycler(self._menu, {'name':'Cycler', 'items': item}, ',')\n+        return super(MenuCard, self)._lookup_item(item)\n+\n+    def render_content(self, eventtime):\n+        if self.selected is not None:\n+            self.selected = (self.selected % len(self)) if len(self) > 0 else None\n+\n+        items = []\n+        for i, item in enumerate(self):\n+            name = ''\n+            if item.is_enabled():\n+                item.heartbeat(eventtime)\n+                name = self._render_item(item, (i == self.selected), True)\n+            items.append(name)\n+        lines = []\n+        for line in self._content_aslist():\n+            try:\n+                lines.append(str(line).format(*items))\n+            except Exception as e:\n+                logging.error('Card rendering exception: '+ str(e))\n+        return lines\n+\n+    def _render(self):\n+        return self._name\n+\n+class MenuDeck(MenuList):\n+    def __init__(self, menu, config):\n+        super(MenuDeck, self).__init__(menu, config)\n+        self._show_back = False\n+        self._show_title = False\n+\n+    def _names_aslist(self):\n+        return self._aslist(self.items)\n+\n+    def is_accepted(self, item):\n+        return type(item) is MenuCard\n+\n+    def _render(self):\n+        return self._name\n+\n+menu_items = { 'item': MenuItem, 'command': MenuCommand, 'input': MenuInput, 'list': MenuList, 'vsdcard':MenuVSDCard, 'deck':MenuDeck, 'card': MenuCard }\n+# Default dimensions for lcds (rows, cols)\n+LCD_dims = { 'st7920': (4,16), 'hd44780': (4,20), 'uc1701' : (4,16) }\n+\n+TIMER_DELAY   = 0.200\n+BLINK_FAST_SEQUENCE = (True, True, False, False)\n+BLINK_SLOW_SEQUENCE = (True, True, True, True, False, False, False)\n+\n+class MenuManager:\n+    def __init__(self, config):\n+        self.running = False\n+        self.menuitems = {}\n+        self.menustack = []\n+        self._recursive_guard = []\n+        self.top_row = 0\n+        self.selected = 0\n+        self.blink_fast_state = True\n+        self.blink_slow_state = True\n+        self.blink_fast_idx = 0\n+        self.blink_slow_idx = 0\n+        self.timeout_idx = 0\n+        self.config = config\n+        self.printer = config.get_printer()\n+        self.gcode = self.printer.lookup_object('gcode')\n+        self.parameters = {}\n+        self.objs = {\n+            'gcode': None,\n+            'toolhead': None,\n+            'fan': None,\n+            'extruder0': None,\n+            'extruder1': None,\n+            'heater_bed': None,\n+            'virtual_sdcard': None,\n+            'display': None,\n+            'output_pin': {},\n+            'servo': {}\n+        }\n+        # try to load printer objects in this state\n+        for name in self.objs.keys():\n+            if self.objs[name] is None:\n+                self.objs[name] = self.printer.lookup_object(name, None)\n+        self.root = None\n+        self._root = config.get('menu_root', None)\n+        self.autorun = config.getboolean('menu_autorun', False)\n+        dims = config.getchoice('lcd_type', LCD_dims)\n+        self.rows = config.getint('rows', dims[0])\n+        self.cols = config.getint('cols', dims[1])\n+        self.timeout = config.getint('menu_timeout', 0)\n+        self.timer = 0\n+        # Add MENU commands\n+        self.gcode.register_mux_command(\"MENU\", \"DO\", 'dump', self.cmd_MENUDO_DUMP, desc=self.cmd_MENUDO_help)\n+        self.gcode.register_mux_command(\"MENU\", \"DO\", 'exit', self.cmd_MENUDO_EXIT, desc=self.cmd_MENUDO_help)\n+        self.gcode.register_mux_command(\"MENU\", \"DO\", 'up', self.cmd_MENUDO_UP, desc=self.cmd_MENUDO_help)\n+        self.gcode.register_mux_command(\"MENU\", \"DO\", 'down', self.cmd_MENUDO_DOWN, desc=self.cmd_MENUDO_help)\n+        self.gcode.register_mux_command(\"MENU\", \"DO\", 'select', self.cmd_MENUDO_SELECT, desc=self.cmd_MENUDO_help)\n+        self.gcode.register_mux_command(\"MENU\", \"DO\", 'back', self.cmd_MENUDO_BACK, desc=self.cmd_MENUDO_help)","path":"klippy/extras/display/menu.py","position":749,"original_position":749,"commit_id":"2059d08a396c683e21b00dceabfcfc52d65eaa5d","original_commit_id":"2059d08a396c683e21b00dceabfcfc52d65eaa5d","user":{"login":"KevinOConnor","id":3004890,"node_id":"MDQ6VXNlcjMwMDQ4OTA=","avatar_url":"https://avatars3.githubusercontent.com/u/3004890?v=4","gravatar_id":"","url":"https://api.github.com/users/KevinOConnor","html_url":"https://github.com/KevinOConnor","followers_url":"https://api.github.com/users/KevinOConnor/followers","following_url":"https://api.github.com/users/KevinOConnor/following{/other_user}","gists_url":"https://api.github.com/users/KevinOConnor/gists{/gist_id}","starred_url":"https://api.github.com/users/KevinOConnor/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/KevinOConnor/subscriptions","organizations_url":"https://api.github.com/users/KevinOConnor/orgs","repos_url":"https://api.github.com/users/KevinOConnor/repos","events_url":"https://api.github.com/users/KevinOConnor/events{/privacy}","received_events_url":"https://api.github.com/users/KevinOConnor/received_events","type":"User","site_admin":false},"body":"Can you expand on what the MENU command is intended for?\r\n","created_at":"2018-07-28T16:38:02Z","updated_at":"2018-07-28T16:57:54Z","html_url":"https://github.com/KevinOConnor/klipper/pull/500#discussion_r205947631","pull_request_url":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500","author_association":"OWNER","_links":{"self":{"href":"https://api.github.com/repos/KevinOConnor/klipper/pulls/comments/205947631"},"html":{"href":"https://github.com/KevinOConnor/klipper/pull/500#discussion_r205947631"},"pull_request":{"href":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500"}}},"pull_request":{"url":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500","id":204206517,"node_id":"MDExOlB1bGxSZXF1ZXN0MjA0MjA2NTE3","html_url":"https://github.com/KevinOConnor/klipper/pull/500","diff_url":"https://github.com/KevinOConnor/klipper/pull/500.diff","patch_url":"https://github.com/KevinOConnor/klipper/pull/500.patch","issue_url":"https://api.github.com/repos/KevinOConnor/klipper/issues/500","number":500,"state":"open","locked":false,"title":"Flexible menu system for klipper","user":{"login":"mcmatrix","id":1433580,"node_id":"MDQ6VXNlcjE0MzM1ODA=","avatar_url":"https://avatars1.githubusercontent.com/u/1433580?v=4","gravatar_id":"","url":"https://api.github.com/users/mcmatrix","html_url":"https://github.com/mcmatrix","followers_url":"https://api.github.com/users/mcmatrix/followers","following_url":"https://api.github.com/users/mcmatrix/following{/other_user}","gists_url":"https://api.github.com/users/mcmatrix/gists{/gist_id}","starred_url":"https://api.github.com/users/mcmatrix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mcmatrix/subscriptions","organizations_url":"https://api.github.com/users/mcmatrix/orgs","repos_url":"https://api.github.com/users/mcmatrix/repos","events_url":"https://api.github.com/users/mcmatrix/events{/privacy}","received_events_url":"https://api.github.com/users/mcmatrix/received_events","type":"User","site_admin":false},"body":"It's a text based menu system and requires working display.\r\nThe menu structure is fully configurable via config file. \r\nIt can be used as alternative info/status screen.\r\nPlease check `example-menu.cfg` for more info.\r\nMenu will working with different screen sizes (4x16, 4x20).\r\nModule has menu and buttons related attributes also in config `display` section. \r\nPlease check `example-extras.cfg` `display` section for more info.\r\n\r\nExample menu related attributes in `display` section\r\n```\r\nmenu_timeout: 60\r\nmenu_root: main\r\nencoder_pins: ^!ar31, ^!ar33\r\nclick_pin: ^!ar35\r\nkill_pin: ^!ar41\r\n```\r\n","created_at":"2018-07-26T17:00:55Z","updated_at":"2018-07-28T16:57:54Z","closed_at":null,"merged_at":null,"merge_commit_sha":"0632190850f0866893584bb762899a55b22adfa0","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500/commits","review_comments_url":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500/comments","review_comment_url":"https://api.github.com/repos/KevinOConnor/klipper/pulls/comments{/number}","comments_url":"https://api.github.com/repos/KevinOConnor/klipper/issues/500/comments","statuses_url":"https://api.github.com/repos/KevinOConnor/klipper/statuses/2059d08a396c683e21b00dceabfcfc52d65eaa5d","head":{"label":"mcmatrix:PR-menu","ref":"PR-menu","sha":"2059d08a396c683e21b00dceabfcfc52d65eaa5d","user":{"login":"mcmatrix","id":1433580,"node_id":"MDQ6VXNlcjE0MzM1ODA=","avatar_url":"https://avatars1.githubusercontent.com/u/1433580?v=4","gravatar_id":"","url":"https://api.github.com/users/mcmatrix","html_url":"https://github.com/mcmatrix","followers_url":"https://api.github.com/users/mcmatrix/followers","following_url":"https://api.github.com/users/mcmatrix/following{/other_user}","gists_url":"https://api.github.com/users/mcmatrix/gists{/gist_id}","starred_url":"https://api.github.com/users/mcmatrix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mcmatrix/subscriptions","organizations_url":"https://api.github.com/users/mcmatrix/orgs","repos_url":"https://api.github.com/users/mcmatrix/repos","events_url":"https://api.github.com/users/mcmatrix/events{/privacy}","received_events_url":"https://api.github.com/users/mcmatrix/received_events","type":"User","site_admin":false},"repo":{"id":137603928,"node_id":"MDEwOlJlcG9zaXRvcnkxMzc2MDM5Mjg=","name":"klipper","full_name":"mcmatrix/klipper","owner":{"login":"mcmatrix","id":1433580,"node_id":"MDQ6VXNlcjE0MzM1ODA=","avatar_url":"https://avatars1.githubusercontent.com/u/1433580?v=4","gravatar_id":"","url":"https://api.github.com/users/mcmatrix","html_url":"https://github.com/mcmatrix","followers_url":"https://api.github.com/users/mcmatrix/followers","following_url":"https://api.github.com/users/mcmatrix/following{/other_user}","gists_url":"https://api.github.com/users/mcmatrix/gists{/gist_id}","starred_url":"https://api.github.com/users/mcmatrix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mcmatrix/subscriptions","organizations_url":"https://api.github.com/users/mcmatrix/orgs","repos_url":"https://api.github.com/users/mcmatrix/repos","events_url":"https://api.github.com/users/mcmatrix/events{/privacy}","received_events_url":"https://api.github.com/users/mcmatrix/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/mcmatrix/klipper","description":"Klipper is a 3d-printer firmware","fork":true,"url":"https://api.github.com/repos/mcmatrix/klipper","forks_url":"https://api.github.com/repos/mcmatrix/klipper/forks","keys_url":"https://api.github.com/repos/mcmatrix/klipper/keys{/key_id}","collaborators_url":"https://api.github.com/repos/mcmatrix/klipper/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/mcmatrix/klipper/teams","hooks_url":"https://api.github.com/repos/mcmatrix/klipper/hooks","issue_events_url":"https://api.github.com/repos/mcmatrix/klipper/issues/events{/number}","events_url":"https://api.github.com/repos/mcmatrix/klipper/events","assignees_url":"https://api.github.com/repos/mcmatrix/klipper/assignees{/user}","branches_url":"https://api.github.com/repos/mcmatrix/klipper/branches{/branch}","tags_url":"https://api.github.com/repos/mcmatrix/klipper/tags","blobs_url":"https://api.github.com/repos/mcmatrix/klipper/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/mcmatrix/klipper/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/mcmatrix/klipper/git/refs{/sha}","trees_url":"https://api.github.com/repos/mcmatrix/klipper/git/trees{/sha}","statuses_url":"https://api.github.com/repos/mcmatrix/klipper/statuses/{sha}","languages_url":"https://api.github.com/repos/mcmatrix/klipper/languages","stargazers_url":"https://api.github.com/repos/mcmatrix/klipper/stargazers","contributors_url":"https://api.github.com/repos/mcmatrix/klipper/contributors","subscribers_url":"https://api.github.com/repos/mcmatrix/klipper/subscribers","subscription_url":"https://api.github.com/repos/mcmatrix/klipper/subscription","commits_url":"https://api.github.com/repos/mcmatrix/klipper/commits{/sha}","git_commits_url":"https://api.github.com/repos/mcmatrix/klipper/git/commits{/sha}","comments_url":"https://api.github.com/repos/mcmatrix/klipper/comments{/number}","issue_comment_url":"https://api.github.com/repos/mcmatrix/klipper/issues/comments{/number}","contents_url":"https://api.github.com/repos/mcmatrix/klipper/contents/{+path}","compare_url":"https://api.github.com/repos/mcmatrix/klipper/compare/{base}...{head}","merges_url":"https://api.github.com/repos/mcmatrix/klipper/merges","archive_url":"https://api.github.com/repos/mcmatrix/klipper/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/mcmatrix/klipper/downloads","issues_url":"https://api.github.com/repos/mcmatrix/klipper/issues{/number}","pulls_url":"https://api.github.com/repos/mcmatrix/klipper/pulls{/number}","milestones_url":"https://api.github.com/repos/mcmatrix/klipper/milestones{/number}","notifications_url":"https://api.github.com/repos/mcmatrix/klipper/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/mcmatrix/klipper/labels{/name}","releases_url":"https://api.github.com/repos/mcmatrix/klipper/releases{/id}","deployments_url":"https://api.github.com/repos/mcmatrix/klipper/deployments","created_at":"2018-06-16T19:02:58Z","updated_at":"2018-07-27T14:37:55Z","pushed_at":"2018-07-27T15:07:39Z","git_url":"git://github.com/mcmatrix/klipper.git","ssh_url":"git@github.com:mcmatrix/klipper.git","clone_url":"https://github.com/mcmatrix/klipper.git","svn_url":"https://github.com/mcmatrix/klipper","homepage":null,"size":4137,"stargazers_count":1,"watchers_count":1,"language":"C","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":1,"mirror_url":null,"archived":false,"open_issues_count":0,"license":{"key":"gpl-3.0","name":"GNU General Public License v3.0","spdx_id":"GPL-3.0","url":"https://api.github.com/licenses/gpl-3.0","node_id":"MDc6TGljZW5zZTk="},"forks":1,"open_issues":0,"watchers":1,"default_branch":"master"}},"base":{"label":"KevinOConnor:master","ref":"master","sha":"7c7de85f01417152a8dd8956f7804d7651ac0398","user":{"login":"KevinOConnor","id":3004890,"node_id":"MDQ6VXNlcjMwMDQ4OTA=","avatar_url":"https://avatars3.githubusercontent.com/u/3004890?v=4","gravatar_id":"","url":"https://api.github.com/users/KevinOConnor","html_url":"https://github.com/KevinOConnor","followers_url":"https://api.github.com/users/KevinOConnor/followers","following_url":"https://api.github.com/users/KevinOConnor/following{/other_user}","gists_url":"https://api.github.com/users/KevinOConnor/gists{/gist_id}","starred_url":"https://api.github.com/users/KevinOConnor/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/KevinOConnor/subscriptions","organizations_url":"https://api.github.com/users/KevinOConnor/orgs","repos_url":"https://api.github.com/users/KevinOConnor/repos","events_url":"https://api.github.com/users/KevinOConnor/events{/privacy}","received_events_url":"https://api.github.com/users/KevinOConnor/received_events","type":"User","site_admin":false},"repo":{"id":59676171,"node_id":"MDEwOlJlcG9zaXRvcnk1OTY3NjE3MQ==","name":"klipper","full_name":"KevinOConnor/klipper","owner":{"login":"KevinOConnor","id":3004890,"node_id":"MDQ6VXNlcjMwMDQ4OTA=","avatar_url":"https://avatars3.githubusercontent.com/u/3004890?v=4","gravatar_id":"","url":"https://api.github.com/users/KevinOConnor","html_url":"https://github.com/KevinOConnor","followers_url":"https://api.github.com/users/KevinOConnor/followers","following_url":"https://api.github.com/users/KevinOConnor/following{/other_user}","gists_url":"https://api.github.com/users/KevinOConnor/gists{/gist_id}","starred_url":"https://api.github.com/users/KevinOConnor/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/KevinOConnor/subscriptions","organizations_url":"https://api.github.com/users/KevinOConnor/orgs","repos_url":"https://api.github.com/users/KevinOConnor/repos","events_url":"https://api.github.com/users/KevinOConnor/events{/privacy}","received_events_url":"https://api.github.com/users/KevinOConnor/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/KevinOConnor/klipper","description":"Klipper is a 3d-printer firmware","fork":false,"url":"https://api.github.com/repos/KevinOConnor/klipper","forks_url":"https://api.github.com/repos/KevinOConnor/klipper/forks","keys_url":"https://api.github.com/repos/KevinOConnor/klipper/keys{/key_id}","collaborators_url":"https://api.github.com/repos/KevinOConnor/klipper/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/KevinOConnor/klipper/teams","hooks_url":"https://api.github.com/repos/KevinOConnor/klipper/hooks","issue_events_url":"https://api.github.com/repos/KevinOConnor/klipper/issues/events{/number}","events_url":"https://api.github.com/repos/KevinOConnor/klipper/events","assignees_url":"https://api.github.com/repos/KevinOConnor/klipper/assignees{/user}","branches_url":"https://api.github.com/repos/KevinOConnor/klipper/branches{/branch}","tags_url":"https://api.github.com/repos/KevinOConnor/klipper/tags","blobs_url":"https://api.github.com/repos/KevinOConnor/klipper/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/KevinOConnor/klipper/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/KevinOConnor/klipper/git/refs{/sha}","trees_url":"https://api.github.com/repos/KevinOConnor/klipper/git/trees{/sha}","statuses_url":"https://api.github.com/repos/KevinOConnor/klipper/statuses/{sha}","languages_url":"https://api.github.com/repos/KevinOConnor/klipper/languages","stargazers_url":"https://api.github.com/repos/KevinOConnor/klipper/stargazers","contributors_url":"https://api.github.com/repos/KevinOConnor/klipper/contributors","subscribers_url":"https://api.github.com/repos/KevinOConnor/klipper/subscribers","subscription_url":"https://api.github.com/repos/KevinOConnor/klipper/subscription","commits_url":"https://api.github.com/repos/KevinOConnor/klipper/commits{/sha}","git_commits_url":"https://api.github.com/repos/KevinOConnor/klipper/git/commits{/sha}","comments_url":"https://api.github.com/repos/KevinOConnor/klipper/comments{/number}","issue_comment_url":"https://api.github.com/repos/KevinOConnor/klipper/issues/comments{/number}","contents_url":"https://api.github.com/repos/KevinOConnor/klipper/contents/{+path}","compare_url":"https://api.github.com/repos/KevinOConnor/klipper/compare/{base}...{head}","merges_url":"https://api.github.com/repos/KevinOConnor/klipper/merges","archive_url":"https://api.github.com/repos/KevinOConnor/klipper/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/KevinOConnor/klipper/downloads","issues_url":"https://api.github.com/repos/KevinOConnor/klipper/issues{/number}","pulls_url":"https://api.github.com/repos/KevinOConnor/klipper/pulls{/number}","milestones_url":"https://api.github.com/repos/KevinOConnor/klipper/milestones{/number}","notifications_url":"https://api.github.com/repos/KevinOConnor/klipper/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/KevinOConnor/klipper/labels{/name}","releases_url":"https://api.github.com/repos/KevinOConnor/klipper/releases{/id}","deployments_url":"https://api.github.com/repos/KevinOConnor/klipper/deployments","created_at":"2016-05-25T15:42:06Z","updated_at":"2018-07-28T14:07:14Z","pushed_at":"2018-07-27T17:07:10Z","git_url":"git://github.com/KevinOConnor/klipper.git","ssh_url":"git@github.com:KevinOConnor/klipper.git","clone_url":"https://github.com/KevinOConnor/klipper.git","svn_url":"https://github.com/KevinOConnor/klipper","homepage":null,"size":3868,"stargazers_count":649,"watchers_count":649,"language":"C","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":210,"mirror_url":null,"archived":false,"open_issues_count":31,"license":{"key":"gpl-3.0","name":"GNU General Public License v3.0","spdx_id":"GPL-3.0","url":"https://api.github.com/licenses/gpl-3.0","node_id":"MDc6TGljZW5zZTk="},"forks":210,"open_issues":31,"watchers":649,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500"},"html":{"href":"https://github.com/KevinOConnor/klipper/pull/500"},"issue":{"href":"https://api.github.com/repos/KevinOConnor/klipper/issues/500"},"comments":{"href":"https://api.github.com/repos/KevinOConnor/klipper/issues/500/comments"},"review_comments":{"href":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500/comments"},"review_comment":{"href":"https://api.github.com/repos/KevinOConnor/klipper/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/KevinOConnor/klipper/pulls/500/commits"},"statuses":{"href":"https://api.github.com/repos/KevinOConnor/klipper/statuses/2059d08a396c683e21b00dceabfcfc52d65eaa5d"}},"author_association":"CONTRIBUTOR"}}
{"actor":{"display_login":"KevinOConnor"}}