Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 1 | # |
| 2 | # BitBake Curses UI Implementation |
| 3 | # |
| 4 | # Implements an ncurses frontend for the BitBake utility. |
| 5 | # |
| 6 | # Copyright (C) 2006 Michael 'Mickey' Lauer |
| 7 | # Copyright (C) 2006-2007 Richard Purdie |
| 8 | # |
Brad Bishop | c342db3 | 2019-05-15 21:57:59 -0400 | [diff] [blame] | 9 | # SPDX-License-Identifier: GPL-2.0-only |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 10 | # |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 11 | |
| 12 | """ |
| 13 | We have the following windows: |
| 14 | |
| 15 | 1.) Main Window: Shows what we are ultimately building and how far we are. Includes status bar |
| 16 | 2.) Thread Activity Window: Shows one status line for every concurrent bitbake thread. |
| 17 | 3.) Command Line Window: Contains an interactive command line where you can interact w/ Bitbake. |
| 18 | |
| 19 | Basic window layout is like that: |
| 20 | |
| 21 | |---------------------------------------------------------| |
| 22 | | <Main Window> | <Thread Activity Window> | |
| 23 | | | 0: foo do_compile complete| |
| 24 | | Building Gtk+-2.6.10 | 1: bar do_patch complete | |
| 25 | | Status: 60% | ... | |
| 26 | | | ... | |
| 27 | | | ... | |
| 28 | |---------------------------------------------------------| |
| 29 | |<Command Line Window> | |
| 30 | |>>> which virtual/kernel | |
| 31 | |openzaurus-kernel | |
| 32 | |>>> _ | |
| 33 | |---------------------------------------------------------| |
| 34 | |
| 35 | """ |
| 36 | |
| 37 | |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 38 | |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 39 | import logging |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 40 | import os, sys, itertools, time |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 41 | |
| 42 | try: |
| 43 | import curses |
| 44 | except ImportError: |
| 45 | sys.exit("FATAL: The ncurses ui could not load the required curses python module.") |
| 46 | |
| 47 | import bb |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 48 | import xmlrpc.client |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 49 | from bb.ui import uihelper |
| 50 | |
Andrew Geissler | c9f7865 | 2020-09-18 14:11:35 -0500 | [diff] [blame] | 51 | logger = logging.getLogger(__name__) |
| 52 | |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 53 | parsespin = itertools.cycle( r'|/-\\' ) |
| 54 | |
| 55 | X = 0 |
| 56 | Y = 1 |
| 57 | WIDTH = 2 |
| 58 | HEIGHT = 3 |
| 59 | |
| 60 | MAXSTATUSLENGTH = 32 |
| 61 | |
| 62 | class NCursesUI: |
| 63 | """ |
| 64 | NCurses UI Class |
| 65 | """ |
| 66 | class Window: |
| 67 | """Base Window Class""" |
| 68 | def __init__( self, x, y, width, height, fg=curses.COLOR_BLACK, bg=curses.COLOR_WHITE ): |
| 69 | self.win = curses.newwin( height, width, y, x ) |
| 70 | self.dimensions = ( x, y, width, height ) |
| 71 | """ |
| 72 | if curses.has_colors(): |
| 73 | color = 1 |
| 74 | curses.init_pair( color, fg, bg ) |
| 75 | self.win.bkgdset( ord(' '), curses.color_pair(color) ) |
| 76 | else: |
| 77 | self.win.bkgdset( ord(' '), curses.A_BOLD ) |
| 78 | """ |
| 79 | self.erase() |
| 80 | self.setScrolling() |
| 81 | self.win.noutrefresh() |
| 82 | |
| 83 | def erase( self ): |
| 84 | self.win.erase() |
| 85 | |
| 86 | def setScrolling( self, b = True ): |
| 87 | self.win.scrollok( b ) |
| 88 | self.win.idlok( b ) |
| 89 | |
| 90 | def setBoxed( self ): |
| 91 | self.boxed = True |
| 92 | self.win.box() |
| 93 | self.win.noutrefresh() |
| 94 | |
| 95 | def setText( self, x, y, text, *args ): |
| 96 | self.win.addstr( y, x, text, *args ) |
| 97 | self.win.noutrefresh() |
| 98 | |
| 99 | def appendText( self, text, *args ): |
| 100 | self.win.addstr( text, *args ) |
| 101 | self.win.noutrefresh() |
| 102 | |
| 103 | def drawHline( self, y ): |
| 104 | self.win.hline( y, 0, curses.ACS_HLINE, self.dimensions[WIDTH] ) |
| 105 | self.win.noutrefresh() |
| 106 | |
| 107 | class DecoratedWindow( Window ): |
| 108 | """Base class for windows with a box and a title bar""" |
| 109 | def __init__( self, title, x, y, width, height, fg=curses.COLOR_BLACK, bg=curses.COLOR_WHITE ): |
| 110 | NCursesUI.Window.__init__( self, x+1, y+3, width-2, height-4, fg, bg ) |
| 111 | self.decoration = NCursesUI.Window( x, y, width, height, fg, bg ) |
| 112 | self.decoration.setBoxed() |
| 113 | self.decoration.win.hline( 2, 1, curses.ACS_HLINE, width-2 ) |
| 114 | self.setTitle( title ) |
| 115 | |
| 116 | def setTitle( self, title ): |
| 117 | self.decoration.setText( 1, 1, title.center( self.dimensions[WIDTH]-2 ), curses.A_BOLD ) |
| 118 | |
| 119 | #-------------------------------------------------------------------------# |
| 120 | # class TitleWindow( Window ): |
| 121 | #-------------------------------------------------------------------------# |
| 122 | # """Title Window""" |
| 123 | # def __init__( self, x, y, width, height ): |
| 124 | # NCursesUI.Window.__init__( self, x, y, width, height ) |
| 125 | # version = bb.__version__ |
| 126 | # title = "BitBake %s" % version |
| 127 | # credit = "(C) 2003-2007 Team BitBake" |
| 128 | # #self.win.hline( 2, 1, curses.ACS_HLINE, width-2 ) |
| 129 | # self.win.border() |
| 130 | # self.setText( 1, 1, title.center( self.dimensions[WIDTH]-2 ), curses.A_BOLD ) |
| 131 | # self.setText( 1, 2, credit.center( self.dimensions[WIDTH]-2 ), curses.A_BOLD ) |
| 132 | |
| 133 | #-------------------------------------------------------------------------# |
| 134 | class ThreadActivityWindow( DecoratedWindow ): |
| 135 | #-------------------------------------------------------------------------# |
| 136 | """Thread Activity Window""" |
| 137 | def __init__( self, x, y, width, height ): |
| 138 | NCursesUI.DecoratedWindow.__init__( self, "Thread Activity", x, y, width, height ) |
| 139 | |
| 140 | def setStatus( self, thread, text ): |
| 141 | line = "%02d: %s" % ( thread, text ) |
| 142 | width = self.dimensions[WIDTH] |
| 143 | if ( len(line) > width ): |
| 144 | line = line[:width-3] + "..." |
| 145 | else: |
| 146 | line = line.ljust( width ) |
| 147 | self.setText( 0, thread, line ) |
| 148 | |
| 149 | #-------------------------------------------------------------------------# |
| 150 | class MainWindow( DecoratedWindow ): |
| 151 | #-------------------------------------------------------------------------# |
| 152 | """Main Window""" |
| 153 | def __init__( self, x, y, width, height ): |
| 154 | self.StatusPosition = width - MAXSTATUSLENGTH |
| 155 | NCursesUI.DecoratedWindow.__init__( self, None, x, y, width, height ) |
| 156 | curses.nl() |
| 157 | |
| 158 | def setTitle( self, title ): |
| 159 | title = "BitBake %s" % bb.__version__ |
| 160 | self.decoration.setText( 2, 1, title, curses.A_BOLD ) |
| 161 | self.decoration.setText( self.StatusPosition - 8, 1, "Status:", curses.A_BOLD ) |
| 162 | |
| 163 | def setStatus(self, status): |
| 164 | while len(status) < MAXSTATUSLENGTH: |
| 165 | status = status + " " |
| 166 | self.decoration.setText( self.StatusPosition, 1, status, curses.A_BOLD ) |
| 167 | |
| 168 | |
| 169 | #-------------------------------------------------------------------------# |
| 170 | class ShellOutputWindow( DecoratedWindow ): |
| 171 | #-------------------------------------------------------------------------# |
| 172 | """Interactive Command Line Output""" |
| 173 | def __init__( self, x, y, width, height ): |
| 174 | NCursesUI.DecoratedWindow.__init__( self, "Command Line Window", x, y, width, height ) |
| 175 | |
| 176 | #-------------------------------------------------------------------------# |
| 177 | class ShellInputWindow( Window ): |
| 178 | #-------------------------------------------------------------------------# |
| 179 | """Interactive Command Line Input""" |
| 180 | def __init__( self, x, y, width, height ): |
| 181 | NCursesUI.Window.__init__( self, x, y, width, height ) |
| 182 | |
| 183 | # put that to the top again from curses.textpad import Textbox |
| 184 | # self.textbox = Textbox( self.win ) |
| 185 | # t = threading.Thread() |
| 186 | # t.run = self.textbox.edit |
| 187 | # t.start() |
| 188 | |
| 189 | #-------------------------------------------------------------------------# |
| 190 | def main(self, stdscr, server, eventHandler, params): |
| 191 | #-------------------------------------------------------------------------# |
| 192 | height, width = stdscr.getmaxyx() |
| 193 | |
| 194 | # for now split it like that: |
| 195 | # MAIN_y + THREAD_y = 2/3 screen at the top |
| 196 | # MAIN_x = 2/3 left, THREAD_y = 1/3 right |
| 197 | # CLI_y = 1/3 of screen at the bottom |
| 198 | # CLI_x = full |
| 199 | |
| 200 | main_left = 0 |
| 201 | main_top = 0 |
| 202 | main_height = ( height // 3 * 2 ) |
| 203 | main_width = ( width // 3 ) * 2 |
| 204 | clo_left = main_left |
| 205 | clo_top = main_top + main_height |
| 206 | clo_height = height - main_height - main_top - 1 |
| 207 | clo_width = width |
| 208 | cli_left = main_left |
| 209 | cli_top = clo_top + clo_height |
| 210 | cli_height = 1 |
| 211 | cli_width = width |
| 212 | thread_left = main_left + main_width |
| 213 | thread_top = main_top |
| 214 | thread_height = main_height |
| 215 | thread_width = width - main_width |
| 216 | |
| 217 | #tw = self.TitleWindow( 0, 0, width, main_top ) |
| 218 | mw = self.MainWindow( main_left, main_top, main_width, main_height ) |
| 219 | taw = self.ThreadActivityWindow( thread_left, thread_top, thread_width, thread_height ) |
| 220 | clo = self.ShellOutputWindow( clo_left, clo_top, clo_width, clo_height ) |
| 221 | cli = self.ShellInputWindow( cli_left, cli_top, cli_width, cli_height ) |
| 222 | cli.setText( 0, 0, "BB>" ) |
| 223 | |
| 224 | mw.setStatus("Idle") |
| 225 | |
| 226 | helper = uihelper.BBUIHelper() |
| 227 | shutdown = 0 |
| 228 | |
| 229 | try: |
| 230 | params.updateFromServer(server) |
| 231 | cmdline = params.parseActions() |
| 232 | if not cmdline: |
| 233 | print("Nothing to do. Use 'bitbake world' to build everything, or run 'bitbake --help' for usage information.") |
| 234 | return 1 |
| 235 | if 'msg' in cmdline and cmdline['msg']: |
| 236 | logger.error(cmdline['msg']) |
| 237 | return 1 |
| 238 | cmdline = cmdline['action'] |
| 239 | ret, error = server.runCommand(cmdline) |
| 240 | if error: |
| 241 | print("Error running command '%s': %s" % (cmdline, error)) |
| 242 | return |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 243 | elif not ret: |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 244 | print("Couldn't get default commandlind! %s" % ret) |
| 245 | return |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 246 | except xmlrpc.client.Fault as x: |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 247 | print("XMLRPC Fault getting commandline:\n %s" % x) |
| 248 | return |
| 249 | |
| 250 | exitflag = False |
| 251 | while not exitflag: |
| 252 | try: |
| 253 | event = eventHandler.waitEvent(0.25) |
| 254 | if not event: |
| 255 | continue |
| 256 | |
| 257 | helper.eventHandler(event) |
| 258 | if isinstance(event, bb.build.TaskBase): |
| 259 | mw.appendText("NOTE: %s\n" % event._message) |
| 260 | if isinstance(event, logging.LogRecord): |
| 261 | mw.appendText(logging.getLevelName(event.levelno) + ': ' + event.getMessage() + '\n') |
| 262 | |
| 263 | if isinstance(event, bb.event.CacheLoadStarted): |
| 264 | self.parse_total = event.total |
| 265 | if isinstance(event, bb.event.CacheLoadProgress): |
| 266 | x = event.current |
| 267 | y = self.parse_total |
| 268 | mw.setStatus("Loading Cache: %s [%2d %%]" % ( next(parsespin), x*100/y ) ) |
| 269 | if isinstance(event, bb.event.CacheLoadCompleted): |
| 270 | mw.setStatus("Idle") |
| 271 | mw.appendText("Loaded %d entries from dependency cache.\n" |
| 272 | % ( event.num_entries)) |
| 273 | |
| 274 | if isinstance(event, bb.event.ParseStarted): |
| 275 | self.parse_total = event.total |
| 276 | if isinstance(event, bb.event.ParseProgress): |
| 277 | x = event.current |
| 278 | y = self.parse_total |
| 279 | mw.setStatus("Parsing Recipes: %s [%2d %%]" % ( next(parsespin), x*100/y ) ) |
| 280 | if isinstance(event, bb.event.ParseCompleted): |
| 281 | mw.setStatus("Idle") |
| 282 | mw.appendText("Parsing finished. %d cached, %d parsed, %d skipped, %d masked.\n" |
| 283 | % ( event.cached, event.parsed, event.skipped, event.masked )) |
| 284 | |
| 285 | # if isinstance(event, bb.build.TaskFailed): |
| 286 | # if event.logfile: |
| 287 | # if data.getVar("BBINCLUDELOGS", d): |
| 288 | # bb.error("log data follows (%s)" % logfile) |
| 289 | # number_of_lines = data.getVar("BBINCLUDELOGS_LINES", d) |
| 290 | # if number_of_lines: |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 291 | # subprocess.check_call('tail -n%s %s' % (number_of_lines, logfile), shell=True) |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 292 | # else: |
| 293 | # f = open(logfile, "r") |
| 294 | # while True: |
| 295 | # l = f.readline() |
| 296 | # if l == '': |
| 297 | # break |
| 298 | # l = l.rstrip() |
| 299 | # print '| %s' % l |
| 300 | # f.close() |
| 301 | # else: |
| 302 | # bb.error("see log in %s" % logfile) |
| 303 | |
| 304 | if isinstance(event, bb.command.CommandCompleted): |
| 305 | # stop so the user can see the result of the build, but |
| 306 | # also allow them to now exit with a single ^C |
| 307 | shutdown = 2 |
| 308 | if isinstance(event, bb.command.CommandFailed): |
Brad Bishop | d7bf8c1 | 2018-02-25 22:55:05 -0500 | [diff] [blame] | 309 | mw.appendText(str(event)) |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 310 | time.sleep(2) |
| 311 | exitflag = True |
| 312 | if isinstance(event, bb.command.CommandExit): |
| 313 | exitflag = True |
| 314 | if isinstance(event, bb.cooker.CookerExit): |
| 315 | exitflag = True |
| 316 | |
| 317 | if isinstance(event, bb.event.LogExecTTY): |
| 318 | mw.appendText('WARN: ' + event.msg + '\n') |
| 319 | if helper.needUpdate: |
| 320 | activetasks, failedtasks = helper.getTasks() |
| 321 | taw.erase() |
| 322 | taw.setText(0, 0, "") |
| 323 | if activetasks: |
| 324 | taw.appendText("Active Tasks:\n") |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 325 | for task in activetasks.values(): |
Patrick Williams | c124f4f | 2015-09-15 14:41:29 -0500 | [diff] [blame] | 326 | taw.appendText(task["title"] + '\n') |
| 327 | if failedtasks: |
| 328 | taw.appendText("Failed Tasks:\n") |
| 329 | for task in failedtasks: |
| 330 | taw.appendText(task["title"] + '\n') |
| 331 | |
| 332 | curses.doupdate() |
| 333 | except EnvironmentError as ioerror: |
| 334 | # ignore interrupted io |
| 335 | if ioerror.args[0] == 4: |
| 336 | pass |
| 337 | |
| 338 | except KeyboardInterrupt: |
| 339 | if shutdown == 2: |
| 340 | mw.appendText("Third Keyboard Interrupt, exit.\n") |
| 341 | exitflag = True |
| 342 | if shutdown == 1: |
| 343 | mw.appendText("Second Keyboard Interrupt, stopping...\n") |
| 344 | _, error = server.runCommand(["stateForceShutdown"]) |
| 345 | if error: |
| 346 | print("Unable to cleanly stop: %s" % error) |
| 347 | if shutdown == 0: |
| 348 | mw.appendText("Keyboard Interrupt, closing down...\n") |
| 349 | _, error = server.runCommand(["stateShutdown"]) |
| 350 | if error: |
| 351 | print("Unable to cleanly shutdown: %s" % error) |
| 352 | shutdown = shutdown + 1 |
| 353 | pass |
| 354 | |
| 355 | def main(server, eventHandler, params): |
| 356 | if not os.isatty(sys.stdout.fileno()): |
| 357 | print("FATAL: Unable to run 'ncurses' UI without a TTY.") |
| 358 | return |
| 359 | ui = NCursesUI() |
| 360 | try: |
| 361 | curses.wrapper(ui.main, server, eventHandler, params) |
| 362 | except: |
| 363 | import traceback |
| 364 | traceback.print_exc() |