#!/usr/bin/env python import curses import time import six # on-screen elements # Status # - status (Idle, and so on) # - position (as read from status) # - spindle (running cw/ccw, off) # ref: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Interface # Joggging # - numpad style layout (arrows, pgup/dn) # - radio selection of jog rates (<,>) # - # # Layout (80 x 24) ################################################################################ class Widget(object): pass class Label(Widget): def __init__(self, window, row, col, len, text='', prefix=''): self.window = window self.row = row self.col = col self.len = len self._text = text self.prefix = prefix self.render() def render(self): text = self._text + (' ' * max(0, self.len - len(self._text))) self.window.addstr(self.row, self.col, self.prefix + text) @property def text(self): return self._text @text.setter def text(self, val): self._text = val self.render() class NumberLabel(Widget): def __init__(self, window, row, col, value=0.0): self.window = window self.row = row self.col = col self._value = value self.render() def render(self): val_text = "{:>8s}".format("{:.3f}".format(self.value)) self.window.addstr(self.row, self.col, val_text) @property def value(self): return self._value @value.setter def value(self, v): self._value = v self.render() class Button(Widget): def __init__(self, window, label, row, col): self.window = window self.label = label self.row = row self.col = col self.render() def flash(self): self.render(strong=True) self.window.refresh() time.sleep(0.1) self.render() self.window.refresh() def render(self, strong=False): addstr_params = [self.row, self.col, '[{}]'.format(self.label)] if strong: addstr_params.append(curses.A_BOLD) self.window.addstr(*addstr_params) class Banner(Widget): label_col = 4 # text def __init__(self, window, label=None, row=0): self.window = window self._label = label self.row = row self.render() def render(self): (y, x) = self.window.getmaxyx() label_str = " {} ".format(self._label) self.window.hline(0, 0, curses.ACS_HLINE, x) self.window.addstr(self.row, self.label_col, " {} ".format(self._label)) @property def label(self): return self._label @label.setter def label(self, value): self._label = value self.render() class Status(object): row = 0 def __init__(self, screen): self.screen = screen (max_y, max_x) = self.screen.getmaxyx() self.window = curses.newwin(5, max_x, self.row, 0) self.banner = Banner(self.window, 'Status: Idle') # Print backdrop (to use as template) backdrop = [ 'Jog: [xxxx.yyy ] MPos WPos', ' [Y+] [Z+] X |xxxx.yyy |xxxx.yyy | Spindle: On (1000rpm)', '[X-] [X+] Y |xxxx.yyy |xxxx.yyy | Coolant: Off', ' [Y-] [Z-] Z |xxxx.yyy |xxxx.yyy | Feed Rate: 100', ] for (row, line) in enumerate(backdrop): self.window.addstr(row + 1, 0, line[:max_x]) # Widgets self.widgets = { 'X+': Button(self.window, 'X+', 3, 6), 'X-': Button(self.window, 'X-', 3, 0), 'Y+': Button(self.window, 'Y+', 2, 3), 'Y-': Button(self.window, 'Y-', 4, 3), 'Z+': Button(self.window, 'Z+', 2, 11), 'Z-': Button(self.window, 'Z-', 4, 11), 'jog': NumberLabel(self.window, 1, 6, 0.001), 'MPosX': NumberLabel(self.window, 2, 21), 'MPosY': NumberLabel(self.window, 3, 21), 'MPosZ': NumberLabel(self.window, 4, 21), 'WPosX': NumberLabel(self.window, 2, 31), 'WPosY': NumberLabel(self.window, 3, 31), 'WPosZ': NumberLabel(self.window, 4, 31), 'spindle': Label(self.window, 2, 44, len=20, prefix='Spindle: '), 'coolant': Label(self.window, 3, 44, len=20, prefix='Coolant: '), 'feed_rate': Label(self.window, 4, 44, len=20, prefix='Feed Rate: '), } self.refresh() def set_status(self, label): self.banner.label = "Status: {}".format(label) self.refresh() def refresh(self): self.window.refresh() # Line types: # gcode: <'>' if cur> > # info: <'>' if cur> class GCodeContent(object): def __init__(self, gcode, sent=False, status=''): self.gcode = gcode self.sent = sent self.status = status def to_str(self, width): gcode_w = max(0, width - 23) return ("{gcode:<%i.%is} {sent} {status:<20s}" % (gcode_w, gcode_w)).format( gcode=self.gcode, sent='>' if self.sent else ' ', status=self.status, ) class ConsoleLine(object): def __init__(self, window, content, cur=False): self.window = window self.content = content self._cur = cur self._width = 0 self.update_width() def render(self, row): line = '> ' if self._cur else ' ' if isinstance(self.content, GCodeContent): line += self.content.to_str(max(0, self._width - len(line))) else: line += str(self.content) self.window.addstr(row, 0, line) def update_width(self): (_, self._width) = self.window.getmaxyx() @property def cur(self): return self._cur @cur.setter def cur(self, value): self._cur = value class AccordionWindow(object): """ A region of the screen that can push those above and below it around. eg: as an active accordion window's content grows, it can push the window below it down, and make it smaller. """ def __init__(self, screen, title, soft_height=2, min_height=0): self.screen = screen self.title = title self.soft_height = soft_height self.min_height = min_height self.focus = False self.lines = [] # list of ConsoleLine instances (first are at the top) self.window = None self.banner = None def init_window(self, row, height): self.window = curses.newwin(height, self.screen.getmaxyx()[1], row, 0) self.banner = Banner(self.window, self.title) def add_line(self, line): self.lines.append(line) def move_window(self, row, height): self.window.resize(height, self.screen.getmaxyx()[1]) self.window.mvwin(row, 0) self.window.clear() #self.window.box() #self.window.addstr(0, 5, self.title) self.banner.render() self.refresh() def __eq__(self, other): return self.title == other.title def refresh(self): self.window.refresh() class AccordionWindowManager(object): def __init__(self, screen, windows, header_height, footer_height=0): self.screen = screen self.windows = windows self.header_height = header_height self.footer_height = footer_height self._focus_index = 0 self._log = [] # Initialize accordion window's curses.Window instance cur_row = self.header_height for (i, window) in enumerate(self.windows): window.init_window(cur_row, window.soft_height) window.refresh() cur_row += window.soft_height @property def focus(self): return self.windows[self._focus_index] @focus.setter def focus(self, value): self._focus_index = self.windows.index(value) self.update_distrobution() def update_distrobution(self): # rows available across space rows = self.screen.getmaxyx()[0] - (self.header_height + self.footer_height) min_occupied = sum(w.min_height for w in self.windows) available = rows - min_occupied if rows - min_occupied < 3: pass # TODO: not enough room to respect min_heights # focussed window height # - self.focus.min_height # - len(self.focus.lines) + 1 # - available + self.focus.min_height # max available space extra_alocate = max(0, (len(self.focus.lines) + 1) - self.focus.min_height) height_f = min(available, extra_alocate) + self.focus.min_height available -= height_f - self.focus.min_height cur_row = self.header_height for w in self.windows: if w == self.focus: height = height_f else: extra_alocate = max(0, (len(w.lines) + 1) - w.min_height) height = min(available, extra_alocate) + w.min_height available -= height - w.min_height w.move_window(cur_row, height) cur_row += height #rows_available = self.screen.getmaxyx()[0] - (self.header_height + self.footer_height) # #focus_rows_available = rows_available - sum(w.min_height for (i, w) in enumerate(self.windows) if i != self._focus_index) #focus_rows = min(focus_rows_available, max(self.focus.min_height, len(self.focus.lines) + 1)) #non_focus_rows_available = rows_available - focus_rows #cur_row = self.header_height #heights = [] #for (i, window) in enumerate(self.windows): # if i == self._focus_index: # height = focus_rows # else: # height = min(non_focus_rows_available, max(window.min_height, len(window.lines) + 1)) # non_focus_rows_available -= height # # heights.append(height) # try: # window.move_window(cur_row, max(1, height)) # except Exception as e: # raise RuntimeError({ # 'rows_available': rows_available, # 'focus_rows_available': focus_rows_available, # 'non_focus_rows_available': non_focus_rows_available, # 'cur_row': cur_row, # 'height': height, # 'window': window, # 'focus': self.focus, # }) # cur_row += height #self.screen.addstr(0, 0, "heights = %s" % str(heights)) class InitWindow(AccordionWindow): pass class JoggingWindow(AccordionWindow): pass class StreamWindow(AccordionWindow): pass JOGGING_VALUES = [ 0.001, 0.01, 0.1, 1, 10, 25, 50, 100, 250, 500 ] def jog_value(cur_val, dindex): try: i = JOGGING_VALUES.index(cur_val) except ValueError: i = 0 i = max(0, min(i + dindex, len(JOGGING_VALUES) - 1)) return JOGGING_VALUES[i] def main(screen): # Clear screen screen.clear() screen.refresh() status = Status(screen=screen) init = InitWindow(screen=screen, title='GRBL Init: /dev/ttyACM0', min_height=2) jogging = JoggingWindow(screen=screen, title='Jogging', min_height=2) stream = StreamWindow(screen=screen, title='Stream: some_file.ngc', min_height=6) #init.lines = list(range(100)) #jogging.lines = list(range(100)) #stream.lines = list(range(100)) accordion = AccordionWindowManager( screen=screen, windows=[init, jogging, stream], header_height=status.window.getmaxyx()[0], footer_height=0, # FIXME ) # ----------- Initialize GRBL # ----------- Jogging, user input while True: k = screen.getkey() if k in ('q', 'Q'): break # Jogging Keys elif k == "KEY_RIGHT": status.widgets['MPosX'].value += status.widgets['jog'].value # FIXME status.widgets['X+'].flash() elif k == "KEY_LEFT": status.widgets['MPosX'].value -= status.widgets['jog'].value # FIXME status.widgets['X-'].flash() elif k == "KEY_UP": status.widgets['MPosY'].value += status.widgets['jog'].value # FIXME status.widgets['Y+'].flash() elif k == "KEY_DOWN": status.widgets['MPosY'].value -= status.widgets['jog'].value # FIXME status.widgets['Y-'].flash() elif k == "KEY_PPAGE": status.widgets['MPosZ'].value += status.widgets['jog'].value # FIXME status.widgets['Z+'].flash() elif k == "KEY_NPAGE": status.widgets['MPosZ'].value -= status.widgets['jog'].value # FIXME status.widgets['Z-'].flash() # Jogging Increment (up/down) elif k in '[],.': status.widgets['jog'].value = jog_value( status.widgets['jog'].value, {'[': 1, ',': 1, ']': -1, '.': -1}[k] ) # Testing elif k == 'a': init.add_line('abc') accordion.update_distrobution() elif k == 'A': accordion.focus = init elif k == 's': jogging.add_line('abc') accordion.update_distrobution() elif k == 'S': accordion.focus = jogging elif k == 'd': stream.add_line('abc') accordion.update_distrobution() elif k == 'D': accordion.focus = stream elif k == ' ': # Zero WPos coordinates status.set_status('Zero Work Offsets...') # FIXME elif k == '\n': # Jogging complete, begin stream status.set_status('Begin Stream...') # FIXME #elif k == 'KEY_RESIZE': # # dummy keypress on console window resize # pass # FIXME: re-render everything (?) else: status.set_status(k) # FIXME: remove status.refresh() curses.wrapper(main) """ Example Output... ---- Status: Idle -------------------------------------------------------------- Jog: [ xx.yyy ] MPos WPos [Y+] [Z+] X: | xxx.yyy | xxx.yyy | Spindle: On (1000rpm) [X-] [X+] Y: | xxx.yyy | xxx.yyy | Coolant: Off [Y-] [Z-] Z: | xxx.yyy | xxx.yyy | Feed Rate: 100 ---- GRBL Init: /dev/ttyACM0 --------------------------------------------------- Grbl 1.1f ['$' for help] [GC:G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0] ---- Jogging ------------------------------------------------------------------- G91 > ok G00 X-1 > ok G00 Y20 > ok > awaiting input ... [space] zero offset, [enter] start stream ---- Stream: some_file.ngc ----------------------------------------------------- G17 G20 G40 G49 G54 G80 G90 G94 > T1M06 > F47.244 > S1000 > G00 Z0.197 > M03 > G04 P1 > G00X1.0961Y0.0126Z0.1969 > G01 Z0.0000 F5.9055 > F47.2441 > X1.0961 Y0.0126 Z0.0000 > X0.3182 Y0.0126 Z0.0000 > X0.1454 Y0.0126 Z0.0000 > X0.0432 Y0.0126 Z0.0000 > X0.0039 Y0.0126 Z0.0000 > Z0.1969 > G00 X1.0961 Y0.0293 Z0.1969 """