From 038120ee4ca772735b93018ff04b677724e452f6 Mon Sep 17 00:00:00 2001 From: Joseph Coffland Date: Wed, 20 Feb 2019 15:58:50 -0800 Subject: [PATCH] Demo mode, Right click to enable camera crosshair. --- CHANGELOG.md | 3 + scripts/install.sh | 6 - src/avr/emu/src/emu.c | 3 + src/js/admin-general-view.js | 6 +- src/js/admin-network-view.js | 16 +- src/js/api.js | 11 ++ src/js/app.js | 16 +- src/js/control-view.js | 2 +- src/js/main.js | 39 ++++ src/pug/index.pug | 14 +- src/py/bbctrl/APIHandler.py | 34 +--- src/py/bbctrl/AVR.py | 21 +- src/py/bbctrl/AVREmu.py | 20 +- src/py/bbctrl/Camera.py | 76 ++++---- src/py/bbctrl/Cmd.py | 16 +- src/py/bbctrl/Comm.py | 29 ++- src/py/bbctrl/CommandQueue.py | 18 +- src/py/bbctrl/Config.py | 59 ++---- src/py/bbctrl/Ctrl.py | 40 ++-- src/py/bbctrl/FileHandler.py | 30 ++- src/py/bbctrl/{GCodeStream.py => IOLoop.py} | 70 +++---- src/py/bbctrl/Jog.py | 6 +- src/py/bbctrl/LCD.py | 9 +- src/py/bbctrl/Log.py | 138 +++++++++++++ src/py/bbctrl/Mach.py | 72 +++---- src/py/bbctrl/Planner.py | 47 +++-- src/py/bbctrl/Preplanner.py | 31 ++- src/py/bbctrl/Pwr.py | 36 ++-- .../bbctrl/{Messages.py => RequestHandler.py} | 50 +++-- src/py/bbctrl/State.py | 41 ++-- src/py/bbctrl/Web.py | 184 +++++++++++------- src/py/bbctrl/__init__.py | 47 +---- .../resources/buildbotics.nc | 0 src/resources/images/in-use.jpg | Bin 7921 -> 11880 bytes src/resources/images/offline.jpg | Bin 9608 -> 12970 bytes src/stylus/style.styl | 36 +++- 36 files changed, 715 insertions(+), 511 deletions(-) rename src/py/bbctrl/{GCodeStream.py => IOLoop.py} (66%) create mode 100644 src/py/bbctrl/Log.py rename src/py/bbctrl/{Messages.py => RequestHandler.py} (67%) rename scripts/buildbotics.gc => src/resources/buildbotics.nc (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c872b1f..8325c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Buildbotics CNC Controller Firmware Changelog ## v0.4.6 - Fixed a rare ``Negative s-curve time`` error. + - Don't allow manual axis homing when soft limits are not set. + - Right click to enable camera crosshair. + - Demo mode. ## v0.4.5 - Fix for random errors while running VFD. diff --git a/scripts/install.sh b/scripts/install.sh index 11f64d3..49965e8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -118,12 +118,6 @@ cp scripts/xinitrc ~pi/.xinitrc chmod +x ~pi/.xinitrc chown pi:pi ~pi/.xinitrc -# Install default GCode -if [ ! -d /var/lib/bbctrl/upload -o -z "$(ls -A /var/lib/bbctrl/upload)" ]; then - mkdir -p /var/lib/bbctrl/upload/ - cp scripts/buildbotics.gc /var/lib/bbctrl/upload/ -fi - # Install rc.local cp scripts/rc.local /etc/ diff --git a/src/avr/emu/src/emu.c b/src/avr/emu/src/emu.c index c420758..a0b06a5 100644 --- a/src/avr/emu/src/emu.c +++ b/src/avr/emu/src/emu.c @@ -83,6 +83,9 @@ void emu_init() { // So usart_flush() returns SERIAL_PORT.STATUS = USART_DREIF_bm | USART_TXCIF_bm; + // Clear motor fault + PIN_PORT(MOTOR_FAULT_PIN)->IN |= PIN_BM(MOTOR_FAULT_PIN); + FD_ZERO(&readFDs); } diff --git a/src/js/admin-general-view.js b/src/js/admin-general-view.js index 2d04bbc..6c5d5fc 100644 --- a/src/js/admin-general-view.js +++ b/src/js/admin-general-view.js @@ -82,7 +82,7 @@ module.exports = { try { config = JSON.parse(e.target.result); } catch (ex) { - alert("Invalid config file"); + api.alert("Invalid config file"); return; } @@ -91,7 +91,7 @@ module.exports = { this.configRestored = true; }.bind(this)).fail(function (error) { - alert('Restore failed: ' + error); + api.alert('Restore failed', error); }) }.bind(this); @@ -106,7 +106,7 @@ module.exports = { this.configReset = true; }.bind(this)).fail(function (error) { - alert('Reset failed: ' + error); + api.alert('Reset failed', error); }); }, diff --git a/src/js/admin-network-view.js b/src/js/admin-network-view.js index 2572a15..4268d35 100644 --- a/src/js/admin-network-view.js +++ b/src/js/admin-network-view.js @@ -100,7 +100,7 @@ module.exports = { }.bind(this)); }.bind(this)).fail(function (error) { - alert('Set hostname failed: ' + JSON.stringify(error)); + api.alert('Set hostname failed', error); }) }, @@ -109,19 +109,19 @@ module.exports = { api.put('remote/username', {username: this.username}).done(function () { this.usernameSet = true; }.bind(this)).fail(function (error) { - alert('Set username failed: ' + JSON.stringify(error)); + api.alert('Set username failed', error); }) }, set_password: function () { if (this.password != this.password2) { - alert('Passwords to not match'); + api.alert('Passwords to not match'); return; } if (this.password.length < 6) { - alert('Password too short'); + api.alert('Password too short'); return; } @@ -131,13 +131,14 @@ module.exports = { }).done(function () { this.passwordSet = true; }.bind(this)).fail(function (error) { - alert('Set password failed: ' + JSON.stringify(error)); + api.alert('Set password failed', error); }) }, config_wifi: function () { this.wifiConfirm = false; + this.rebooting = true; var config = { @@ -148,8 +149,9 @@ module.exports = { } api.put('wifi', config).fail(function (error) { - alert('Failed to configure WiFi: ' + JSON.stringify(error)); - }) + api.alert('Failed to configure WiFi', error); + this.rebooting = false; + }.bind(this)) } } } diff --git a/src/js/api.js b/src/js/api.js index 251d546..8c07a02 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -89,5 +89,16 @@ module.exports = { 'delete': function (url, config) { return api_cb('DELETE', url, undefined, config); + }, + + + alert: function (msg, error) { + if (typeof error != 'undefined') { + if (typeof error.message != 'undefined') + msg += '\n' + error.message; + else msg += '\n' + JSON.stringify(error); + } + + alert(msg); } } diff --git a/src/js/app.js b/src/js/app.js index 8ba50dc..aeb185f 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -106,6 +106,7 @@ module.exports = new Vue({ state: {}, messages: [], video_size: cookie.get('video-size', 'small'), + crosshair: cookie.get('crosshair', false), errorTimeout: 30, errorTimeoutStart: 0, errorShow: false, @@ -218,13 +219,20 @@ module.exports = new Vue({ }, - toggle_video: function () { + toggle_video: function (e) { if (this.video_size == 'small') this.video_size = 'large'; else if (this.video_size == 'large') this.video_size = 'small'; cookie.set('video-size', this.video_size); }, + toggle_crosshair: function (e) { + e.preventDefault(); + this.crosshair = !this.crosshair; + cookie.set('crosshair', this.crosshair); + }, + + estop: function () { if (this.state.xx == 'ESTOPPED') api.put('clear'); else api.put('estop'); @@ -238,7 +246,7 @@ module.exports = new Vue({ this.firmwareUpgrading = true; }.bind(this)).fail(function () { - alert('Invalid password'); + api.alert('Invalid password'); }.bind(this)) }, @@ -262,7 +270,7 @@ module.exports = new Vue({ this.firmwareUpgrading = true; }.bind(this)).error(function () { - alert('Invalid password or bad firmware'); + api.alert('Invalid password or bad firmware'); }.bind(this)) }, @@ -356,7 +364,7 @@ module.exports = new Vue({ api.put('config/save', this.config).done(function (data) { this.modified = false; }.bind(this)).fail(function (error) { - alert('Save failed: ' + error); + api.alert('Save failed', error); }); }, diff --git a/src/js/control-view.js b/src/js/control-view.js index e756c91..46c199a 100644 --- a/src/js/control-view.js +++ b/src/js/control-view.js @@ -281,7 +281,7 @@ module.exports = { this.$broadcast('gcode-reload', file.name); }.bind(this)).fail(function (error) { - alert('Upload failed: ' + error) + api.alert('Upload failed', error) }.bind(this)); }, diff --git a/src/js/main.js b/src/js/main.js index 336a306..c3ebf6c 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -28,7 +28,46 @@ 'use strict'; +function cookie_get(name) { + var decodedCookie = decodeURIComponent(document.cookie); + var ca = decodedCookie.split(';'); + name = name + '='; + + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') c = c.substring(1); + if (!c.indexOf(name)) return c.substring(name.length, c.length); + } +} + + +function cookie_set(name, value, days) { + var d = new Date(); + d.setTime(d.getTime() + days * 24 * 60 * 60 * 1000); + var expires = 'expires=' + d.toUTCString(); + document.cookie = name + '=' + value + ';' + expires + ';path=/'; +} + + +var uuid_chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_+'; + + +function uuid(length) { + if (typeof length == 'undefined') length = 52; + + var s = ''; + for (var i = 0; i < length; i++) + s += uuid_chars[Math.floor(Math.random() * uuid_chars.length)]; + + return s +} + + $(function() { + if (typeof cookie_get('client-id') == 'undefined') + cookie_set('client-id', uuid(), 10000); + // Vue debugging Vue.config.debug = true; //Vue.util.warn = function (msg) {console.debug('[Vue warn]: ' + msg)} diff --git a/src/pug/index.pug b/src/pug/index.pug index dfd0647..f960c0c 100644 --- a/src/pug/index.pug +++ b/src/pug/index.pug @@ -96,7 +96,8 @@ html(lang="en") span.left Build span.right botics .subtitle - | Machine Controller v{{config.version}} + | CNC Controller #[b {{state.demo ? 'Demo ' : ''}}] + | v{{config.version}} a.upgrade-version(v-if="show_upgrade()", href="#admin-general") | Upgrade to v{{latestVersion}} .fa.fa-check(v-if="!show_upgrade() && latestVersion", @@ -105,8 +106,15 @@ html(lang="en") .estop(:class="{active: state.es}") estop(@click="estop") - .video(title="Plug camera into USB.\nClick to change video size.") - img(src="/api/video", @click="toggle_video", :class="video_size") + .video(title="Plug camera into USB.\n" + + "Left click to toggle video size.\n" + + "Right click to toggle crosshair.", @click="toggle_video", + @contextmenu="toggle_crosshair", :class="video_size") + .crosshair(v-if="crosshair") + .vertical + .horizontal + .box + img(src="/api/video") .clear diff --git a/src/py/bbctrl/APIHandler.py b/src/py/bbctrl/APIHandler.py index 10bde7b..c21ca56 100644 --- a/src/py/bbctrl/APIHandler.py +++ b/src/py/bbctrl/APIHandler.py @@ -27,32 +27,13 @@ import json import traceback -import logging +import bbctrl -from tornado.web import RequestHandler, HTTPError +from tornado.web import HTTPError import tornado.httpclient -log = logging.getLogger('API') -log.setLevel(logging.DEBUG) - - -class APIHandler(RequestHandler): - def __init__(self, app, request, **kwargs): - super().__init__(app, request, **kwargs) - self.ctrl = app.ctrl - - - # Override exception logging - def log_exception(self, typ, value, tb): - if isinstance(value, HTTPError) and value.status_code in (404, 408): - return - - log.error(str(value)) - trace = ''.join(traceback.format_exception(typ, value, tb)) - log.debug(trace) - - +class APIHandler(bbctrl.RequestHandler): def delete(self, *args, **kwargs): self.delete_ok(*args, **kwargs) self.write_json('ok') @@ -76,7 +57,7 @@ class APIHandler(RequestHandler): try: self.json = tornado.escape.json_decode(self.request.body) except ValueError: - raise HTTPError(400, 'Unable to parse JSON.') + raise HTTPError(400, 'Unable to parse JSON') def set_default_headers(self): @@ -87,8 +68,13 @@ class APIHandler(RequestHandler): e = {} if 'message' in kwargs: e['message'] = kwargs['message'] + elif 'exc_info' in kwargs: - e['message'] = str(kwargs['exc_info'][1]) + typ, value, tb = kwargs['exc_info'] + if isinstance(value, HTTPError) and value.log_message: + e['message'] = value.log_message % value.args + else: e['message'] = str(kwargs['exc_info'][1]) + else: e['message'] = 'Unknown error' e['code'] = status_code diff --git a/src/py/bbctrl/AVR.py b/src/py/bbctrl/AVR.py index 528f00d..a54354c 100644 --- a/src/py/bbctrl/AVR.py +++ b/src/py/bbctrl/AVR.py @@ -26,25 +26,28 @@ ################################################################################ import serial -import logging import time import traceback import bbctrl import bbctrl.Cmd as Cmd -log = logging.getLogger('AVR') class AVR(object): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('AVR') + self.sp = None self.i2c_addr = ctrl.args.avr_addr self.read_cb = None self.write_cb = None + def close(self): pass + + def _start(self): try: self.sp = serial.Serial(self.ctrl.args.serial, self.ctrl.args.baud, @@ -53,7 +56,7 @@ class AVR(object): except Exception as e: self.sp = None - log.warning('Failed to open serial port: %s', e) + self.log.warning('Failed to open serial port: %s', e) if self.sp is not None: self.ctrl.ioloop.add_handler(self.sp, self._serial_handler, @@ -62,7 +65,7 @@ class AVR(object): def set_handlers(self, read_cb, write_cb): if self.read_cb is not None or self.write_cb is not None: - raise Exception('AVR handler already set') + raise Exception('Handler already set') self.read_cb = read_cb self.write_cb = write_cb @@ -87,7 +90,7 @@ class AVR(object): self.read_cb(data) except Exception as e: - log.warning('%s: %s', e, data) + self.log.warning('%s: %s', e, data) def _serial_handler(self, fd, events): @@ -96,11 +99,11 @@ class AVR(object): if self.ctrl.ioloop.WRITE & events: self._serial_write() except Exception as e: - log.warning('Serial handler error: %s', traceback.format_exc()) + self.log.warning('Serial handler error: %s', traceback.format_exc()) def i2c_command(self, cmd, byte = None, word = None, block = None): - log.info('I2C: %s b=%s w=%s d=%s' % (cmd, byte, word, block)) + self.log.info('I2C: %s b=%s w=%s d=%s' % (cmd, byte, word, block)) retry = 5 cmd = ord(cmd[0]) @@ -113,10 +116,10 @@ class AVR(object): retry -= 1 if retry: - log.warning('AVR I2C failed, retrying: %s' % e) + self.log.warning('I2C failed, retrying: %s' % e) time.sleep(0.1) continue else: - log.error('AVR I2C failed: %s' % e) + self.log.error('I2C failed: %s' % e) raise diff --git a/src/py/bbctrl/AVREmu.py b/src/py/bbctrl/AVREmu.py index f06add3..02ee250 100644 --- a/src/py/bbctrl/AVREmu.py +++ b/src/py/bbctrl/AVREmu.py @@ -25,20 +25,20 @@ # # ################################################################################ -import logging import os import sys import traceback +import signal import bbctrl import bbctrl.Cmd as Cmd -log = logging.getLogger('AVREmu') - class AVREmu(object): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('AVREmu') + self.avrOut = None self.avrIn = None self.i2cOut = None @@ -47,6 +47,13 @@ class AVREmu(object): self.pid = None + def close(self): + if self.pid is None: return + os.kill(self.pid, signal.SIGINT) + os.waitpid(self.pid, 0) + self.pid = None + + def _start(self): try: if self.pid is not None: os.waitpid(self.pid, 0) @@ -104,7 +111,7 @@ class AVREmu(object): except Exception as e: self.pid = None self.avrOut, self.avrIn, self.i2cOut = None, None, None - log.exception('Failed to start bbemu') + self.log.exception('Failed to start bbemu') def set_handlers(self, read_cb, write_cb): @@ -150,7 +157,8 @@ class AVREmu(object): if not self.continue_write: break except Exception as e: - log.warning('AVR write handler error: %s', traceback.format_exc()) + self.log.warning('AVR write handler error: %s', + traceback.format_exc()) def _avr_read_handler(self, fd, events): @@ -165,7 +173,7 @@ class AVREmu(object): if data is not None: self.read_cb(data) except Exception as e: - log.warning('AVR read handler error: %s %s' % + self.log.warning('AVR read handler error: %s %s' % (data, traceback.format_exc())) diff --git a/src/py/bbctrl/Camera.py b/src/py/bbctrl/Camera.py index c0bfdde..f8ab73b 100755 --- a/src/py/bbctrl/Camera.py +++ b/src/py/bbctrl/Camera.py @@ -28,7 +28,6 @@ import os import fcntl -import logging import select import struct import mmap @@ -43,8 +42,6 @@ try: except: import bbctrl.v4l2 as v4l2 -log = logging.getLogger('Camera') - def array_to_string(a): return ''.join([chr(i) for i in a]) @@ -273,13 +270,14 @@ class VideoDevice(object): if self.fd is None: return try: os.close(self.fd) - except Exception as e: log.warning('While closing camera: %s', e) finally: self.fd = None class Camera(object): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('Camera') + self.width = ctrl.args.width self.height = ctrl.args.height self.fps = ctrl.args.fps @@ -323,7 +321,7 @@ class Camera(object): try: self.clients[-1].write_frame(format_frame(frame)) except Exception as e: - log.warning('Failed to write frame to client: %s' % e) + self.log.warning('Failed to write frame to client: %s' % e) def _fd_handler(self, fd, events): @@ -337,7 +335,7 @@ class Camera(object): except Exception as e: if isinstance(e, BlockingIOError): return - log.warning('Failed to read from camera.') + self.log.warning('Failed to read from camera.') self.ctrl.ioloop.remove_handler(fd) self.close() return @@ -350,15 +348,15 @@ class Camera(object): self.dev = VideoDevice(path) caps = self.dev.get_info() - log.info('%s, %s, %s, %s', caps._driver, caps._card, caps._bus_info, - caps._caps) + self.log.info('%s, %s, %s, %s', caps._driver, caps._card, + caps._bus_info, caps._caps) if caps.capabilities & v4l2.V4L2_CAP_VIDEO_CAPTURE == 0: raise Exception('Video capture not supported.') - log.info('Formats: %s', self.dev.get_formats()) - log.info('Sizes: %s', self.dev.get_frame_sizes(self.fourcc)) - log.info('Audio: %s', self.dev.get_audio()) + self.log.info('Formats: %s', self.dev.get_formats()) + self.log.info('Sizes: %s', self.dev.get_frame_sizes(self.fourcc)) + self.log.info('Audio: %s', self.dev.get_audio()) self.dev.set_format(self.width, self.height, fourcc = self.fourcc) self.dev.set_fps(self.fps) @@ -368,14 +366,21 @@ class Camera(object): self.ctrl.ioloop.add_handler(self.dev, self._fd_handler, self.ctrl.ioloop.READ) - log.info('Opened camera ' + path) + self.log.info('Opened camera ' + path) except Exception as e: - log.warning('While loading camera: %s' % e) - if not self.dev is None: - self.dev.close() - self.dev = None + self.log.warning('While loading camera: %s' % e) + self._close_dev() + + + def _close_dev(self): + if self.dev is None: return + try: + self.dev.close() + except Exception as e: self.log.warning('While closing camera: %s', e) + + self.dev = None def close(self): @@ -385,19 +390,19 @@ class Camera(object): try: self.dev.stop() except: pass - self.dev.close() + self._close_dev() for client in self.clients: client.write_frame_twice(self.offline_jpg) - log.info('Closed camera %s' % self.path) + self.log.info('Closed camera %s' % self.path) - except: log.warning('Closing camera') + except: self.log.warning('Closing camera') finally: self.dev = None def add_client(self, client): - log.info('Adding camera client: %d' % len(self.clients)) + self.log.info('Adding camera client: %d' % len(self.clients)) if len(self.clients): self.clients[-1].write_frame_twice(self.in_use_jpg) @@ -409,7 +414,7 @@ class Camera(object): def remove_client(self, client): - log.info('Removing camera client') + self.log.info('Removing camera client') try: self.clients.remove(client) except: pass @@ -422,7 +427,7 @@ class VideoHandler(web.RequestHandler): def __init__(self, app, request, **kwargs): super().__init__(app, request, **kwargs) - self.camera = app.ctrl.camera + self.camera = app.get_ctrl(self.get_cookie('client-id')).camera @web.asynchronous @@ -470,9 +475,11 @@ class VideoHandler(web.RequestHandler): if __name__ == '__main__': class Ctrl(object): - def __init__(self): - from tornado import ioloop - self.ioloop = ioloop.IOLoop.current() + def __init__(self, args, ioloop): + self.args = args + self.ioloop = ioloop + self.log = bbctrl.log.Log(args, ioloop) + self.camera = Camera(self) class RootHandler(web.RequestHandler): @@ -482,8 +489,8 @@ if __name__ == '__main__': class Web(web.Application): - def __init__(self, ctrl): - self.ctrl = ctrl + def __init__(self, args, ioloop): + self.ctrl = Ctrl(args, ioloop) handlers = [ (r'/', RootHandler), @@ -493,6 +500,10 @@ if __name__ == '__main__': web.Application.__init__(self, handlers) self.listen(9000, address = '127.0.0.1') + + def get_ctrl(self, id = None): return self.ctrl + + import argparse parser = argparse.ArgumentParser(description = 'Camera Server Test') parser.add_argument('--width', default = 640, type = int) @@ -501,11 +512,8 @@ if __name__ == '__main__': parser.add_argument('--fourcc', default = 'MJPG') args = parser.parse_args() + from tornado import ioloop + ioloop = ioloop.IOLoop.current() - logging.basicConfig(level = logging.INFO) - - ctrl = Ctrl() - ctrl.args = args - ctrl.camera = Camera(ctrl) - server = Web(ctrl) - ctrl.ioloop.start() + server = Web(args, ioloop) + ioloop.start() diff --git a/src/py/bbctrl/Cmd.py b/src/py/bbctrl/Cmd.py index 3408fef..3b5c1af 100644 --- a/src/py/bbctrl/Cmd.py +++ b/src/py/bbctrl/Cmd.py @@ -30,9 +30,6 @@ import struct import base64 import json -import logging - -log = logging.getLogger('Cmd') # Keep this in sync with AVR code command.def SET = '$' @@ -261,12 +258,17 @@ def decode_command(cmd): elif cmd[0] == FLUSH: data['type'] = 'flush' elif cmd[0] == RESUME: data['type'] = 'resume' - print(json.dumps(data)) + return data def decode(cmd): for line in cmd.split('\n'): - decode_command(line.strip()) + yield decode_command(line.strip()) + + +def decode_and_print(cmd): + for data in decode(cmd): + print(json.dumps(data)) if __name__ == "__main__": @@ -274,8 +276,8 @@ if __name__ == "__main__": if 1 < len(sys.argv): for arg in sys.argv[1:]: - decode(arg) + decode_and_print(arg) else: for line in sys.stdin: - decode(line) + decode_and_print(line) diff --git a/src/py/bbctrl/Comm.py b/src/py/bbctrl/Comm.py index 8e7f3a6..742907c 100644 --- a/src/py/bbctrl/Comm.py +++ b/src/py/bbctrl/Comm.py @@ -26,7 +26,6 @@ ################################################################################ import serial -import logging import json import time import traceback @@ -35,13 +34,12 @@ from collections import deque import bbctrl import bbctrl.Cmd as Cmd -log = logging.getLogger('Comm') - class Comm(object): def __init__(self, ctrl, avr): self.ctrl = ctrl self.avr = avr + self.log = self.ctrl.log.get('Comm') self.queue = deque() self.in_buf = '' self.command = None @@ -57,7 +55,7 @@ class Comm(object): def i2c_command(self, cmd, byte = None, word = None, block = None): - log.info('I2C: %s b=%s w=%s d=%s' % (cmd, byte, word, block)) + self.log.info('I2C: %s b=%s w=%s d=%s' % (cmd, byte, word, block)) self.avr.i2c_command(cmd, byte, word, block) @@ -65,7 +63,7 @@ class Comm(object): def _load_next_command(self, cmd): - log.info('< ' + json.dumps(cmd).strip('"')) + self.log.info('< ' + json.dumps(cmd).strip('"')) self.command = bytes(cmd.strip() + '\n', 'utf-8') @@ -114,20 +112,19 @@ class Comm(object): self.queue_command(Cmd.set_axis(axis, position)) except Exception as e: - log.warning('AVR reload failed: %s', traceback.format_exc()) + self.log.warning('AVR reload failed: %s', traceback.format_exc()) self.ctrl.ioloop.call_later(1, self.connect) def _log_msg(self, msg): level = msg.get('level', 'info') - if 'where' in msg: extra = {'where': msg['where']} - else: extra = None + where = msg.get('where') msg = msg['msg'] - if level == 'info': log.info(msg, extra = extra) - elif level == 'debug': log.debug(msg, extra = extra) - elif level == 'warning': log.warning(msg, extra = extra) - elif level == 'error': log.error(msg, extra = extra) + if level == 'info': self.log.info(msg, where = where) + elif level == 'debug': self.log.debug(msg, where = where) + elif level == 'warning': self.log.warning(msg, where = where) + elif level == 'error': self.log.error(msg, where = where) if level == 'error': self.comm_error() @@ -147,20 +144,20 @@ class Comm(object): self.in_buf = self.in_buf[i + 1:] if line: - log.info('> ' + line) + self.log.info('> ' + line) try: msg = json.loads(line) except Exception as e: - log.warning('%s, data: %s', e, line) + self.log.warning('%s, data: %s', e, line) continue if 'variables' in msg: self._update_vars(msg) elif 'msg' in msg: self._log_msg(msg) elif 'firmware' in msg: - log.info('AVR firmware rebooted') + self.log.info('AVR firmware rebooted') self.connect() else: @@ -194,5 +191,5 @@ class Comm(object): self.queue_command(Cmd.HELP) # Load AVR commands and variables except Exception as e: - log.warning('Connect failed: %s', e) + self.log.warning('Connect failed: %s', e) self.ctrl.ioloop.call_later(1, self.connect) diff --git a/src/py/bbctrl/CommandQueue.py b/src/py/bbctrl/CommandQueue.py index ee20ee8..7f89a89 100644 --- a/src/py/bbctrl/CommandQueue.py +++ b/src/py/bbctrl/CommandQueue.py @@ -25,19 +25,19 @@ # # ################################################################################ -import logging +import bbctrl from collections import deque -log = logging.getLogger('CmdQ') -log.setLevel(logging.WARNING) - # 16-bit less with wrap around def id_less(a, b): return (1 << 15) < (a - b) & ((1 << 16) - 1) class CommandQueue(): - def __init__(self): + def __init__(self, ctrl): + self.log = ctrl.log.get('CmdQ') + self.log.set_level(bbctrl.log.WARNING) + self.lastEnqueueID = 0 self.releaseID = 0 self.q = deque() @@ -53,7 +53,7 @@ class CommandQueue(): def enqueue(self, id, cb, *args, **kwargs): - log.info('add(#%d) releaseID=%d', id, self.releaseID) + self.log.info('add(#%d) releaseID=%d', id, self.releaseID) self.lastEnqueueID = id self.q.append([id, cb, args, kwargs]) self._release() @@ -66,19 +66,19 @@ class CommandQueue(): # Execute commands <= releaseID if id_less(self.releaseID, id): return - log.info('releasing id=%d' % id) + self.log.info('releasing id=%d' % id) self.q.popleft() try: if cb is not None: cb(*args, **kwargs) except Exception as e: - log.exception('During command queue callback') + self.log.exception('During command queue callback') def release(self, id): if id and not id_less(self.releaseID, id): - log.debug('id out of order %d <= %d' % (id, self.releaseID)) + self.log.debug('id out of order %d <= %d' % (id, self.releaseID)) self.releaseID = id self._release() diff --git a/src/py/bbctrl/Config.py b/src/py/bbctrl/Config.py index 9d353ce..ad146c2 100644 --- a/src/py/bbctrl/Config.py +++ b/src/py/bbctrl/Config.py @@ -27,14 +27,11 @@ import os import json -import logging import pkg_resources import subprocess import copy from pkg_resources import Requirement, resource_filename -log = logging.getLogger('Config') - def get_resource(path): return resource_filename(Requirement.parse('bbctrl'), 'bbctrl/' + path) @@ -43,6 +40,8 @@ def get_resource(path): class Config(object): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('Config') + self.values = {} try: @@ -53,7 +52,7 @@ class Config(object): encoding = 'utf-8') as f: self.template = json.load(f) - except Exception as e: log.exception(e) + except Exception as e: self.log.exception(e) def get(self, name, default = None): @@ -64,22 +63,20 @@ class Config(object): return self.values.get(name, {}).get(str(index), None) - def load_path(self, path): - with open(path, 'r') as f: - return json.load(f) + def load(self): + path = self.ctrl.get_path('config.json') - - def load(self, path = 'config.json'): try: - if os.path.exists(path): config = self.load_path(path) + if os.path.exists(path): + with open(path, 'r') as f: config = json.load(f) else: config = {'version': self.version} try: self.upgrade(config) - except Exception as e: log.exception(e) + except Exception as e: self.log.exception(e) except Exception as e: - log.warning('%s', e) + self.log.warning('%s', e) config = {'version': self.version} self._defaults(config) @@ -169,13 +166,13 @@ class Config(object): self.upgrade(config) self._update(config, False) - with open('config.json', 'w') as f: + with open(self.ctrl.get_path('config.json'), 'w') as f: json.dump(config, f) subprocess.check_call(['sync']) self.ctrl.preplanner.invalidate_all() - log.info('Saved') + self.log.info('Saved') def reset(self): @@ -235,37 +232,3 @@ class Config(object): def reload(self): self._update(self.load(), True) - - - -if __name__ == "__main__": - import sys - import argparse - - class State(object): - def config(self, name, value): print('config(%s, %s)' % (name, value)) - - - class Ctrl(object): - def __init__(self): - self.state = State() - - parser = argparse.ArgumentParser(description = 'Buildbotics Config Test') - - parser.add_argument('configs', metavar = 'CONFIG', nargs = '*', - help = 'Configuration file') - parser.add_argument('-u', '--update', action = 'store_true', - help = 'Update config') - args = parser.parse_args() - - config = Config(Ctrl()) - - def do_cfg(path): - cfg = config.load(path) - if args.update: config._update(cfg, True) - else: print(json.dumps(cfg, sort_keys = True, indent = 2)) - - if len(args.configs): - for path in args.configs: do_cfg(path) - - else: do_cfg('') diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index 4b447d0..5e5a786 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -25,31 +25,29 @@ # # ################################################################################ -import logging +import os import bbctrl -log = logging.getLogger('Ctrl') - - - class Ctrl(object): - def __init__(self, args, ioloop): + def __init__(self, args, ioloop, id): self.args = args - self.ioloop = ioloop + self.ioloop = bbctrl.IOLoop(ioloop) + self.id = id - self.msgs = bbctrl.Messages(self) + if id and not os.path.exists(id): os.mkdir(id) + + self.log = bbctrl.log.Log(self) self.state = bbctrl.State(self) self.config = bbctrl.Config(self) - self.web = bbctrl.Web(self) try: - if args.demo: avr = bbctrl.AVREmu(self) - else: avr = bbctrl.AVR(self) + if args.demo: self.avr = bbctrl.AVREmu(self) + else: self.avr = bbctrl.AVR(self) self.i2c = bbctrl.I2C(args.i2c_port, args.demo) self.lcd = bbctrl.LCD(self) - self.mach = bbctrl.Mach(self, avr) + self.mach = bbctrl.Mach(self, self.avr) self.preplanner = bbctrl.Preplanner(self) self.jog = bbctrl.Jog(self) self.pwr = bbctrl.Pwr(self) @@ -63,7 +61,21 @@ class Ctrl(object): if not args.disable_camera: self.camera = bbctrl.Camera(self) - except Exception as e: log.exception(e) + except Exception as e: self.log.get('Ctrl').exception(e) + + + def get_path(self, dir = None, filename = None): + path = './' + self.id if self.id else '.' + path = path if dir is None else (path + '/' + dir) + return path if filename is None else (path + '/' + filename) + + + def get_upload(self, filename = None): + return self.get_path('upload', filename) + + + def get_plan(self, filename = None): + return self.get_path('plans', filename) def configure(self): @@ -78,3 +90,5 @@ class Ctrl(object): def close(self): if not self.camera is None: self.camera.close() + self.ioloop.close() + self.avr.close() diff --git a/src/py/bbctrl/FileHandler.py b/src/py/bbctrl/FileHandler.py index 52cf476..0d8a706 100644 --- a/src/py/bbctrl/FileHandler.py +++ b/src/py/bbctrl/FileHandler.py @@ -29,14 +29,10 @@ import os import bbctrl import glob import html -import logging from tornado import gen from tornado.web import HTTPError -log = logging.getLogger('FileHandler') - - def safe_remove(path): try: os.unlink(path) @@ -50,30 +46,30 @@ class FileHandler(bbctrl.APIHandler): def delete_ok(self, filename): if not filename: # Delete everything - for path in glob.glob('upload/*'): safe_remove(path) - self.ctrl.preplanner.delete_all_plans() - self.ctrl.state.clear_files() + for path in glob.glob(self.get_upload('*')): safe_remove(path) + self.get_ctrl().preplanner.delete_all_plans() + self.get_ctrl().state.clear_files() else: # Delete a single file filename = os.path.basename(filename) - safe_remove('upload/' + filename) - self.ctrl.preplanner.delete_plans(filename) - self.ctrl.state.remove_file(filename) + safe_remove(self.get_upload(filename)) + self.get_ctrl().preplanner.delete_plans(filename) + self.get_ctrl().state.remove_file(filename) def put_ok(self, *args): gcode = self.request.files['gcode'][0] filename = os.path.basename(gcode['filename']) - if not os.path.exists('upload'): os.mkdir('upload') + if not os.path.exists(self.get_upload()): os.mkdir(self.get_upload()) - with open('upload/' + filename, 'wb') as f: + with open(self.get_upload(filename), 'wb') as f: f.write(gcode['body']) - self.ctrl.preplanner.invalidate(filename) - self.ctrl.state.add_file(filename) - log.info('GCode received: ' + filename) + self.get_ctrl().preplanner.invalidate(filename) + self.get_ctrl().state.add_file(filename) + self.get_log('FileHandler').info('GCode received: ' + filename) @gen.coroutine @@ -81,7 +77,7 @@ class FileHandler(bbctrl.APIHandler): if not filename: raise HTTPError(400, 'Missing filename') filename = os.path.basename(filename) - with open('upload/' + filename, 'r') as f: + with open(self.get_upload(filename), 'r') as f: self.write(f.read()) - self.ctrl.state.select_file(filename) + self.get_ctrl().state.select_file(filename) diff --git a/src/py/bbctrl/GCodeStream.py b/src/py/bbctrl/IOLoop.py similarity index 66% rename from src/py/bbctrl/GCodeStream.py rename to src/py/bbctrl/IOLoop.py index e09b2d0..641ab45 100644 --- a/src/py/bbctrl/GCodeStream.py +++ b/src/py/bbctrl/IOLoop.py @@ -25,62 +25,48 @@ # # ################################################################################ -import re -import logging +import tornado.ioloop +import bbctrl -log = logging.getLogger('GCode') +class IOLoop(object): + READ = tornado.ioloop.IOLoop.READ + WRITE = tornado.ioloop.IOLoop.WRITE + ERROR = tornado.ioloop.IOLoop.ERROR -class GCodeStream(): - comment1RE = re.compile(r';.*') - comment2RE = re.compile(r'\(([^\)]*)\)') - - - def __init__(self, path): - self.path = path - self.f = None - - self.open() + def __init__(self, ioloop): + self.ioloop = ioloop + self.fds = set() + self.handles = set() def close(self): - if self.f is not None: - self.f.close() - self.f = None - - - def open(self): - self.close() - - self.line = 0 - self.f = open('upload' + self.path, 'r') - - - def reset(self): self.open() + for fd in self.fds: self.ioloop.remove_handler(fd) + for h in self.handles: self.ioloop.remove_timeout(h) - def comment(self, s): - log.debug('Comment: %s', s) + def add_handler(self, fd, handler, events): + self.ioloop.add_handler(fd, handler, events) + if hasattr(fd, 'fileno'): fd = fd.fileno() + self.fds.add(fd) - def next(self): - line = self.f.readline() - if line is None or line == '': return + def remove_handler(self, fd): + self.ioloop.remove_handler(fd) + if hasattr(fd, 'fileno'): fd = fd.fileno() + self.fds.remove(fd) - # Remove comments - line = self.comment1RE.sub('', line) - for comment in self.comment2RE.findall(line): - self.comment(comment) + def update_handler(self, fd, events): self.ioloop.update_handler(fd, events) - line = self.comment2RE.sub(' ', line) - # Remove space - line = line.strip() + def call_later(self, delay, callback, *args, **kwargs): + h = self.ioloop.call_later(delay, callback, *args, **kwargs) + self.handles.add(h) + return h - # Append line number - self.line += 1 - line += ' N%d' % self.line - return line + def remove_timeout(self, h): + self.ioloop.remove_timeout(h) + self.handles.remove(h) diff --git a/src/py/bbctrl/Jog.py b/src/py/bbctrl/Jog.py index 04a5d08..c4544e2 100644 --- a/src/py/bbctrl/Jog.py +++ b/src/py/bbctrl/Jog.py @@ -27,15 +27,13 @@ import inevent from inevent.Constants import * -import logging - -log = logging.getLogger('Jog') # Listen for input events class Jog(inevent.JogHandler): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('Jog') config = { "Logitech Logitech RumblePad 2 USB": { @@ -81,7 +79,7 @@ class Jog(inevent.JogHandler): self.ctrl.mach.jog(axes) except Exception as e: - log.warning('Jog: %s', e) + self.log.warning('Jog: %s', e) self.ctrl.ioloop.call_later(0.25, self.callback) diff --git a/src/py/bbctrl/LCD.py b/src/py/bbctrl/LCD.py index f954726..0d2cdcb 100644 --- a/src/py/bbctrl/LCD.py +++ b/src/py/bbctrl/LCD.py @@ -27,13 +27,9 @@ import lcd import atexit -import logging from tornado.ioloop import PeriodicCallback -log = logging.getLogger('LCD') - - class LCDPage: def __init__(self, lcd, text = None): self.lcd = lcd @@ -77,6 +73,7 @@ class LCDPage: class LCD: def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('LCD') self.addrs = self.ctrl.args.lcd_addr self.addr = self.addrs[0] @@ -102,7 +99,7 @@ class LCD: self.load_page(LCDPage(self, msg)) self._update() except IOError as e: - log.warning('LCD communication failed: %s' % e) + self.log.warning('LCD communication failed: %s' % e) def new_screen(self): @@ -189,7 +186,7 @@ class LCD: self.addr = self.addrs[self.addr_num] self.lcd = None - log.warning('LCD communication failed, ' + + self.log.warning('LCD communication failed, ' + 'retrying on address 0x%02x: %s' % (self.addr, e)) self.reset = True diff --git a/src/py/bbctrl/Log.py b/src/py/bbctrl/Log.py new file mode 100644 index 0000000..c1a5205 --- /dev/null +++ b/src/py/bbctrl/Log.py @@ -0,0 +1,138 @@ +################################################################################ +# # +# This file is part of the Buildbotics firmware. # +# # +# Copyright (c) 2015 - 2018, Buildbotics LLC # +# All rights reserved. # +# # +# This file ("the software") is free software: you can redistribute it # +# and/or modify it under the terms of the GNU General Public License, # +# version 2 as published by the Free Software Foundation. You should # +# have received a copy of the GNU General Public License, version 2 # +# along with the software. If not, see . # +# # +# The software is distributed in the hope that it will be useful, but # +# WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # +# Lesser General Public License for more details. # +# # +# You should have received a copy of the GNU Lesser General Public # +# License along with the software. If not, see # +# . # +# # +# For information regarding this software email: # +# "Joseph Coffland" # +# # +################################################################################ + +import os +import sys +import datetime +import traceback +import pkg_resources +from inspect import getframeinfo, stack +import bbctrl + + +DEBUG = 0 +INFO = 1 +WARNING = 2 +ERROR = 3 + + +def get_level_name(level): return 'debug info warning error'.split()[level] + + +class Logger(object): + def __init__(self, log, name, level): + self.log = log + self.name = name + self.level = level + + + def set_level(self, level): self.level = level + def _enabled(self, level): return self.level <= level and level <= ERROR + + + def _log(self, level, msg, *args, **kwargs): + if not self._enabled(level): return + + if not 'where' in kwargs: + caller = getframeinfo(stack()[2][0]) + kwargs['where'] = '%s:%d' % ( + os.path.basename(caller.filename), caller.lineno) + + if len(args): msg %= args + + self.log._log(msg, level = level, prefix = self.name, **kwargs) + + + def debug (self, *args, **kwargs): self._log(DEBUG, *args, **kwargs) + def info (self, *args, **kwargs): self._log(INFO, *args, **kwargs) + def warning(self, *args, **kwargs): self._log(WARNING, *args, **kwargs) + def error (self, *args, **kwargs): self._log(ERROR, *args, **kwargs) + + + def exception(self, *args, **kwargs): + msg = traceback.format_exc() + if len(args): msg = args[0] % args[1:] + '\n' + msg + self._log(ERROR, msg, **kwargs) + + +class Log(object): + def __init__(self, ctrl): + self.listeners = [] + self.loggers = {} + + self.level = DEBUG if ctrl.args.verbose else INFO + + if ctrl.args.demo: self.path = ctrl.get_path(filename = 'bbctrl.log') + else: self.path = ctrl.args.log + self.f = None if self.path is None else open(self.path, 'w') + + # Log header + version = pkg_resources.require('bbctrl')[0].version + self._log('Log started v%s' % version) + self._log_time(ctrl.ioloop) + + + def get_path(self): return self.path + + def add_listener(self, listener): self.listeners.append(listener) + def remove_listener(self, listener): self.listeners.remove(listener) + + + def get(self, name, level = None): + if not name in self.loggers: + self.loggers[name] = Logger(self, name, self.level) + return self.loggers[name] + + + def _log_time(self, ioloop): + self._log(datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')) + ioloop.call_later(60 * 60, self._log_time, ioloop) + + + def broadcast(self, msg): + for listener in self.listeners: listener(msg) + + + def _log(self, msg, level = INFO, prefix = '', where = None): + if not msg: return + + hdr = '%s:%s:' % ('DIWE'[level], prefix) + s = hdr + ('\n' + hdr).join(msg.split('\n')) + + if self.f is not None: + self.f.write(s + '\n') + self.f.flush() + + print(s) + + # Broadcast to log listeners + if level == INFO: return + + msg = dict(level = get_level_name(level), source = prefix, msg = msg) + if where is not None: msg['where'] = where + + self.broadcast(dict(log = msg)) diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index 6bcbd13..50b172a 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -25,15 +25,11 @@ # # ################################################################################ -import logging - import bbctrl from bbctrl.Comm import Comm import bbctrl.Cmd as Cmd from tornado.ioloop import PeriodicCallback -log = logging.getLogger('Mach') - # Axis homing procedure: # @@ -65,7 +61,7 @@ for more information.\ def overrides(interface_class): def overrider(method): if not method.__name__ in dir(interface_class): - log.warning('%s does not override %s' % ( + raise Exception('%s does not override %s' % ( method.__name__, interface_class.__name__)) return method @@ -78,6 +74,8 @@ class Mach(Comm): super().__init__(ctrl, avr) self.ctrl = ctrl + self.mlog = self.ctrl.log.get('Mach') + self.planner = bbctrl.Planner(ctrl) self.unpausing = False self.last_cycle = 'idle' @@ -142,7 +140,7 @@ class Mach(Comm): for motor in range(4): key = '%ddf' % motor if key in update and update[key] & 0x1f: - log.error(motor_fault_error % motor) + self.mlog.error(motor_fault_error % motor) # Update cycle now, if it has changed self._update_cycle() @@ -172,7 +170,7 @@ class Mach(Comm): def _unpause(self): pause_reason = self._get_pause_reason() - log.info('Unpause: ' + pause_reason) + self.mlog.info('Unpause: ' + pause_reason) if pause_reason == 'User stop': self.planner.stop() @@ -214,7 +212,7 @@ class Mach(Comm): def _query_var(self, cmd): equal = cmd.find('=') if equal == -1: - log.info('%s=%s' % (cmd, self.ctrl.state.get(cmd[1:]))) + self.mlog.info('%s=%s' % (cmd, self.ctrl.state.get(cmd[1:]))) else: name, value = cmd[1:equal], cmd[equal + 1:] @@ -251,34 +249,36 @@ class Mach(Comm): def home(self, axis, position = None): state = self.ctrl.state - if position is not None: self.mdi('G28.3 %c%f' % (axis, position)) - - else: + if axis is None: axes = 'zxyabc' # TODO This should be configurable + else: axes = '%c' % axis + + for axis in axes: + enabled = state.is_axis_enabled(axis) + mode = state.axis_homing_mode(axis) + + # If this is not a request to home a specific axis and the + # axis is disabled or in manual homing mode, don't show any + # warnings + if 1 < len(axes) and (not enabled or mode == 'manual'): + continue + + # Error when axes cannot be homed + reason = state.axis_home_fail_reason(axis) + if reason is not None: + self.mlog.error('Cannot home %s axis: %s' % ( + axis.upper(), reason)) + continue + + if mode == 'manual': + if position is None: raise Exception('Position not set') + self.mdi('G28.3 %c%f' % (axis, position)) + continue + + # Home axis + self.mlog.info('Homing %s axis' % axis) self._begin_cycle('homing') - - if axis is None: axes = 'zxyabc' # TODO This should be configurable - else: axes = '%c' % axis - - for axis in axes: - # If this is not a request to home a specific axis and the - # axis is disabled or in manual homing mode, don't show any - # warnings - if 1 < len(axes) and ( - not state.is_axis_enabled(axis) or - state.axis_homing_mode(axis) == 'manual'): - continue - - # Error when axes cannot be homed - reason = state.axis_home_fail_reason(axis) - if reason is not None: - log.error('Cannot home %s axis: %s' % ( - axis.upper(), reason)) - continue - - # Home axis - log.info('Homing %s axis' % axis) - self.planner.mdi(axis_homing_procedure % {'axis': axis}, False) - super().resume() + self.planner.mdi(axis_homing_procedure % {'axis': axis}, False) + super().resume() def unhome(self, axis): self.mdi('G28.2 %c0' % axis) @@ -295,7 +295,7 @@ class Mach(Comm): filename = self.ctrl.state.get('selected', '') if not filename: return self._begin_cycle('running') - self.planner.load('upload/' + filename) + self.planner.load(filename) super().resume() diff --git a/src/py/bbctrl/Planner.py b/src/py/bbctrl/Planner.py index b4a101f..5d272b7 100644 --- a/src/py/bbctrl/Planner.py +++ b/src/py/bbctrl/Planner.py @@ -28,14 +28,12 @@ import json import math import re -import logging import time from collections import deque import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error import bbctrl.Cmd as Cmd from bbctrl.CommandQueue import CommandQueue -log = logging.getLogger('Planner') reLogLine = re.compile( r'^(?P[A-Z])[0-9 ]:' @@ -58,7 +56,8 @@ def log_json(o): return json.dumps(log_floats(o)) class Planner(): def __init__(self, ctrl): self.ctrl = ctrl - self.cmdq = CommandQueue() + self.log = ctrl.log.get('Planner') + self.cmdq = CommandQueue(ctrl) ctrl.state.add_listener(self._update) @@ -114,7 +113,7 @@ class Planner(): if overrides: cfg['overrides'] = overrides - log.info('Config:' + log_json(cfg)) + self.log.info('Config:' + log_json(cfg)) return cfg @@ -133,7 +132,7 @@ class Planner(): value = self.ctrl.state.get(name[1:], 0) if units == 'IMPERIAL': value /= 25.4 # Assume metric - log.info('Get: %s=%s (units=%s)' % (name, value, units)) + self.log.info('Get: %s=%s (units=%s)' % (name, value, units)) return value @@ -153,19 +152,16 @@ class Planner(): if line is not None: line = int(line) if column is not None: column = int(column) - if where: extra = dict(where = where) - else: extra = None - if level == 'I': log.info (msg, extra = extra) - elif level == 'D': log.debug (msg, extra = extra) - elif level == 'W': log.warning (msg, extra = extra) - elif level == 'E': log.error (msg, extra = extra) - elif level == 'C': log.critical(msg, extra = extra) - else: log.error('Could not parse planner log line: ' + line) + if level == 'I': self.log.info (msg, where = where) + elif level == 'D': self.log.debug (msg, where = where) + elif level == 'W': self.log.warning (msg, where = where) + elif level == 'E': self.log.error (msg, where = where) + else: self.log.error('Could not parse planner log line: ' + line) def _enqueue_set_cmd(self, id, name, value): - log.info('set(#%d, %s, %s)', id, name, value) + self.log.info('set(#%d, %s, %s)', id, name, value) self.cmdq.enqueue(id, self.ctrl.state.set, name, value) @@ -215,7 +211,7 @@ class Planner(): def __encode(self, block): type, id = block['type'], block['id'] - if type != 'set': log.info('Cmd:' + log_json(block)) + if type != 'set': self.log.info('Cmd:' + log_json(block)) if type == 'line': self._enqueue_line_time(block) @@ -228,7 +224,7 @@ class Planner(): if name == 'message': msg = dict(message = value) - self.cmdq.enqueue(id, self.ctrl.msgs.broadcast, msg) + self.cmdq.enqueue(id, self.ctrl.log.broadcast, msg) if name in ['line', 'tool']: self._enqueue_set_cmd(id, name, value) if name == 'speed': return Cmd.speed(value) @@ -300,13 +296,14 @@ class Planner(): def mdi(self, cmd, with_limits = True): - log.info('MDI:' + cmd) + self.log.info('MDI:' + cmd) self.planner.load_string(cmd, self.get_config(True, with_limits)) self.reset_times() def load(self, path): - log.info('GCode:' + path) + path = self.ctrl.get_path('upload', path) + self.log.info('GCode:' + path) self.planner.load(path, self.get_config(False, True)) self.reset_times() @@ -316,8 +313,8 @@ class Planner(): self.planner.stop() self.cmdq.clear() - except Exception as e: - log.exception(e) + except: + self.log.exception() self.reset() @@ -326,15 +323,15 @@ class Planner(): id = self.ctrl.state.get('id') position = self.ctrl.state.get_position() - log.info('Planner restart: %d %s' % (id, log_json(position))) + self.log.info('Planner restart: %d %s' % (id, log_json(position))) self.cmdq.clear() self.cmdq.release(id) self._plan_time_restart() self.planner.restart(id, position) - except Exception as e: - log.exception(e) + except: + self.log.exception() self.stop() @@ -345,6 +342,6 @@ class Planner(): cmd = self._encode(cmd) if cmd is not None: return cmd - except Exception as e: - log.exception(e) + except: + self.log.exception() self.stop() diff --git a/src/py/bbctrl/Preplanner.py b/src/py/bbctrl/Preplanner.py index b2aaafc..9a706e6 100644 --- a/src/py/bbctrl/Preplanner.py +++ b/src/py/bbctrl/Preplanner.py @@ -26,7 +26,6 @@ ################################################################################ import os -import logging import time import json import hashlib @@ -39,16 +38,12 @@ from tornado import gen import bbctrl -log = logging.getLogger('Preplanner') - - def hash_dump(o): s = json.dumps(o, separators = (',', ':'), sort_keys = True) return s.encode('utf8') def plan_hash(path, config): - path = 'upload/' + path h = hashlib.sha256() h.update('v4'.encode('utf8')) h.update(hash_dump(config)) @@ -68,10 +63,13 @@ class Preplanner(object): def __init__(self, ctrl, threads = 4, max_plan_time = 60 * 60 * 24, max_loop_time = 300): self.ctrl = ctrl + self.log = ctrl.log.get('Preplanner') + self.max_plan_time = max_plan_time self.max_loop_time = max_loop_time - if not os.path.exists('plans'): os.mkdir('plans') + path = self.ctrl.get_plan() + if not os.path.exists(path): os.mkdir(path) self.started = Future() @@ -82,7 +80,7 @@ class Preplanner(object): def start(self): if not self.started.done(): - log.info('Preplanner started') + self.log.info('Preplanner started') self.started.set_result(True) @@ -101,7 +99,7 @@ class Preplanner(object): def delete_all_plans(self): - files = glob.glob('plans/*') + files = glob.glob(self.ctrl.get_plan('*')) for path in files: try: @@ -112,7 +110,7 @@ class Preplanner(object): def delete_plans(self, filename): - files = glob.glob('plans/' + filename + '.*') + files = glob.glob(self.ctrl.get_plan(filename + '.*')) for path in files: try: @@ -158,7 +156,7 @@ class Preplanner(object): def _clean_plans(self, filename, max = 2): - plans = glob.glob('plans/' + filename + '.*') + plans = glob.glob(self.ctrl.get_plan(filename + '.*')) if len(plans) <= max: return # Delete oldest plans @@ -181,8 +179,8 @@ class Preplanner(object): try: os.nice(5) - hid = plan_hash(filename, config) - base = 'plans/' + filename + '.' + hid + hid = plan_hash(self.ctrl.get_upload(filename), config) + base = self.ctrl.get_plan(filename + '.' + hid) files = [ base + '.json', base + '.positions.gz', base + '.speeds.gz'] @@ -193,7 +191,7 @@ class Preplanner(object): if not found: self._clean_plans(filename) # Clean up old plans - path = os.path.abspath('upload/' + filename) + path = os.path.abspath(self.ctrl.get_upload(filename)) with tempfile.TemporaryDirectory() as tmpdir: cmd = ( '/usr/bin/env', 'python3', @@ -203,7 +201,7 @@ class Preplanner(object): '--max-loop=%s' % self.max_loop_time ) - log.info('Running: %s', cmd) + self.log.info('Running: %s', cmd) with subprocess.Popen(cmd, stdout = subprocess.PIPE, stderr = subprocess.PIPE, @@ -221,7 +219,8 @@ class Preplanner(object): if cancel.is_set(): return if proc.returncode: - log.error('Plan failed: ' + errs.decode('utf8')) + self.log.error('Plan failed: ' + + errs.decode('utf8')) return # Failed os.rename(tmpdir + '/meta.json', files[0]) @@ -234,4 +233,4 @@ class Preplanner(object): return meta, positions, speeds - except Exception as e: log.exception(e) + except Exception as e: self.log.exception(e) diff --git a/src/py/bbctrl/Pwr.py b/src/py/bbctrl/Pwr.py index e7f9d93..989a359 100644 --- a/src/py/bbctrl/Pwr.py +++ b/src/py/bbctrl/Pwr.py @@ -25,13 +25,10 @@ # # ################################################################################ -import logging from tornado.ioloop import PeriodicCallback import bbctrl -log = logging.getLogger('PWR') - # Must match regs in pwr firmware TEMP_REG = 0 @@ -66,6 +63,7 @@ reg_names = 'temp vin vout motor load1 load2 vdd pwr_flags pwr_version'.split() class Pwr(): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('Pwr') self.i2c_addr = ctrl.args.pwr_addr self.regs = [-1] * 9 @@ -89,52 +87,52 @@ class Pwr(): flags = self.regs[FLAGS_REG] if self.check_fault('under_voltage', flags & UNDER_VOLTAGE_FLAG): - log.error('Device under voltage') + self.log.error('Device under voltage') if self.check_fault('over_voltage', flags & OVER_VOLTAGE_FLAG): - log.error('Device over voltage') + self.log.error('Device over voltage') if self.check_fault('over_current', flags & OVER_CURRENT_FLAG): - log.error('Device total current limit exceeded') + self.log.error('Device total current limit exceeded') if self.check_fault('sense_error', flags & SENSE_ERROR_FLAG): - log.error('Power sense error') + self.log.error('Power sense error') if self.check_fault('shunt_overload', flags & SHUNT_OVERLOAD_FLAG): - log.error('Power shunt overload') + self.log.error('Power shunt overload') if self.check_fault('motor_overload', flags & MOTOR_OVERLOAD_FLAG): - log.error('Motor power overload') + self.log.error('Motor power overload') if self.check_fault('load1_shutdown', flags & LOAD1_SHUTDOWN_FLAG): - log.error('Load 1 over temperature shutdown') + self.log.error('Load 1 over temperature shutdown') if self.check_fault('load2_shutdown', flags & LOAD2_SHUTDOWN_FLAG): - log.error('Load 2 over temperature shutdown') + self.log.error('Load 2 over temperature shutdown') if self.check_fault('motor_under_voltage', flags & MOTOR_UNDER_VOLTAGE_FLAG): - log.error('Motor under voltage') + self.log.error('Motor under voltage') if self.check_fault('motor_voltage_sense_error', flags & MOTOR_VOLTAGE_SENSE_ERROR_FLAG): - log.error('Motor voltage sense error') + self.log.error('Motor voltage sense error') if self.check_fault('motor_current_sense_error', flags & MOTOR_CURRENT_SENSE_ERROR_FLAG): - log.error('Motor current sense error') + self.log.error('Motor current sense error') if self.check_fault('load1_sense_error', flags & LOAD1_SENSE_ERROR_FLAG): - log.error('Load1 sense error') + self.log.error('Load1 sense error') if self.check_fault('load2_sense_error', flags & LOAD2_SENSE_ERROR_FLAG): - log.error('Load2 sense error') + self.log.error('Load2 sense error') if self.check_fault('vdd_current_sense_error', flags & VDD_CURRENT_SENSE_ERROR_FLAG): - log.error('Vdd current sense error') + self.log.error('Vdd current sense error') def _update_cb(self, now = True): @@ -167,9 +165,9 @@ class Pwr(): if i < 6: # Older pwr firmware does not have regs > 5 self.failures += 1 msg = 'Pwr communication failed at reg %d: %s' % (i, e) - if self.failures != 5: log.info(msg) + if self.failures != 5: self.log.info(msg) else: - log.warning(msg) + self.log.warning(msg) self.failures = 0 return diff --git a/src/py/bbctrl/Messages.py b/src/py/bbctrl/RequestHandler.py similarity index 67% rename from src/py/bbctrl/Messages.py rename to src/py/bbctrl/RequestHandler.py index b6ae595..050a425 100644 --- a/src/py/bbctrl/Messages.py +++ b/src/py/bbctrl/RequestHandler.py @@ -25,45 +25,39 @@ # # ################################################################################ -import os -import logging +import traceback import bbctrl +from tornado.web import HTTPError +import tornado.web -log = logging.getLogger('Msgs') +class RequestHandler(tornado.web.RequestHandler): + def __init__(self, app, request, **kwargs): + super().__init__(app, request, **kwargs) + self.app = app -class Messages(logging.Handler): - def __init__(self, ctrl): - logging.Handler.__init__(self) - self.ctrl = ctrl - self.listeners = [] + def get_ctrl(self): return self.app.get_ctrl(self.get_cookie('client-id')) + def get_log(self, name = 'API'): return self.get_ctrl().log.get(name) - debug = os.path.exists('/etc/bbctrl-dev-mode') - self.setLevel(logging.DEBUG if debug else logging.WARNING) - logging.getLogger().addHandler(self) + def get_path(self, path = None, filename = None): + return self.get_ctrl().get_path(path, filename) - def add_listener(self, listener): self.listeners.append(listener) - def remove_listener(self, listener): self.listeners.remove(listener) + def get_upload(self, filename = None): + return self.get_ctrl().get_upload(filename) - def broadcast(self, msg): - for listener in self.listeners: - listener(msg) + # Override exception logging + def log_exception(self, typ, value, tb): + if (isinstance(value, HTTPError) and + value.status_code in (400, 404, 408)): return + log = self.get_log() + log.set_level(bbctrl.log.DEBUG) - # From logging.Handler - def emit(self, record): - if record.levelno == logging.INFO: return - - msg = dict(level = record.levelname.lower(), - source = record.name, - msg = record.getMessage()) - - if hasattr(record, 'where'): msg['where'] = record.where - else: msg['where'] = '%s:%d' % (record.filename, record.lineno) - - self.broadcast({'log': msg}) + log.error(str(value)) + trace = ''.join(traceback.format_exception(typ, value, tb)) + log.debug(trace) diff --git a/src/py/bbctrl/State.py b/src/py/bbctrl/State.py index d00b324..bf1e0ab 100644 --- a/src/py/bbctrl/State.py +++ b/src/py/bbctrl/State.py @@ -25,7 +25,6 @@ # # ################################################################################ -import logging import traceback import copy import uuid @@ -33,12 +32,11 @@ import os import bbctrl -log = logging.getLogger('State') - - class State(object): def __init__(self, ctrl): self.ctrl = ctrl + self.log = ctrl.log.get('State') + self.callbacks = {} self.changes = {} self.listeners = [] @@ -51,6 +49,8 @@ class State(object): 'tool': 0, 'feed': 0, 'speed': 0, + 'sid': str(uuid.uuid4()), + 'demo': ctrl.args.demo, } # Add computed variable callbacks for each motor. @@ -74,8 +74,6 @@ class State(object): self.set_callback('metric', lambda name: 1 if self.is_metric() else 0) self.set_callback('imperial', lambda name: 0 if self.is_metric() else 1) - self.set('sid', str(uuid.uuid4())) - self.reset() self.load_files() @@ -96,10 +94,16 @@ class State(object): def load_files(self): self.files = [] - if os.path.exists('upload'): - for path in os.listdir('upload'): - if os.path.isfile('upload/' + path): - self.files.append(path) + upload = self.ctrl.get_upload() + + if not os.path.exists(upload): + os.mkdir(upload) + from shutil import copy + copy(bbctrl.get_resource('http/buildbotics.nc'), upload) + + for path in os.listdir(upload): + if os.path.isfile(upload + '/' + path): + self.files.append(path) self.files.sort() self.set('files', self.files) @@ -144,7 +148,7 @@ class State(object): listener(self.changes) except Exception as e: - log.warning('Updating state listener: %s', + self.log.warning('Updating state listener: %s', traceback.format_exc()) self.changes = {} @@ -186,7 +190,9 @@ class State(object): if name in self.vars: return self.vars[name] if name in self.callbacks: return self.callbacks[name](name) - if default is None: log.warning('State variable "%s" not found' % name) + if default is None: + self.log.warning('State variable "%s" not found' % name) + return default @@ -318,13 +324,12 @@ class State(object): mode = self.motor_homing_mode(motor) - if mode == 'manual': return 'Configured for manual homing' - - if mode == 'switch-min' and not int(self.get(axis + '_ls', 0)): - return 'Configured for min switch but switch is disabled' + if mode != 'manual': + if mode == 'switch-min' and not int(self.get(axis + '_ls', 0)): + return 'Configured for min switch but switch is disabled' - if mode == 'switch-max' and not int(self.get(axis + '_xs', 0)): - return 'Configured for max switch but switch is disabled' + if mode == 'switch-max' and not int(self.get(axis + '_xs', 0)): + return 'Configured for max switch but switch is disabled' softMin = int(self.get(axis + '_tn', 0)) softMax = int(self.get(axis + '_tm', 0)) diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 5e30ee8..199801e 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -30,7 +30,6 @@ import sys import json import tornado import sockjs.tornado -import logging import datetime import shutil import tarfile @@ -43,8 +42,6 @@ from tornado import web, gen import bbctrl -log = logging.getLogger('Web') - def call_get_output(cmd): p = subprocess.Popen(cmd, stdout = subprocess.PIPE) @@ -81,18 +78,13 @@ def check_password(password): class RebootHandler(bbctrl.APIHandler): def put_ok(self): - self.ctrl.lcd.goodbye('Rebooting...') + self.get_ctrl().lcd.goodbye('Rebooting...') subprocess.Popen('reboot') -class LogHandler(tornado.web.RequestHandler): - def __init__(self, app, request, **kwargs): - super().__init__(app, request, **kwargs) - self.filename = app.ctrl.args.log - - +class LogHandler(bbctrl.RequestHandler): def get(self): - with open(self.filename, 'r') as f: + with open(self.get_ctrl().log.get_path(), 'r') as f: self.write(f.read()) @@ -103,12 +95,7 @@ class LogHandler(tornado.web.RequestHandler): self.set_header('Content-Type', 'text/plain') -class BugReportHandler(tornado.web.RequestHandler): - def __init__(self, app, request, **kwargs): - super().__init__(app, request, **kwargs) - self.app = app - - +class BugReportHandler(bbctrl.RequestHandler): def get(self): import tarfile, io @@ -123,12 +110,14 @@ class BugReportHandler(tornado.web.RequestHandler): def check_add_basename(path): check_add(path, os.path.basename(path)) - check_add_basename(self.app.ctrl.args.log) - check_add_basename(self.app.ctrl.args.log + '.1') - check_add_basename(self.app.ctrl.args.log + '.2') - check_add_basename(self.app.ctrl.args.log + '.3') + ctrl = self.get_ctrl() + path = ctrl.log.get_path() + check_add_basename(path) + check_add_basename(path + '.1') + check_add_basename(path + '.2') + check_add_basename(path + '.3') check_add('config.json') - check_add('upload/' + self.app.ctrl.state.get('selected', '')) + check_add(ctrl.get_upload(ctrl.state.get('selected', ''))) tar.close() @@ -147,6 +136,9 @@ class HostnameHandler(bbctrl.APIHandler): def get(self): self.write_json(socket.gethostname()) def put(self): + if self.get_ctrl().args.demo: + raise HTTPError(400, 'Cannot set hostname in demo mode') + if 'hostname' in self.json: if subprocess.call(['/usr/local/bin/sethostname', self.json['hostname'].strip()]) == 0: @@ -164,7 +156,11 @@ class WifiHandler(bbctrl.APIHandler): except: pass self.write_json(data) + def put(self): + if self.get_ctrl().args.demo: + raise HTTPError(400, 'Cannot configure WiFi in demo mode') + if 'mode' in self.json: cmd = ['config-wifi', '-r'] mode = self.json['mode'] @@ -193,12 +189,18 @@ class UsernameHandler(bbctrl.APIHandler): def put_ok(self): + if self.get_ctrl().args.demo: + raise HTTPError(400, 'Cannot set username in demo mode') + if 'username' in self.json: set_username(self.json['username']) else: raise HTTPError(400, 'Missing "username"') class PasswordHandler(bbctrl.APIHandler): def put(self): + if self.get_ctrl().args.demo: + raise HTTPError(400, 'Cannot set password in demo mode') + if 'current' in self.json and 'password' in self.json: check_password(self.json['current']) @@ -218,7 +220,7 @@ class PasswordHandler(bbctrl.APIHandler): class ConfigLoadHandler(bbctrl.APIHandler): - def get(self): self.write_json(self.ctrl.config.load()) + def get(self): self.write_json(self.get_ctrl().config.load()) class ConfigDownloadHandler(bbctrl.APIHandler): @@ -230,15 +232,15 @@ class ConfigDownloadHandler(bbctrl.APIHandler): 'attachment; filename="%s"' % filename) def get(self): - self.write_json(self.ctrl.config.load(), pretty = True) + self.write_json(self.get_ctrl().config.load(), pretty = True) class ConfigSaveHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.config.save(self.json) + def put_ok(self): self.get_ctrl().config.save(self.json) class ConfigResetHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.config.reset() + def put_ok(self): self.get_ctrl().config.reset() class FirmwareUpdateHandler(bbctrl.APIHandler): @@ -261,31 +263,31 @@ class FirmwareUpdateHandler(bbctrl.APIHandler): with open('firmware/update.tar.bz2', 'wb') as f: f.write(firmware['body']) - self.ctrl.lcd.goodbye('Upgrading firmware') + self.get_ctrl().lcd.goodbye('Upgrading firmware') subprocess.Popen(['/usr/local/bin/update-bbctrl']) class UpgradeHandler(bbctrl.APIHandler): def put_ok(self): check_password(self.json['password']) - self.ctrl.lcd.goodbye('Upgrading firmware') + self.get_ctrl().lcd.goodbye('Upgrading firmware') subprocess.Popen(['/usr/local/bin/upgrade-bbctrl']) class PathHandler(bbctrl.APIHandler): @gen.coroutine def get(self, filename, dataType, *args): - if not os.path.exists('upload/' + filename): + if not os.path.exists(self.get_upload(filename)): raise HTTPError(404, 'File not found') - future = self.ctrl.preplanner.get_plan(filename) + future = self.get_ctrl().preplanner.get_plan(filename) try: delta = datetime.timedelta(seconds = 1) data = yield gen.with_timeout(delta, future) except gen.TimeoutError: - progress = self.ctrl.preplanner.get_plan_progress(filename) + progress = self.get_ctrl().preplanner.get_plan_progress(filename) self.write_json(dict(progress = progress)) return @@ -323,80 +325,81 @@ class HomeHandler(bbctrl.APIHandler): if not 'position' in self.json: raise HTTPError(400, 'Missing "position"') - self.ctrl.mach.home(axis, self.json['position']) + self.get_ctrl().mach.home(axis, self.json['position']) - elif action == '/clear': self.ctrl.mach.unhome(axis) - else: self.ctrl.mach.home(axis) + elif action == '/clear': self.get_ctrl().mach.unhome(axis) + else: self.get_ctrl().mach.home(axis) class StartHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.start() + def put_ok(self): self.get_ctrl().mach.start() class EStopHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.estop() + def put_ok(self): self.get_ctrl().mach.estop() class ClearHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.clear() + def put_ok(self): self.get_ctrl().mach.clear() class StopHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.stop() + def put_ok(self): self.get_ctrl().mach.stop() class PauseHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.pause() + def put_ok(self): self.get_ctrl().mach.pause() class UnpauseHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.unpause() + def put_ok(self): self.get_ctrl().mach.unpause() class OptionalPauseHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.optional_pause() + def put_ok(self): self.get_ctrl().mach.optional_pause() class StepHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.step() + def put_ok(self): self.get_ctrl().mach.step() class PositionHandler(bbctrl.APIHandler): def put_ok(self, axis): - self.ctrl.mach.set_position(axis, float(self.json['position'])) + self.get_ctrl().mach.set_position(axis, float(self.json['position'])) class OverrideFeedHandler(bbctrl.APIHandler): - def put_ok(self, value): self.ctrl.mach.override_feed(float(value)) + def put_ok(self, value): self.get_ctrl().mach.override_feed(float(value)) class OverrideSpeedHandler(bbctrl.APIHandler): - def put_ok(self, value): self.ctrl.mach.override_speed(float(value)) + def put_ok(self, value): self.get_ctrl().mach.override_speed(float(value)) class ModbusReadHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.modbus_read(int(self.json['address'])) + def put_ok(self): + self.get_ctrl().mach.modbus_read(int(self.json['address'])) class ModbusWriteHandler(bbctrl.APIHandler): def put_ok(self): - self.ctrl.mach.modbus_write(int(self.json['address']), + self.get_ctrl().mach.modbus_write(int(self.json['address']), int(self.json['value'])) class JogHandler(bbctrl.APIHandler): - def put_ok(self): self.ctrl.mach.jog(self.json) + def put_ok(self): self.get_ctrl().mach.jog(self.json) # Base class for Web Socket connections class ClientConnection(object): - def __init__(self, ctrl): - self.ctrl = ctrl + def __init__(self, app): + self.app = app self.count = 0 def heartbeat(self): - self.timer = self.ctrl.ioloop.call_later(3, self.heartbeat) + self.timer = self.app.ioloop.call_later(3, self.heartbeat) self.send({'heartbeat': self.count}) self.count += 1 @@ -404,17 +407,19 @@ class ClientConnection(object): def send(self, msg): raise HTTPError(400, 'Not implemented') - def on_open(self, *args, **kwargs): + def on_open(self, id = None): + self.ctrl = self.app.get_ctrl(id) + self.ctrl.state.add_listener(self.send) - self.ctrl.msgs.add_listener(self.send) + self.ctrl.log.add_listener(self.send) self.is_open = True self.heartbeat() def on_close(self): - self.ctrl.ioloop.remove_timeout(self.timer) + self.app.ioloop.remove_timeout(self.timer) self.ctrl.state.remove_listener(self.send) - self.ctrl.msgs.remove_listener(self.send) + self.ctrl.log.remove_listener(self.send) self.is_open = False @@ -424,7 +429,7 @@ class ClientConnection(object): # Used by CAMotics class WSConnection(ClientConnection, tornado.websocket.WebSocketHandler): def __init__(self, app, request, **kwargs): - ClientConnection.__init__(self, app.ctrl) + ClientConnection.__init__(self, app) tornado.websocket.WebSocketHandler.__init__( self, app, request, **kwargs) @@ -435,7 +440,7 @@ class WSConnection(ClientConnection, tornado.websocket.WebSocketHandler): # Used by Web frontend class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection): def __init__(self, session): - ClientConnection.__init__(self, session.server.ctrl) + ClientConnection.__init__(self, session.server.app) sockjs.tornado.SockJSConnection.__init__(self, session) @@ -446,6 +451,18 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection): self.close() + def on_open(self, info): + cookie = info.get_cookie('client-id') + if cookie is None: self.send(dict(sid = '')) # Trigger client reset + else: + id = cookie.value + + ip = info.ip + if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-IP'] + self.app.get_ctrl(id).log.get('Web').info('Connection from %s' % ip) + super().on_open(id) + + class StaticFileHandler(tornado.web.StaticFileHandler): def set_extra_headers(self, path): self.set_header('Cache-Control', @@ -453,8 +470,13 @@ class StaticFileHandler(tornado.web.StaticFileHandler): class Web(tornado.web.Application): - def __init__(self, ctrl): - self.ctrl = ctrl + def __init__(self, args, ioloop): + self.args = args + self.ioloop = ioloop + self.ctrls = {} + + # Init controller + if not self.args.demo: self.get_ctrl() handlers = [ (r'/websocket', WSConnection), @@ -491,25 +513,55 @@ class Web(tornado.web.Application): (r'/api/video', bbctrl.VideoHandler), (r'/(.*)', StaticFileHandler, {'path': bbctrl.get_resource('http/'), - "default_filename": "index.html"}), + 'default_filename': 'index.html'}), ] router = sockjs.tornado.SockJSRouter(SockJSConnection, '/sockjs') - router.ctrl = ctrl + router.app = self tornado.web.Application.__init__(self, router.urls + handlers) try: - self.listen(ctrl.args.port, address = ctrl.args.addr) + self.listen(args.port, address = args.addr) except Exception as e: - log.error('Failed to bind %s:%d: %s', ctrl.args.addr, - ctrl.args.port, e) - sys.exit(1) + raise Exception('Failed to bind %s:%d: %s' % ( + args.addr, args.port, e)) + + print('Listening on http://%s:%d/' % (args.addr, args.port)) + + self._reap_ctrls() + + + def _reap_ctrls(self): + if not self.args.demo: return + now = time.time() + + for id in list(self.ctrls.keys()): + ctrl = self.ctrls[id] + + if ctrl.lastTS + self.args.client_timeout < now: + ctrl.close() + del self.ctrls[id] + + self.ioloop.call_later(60, self._reap_ctrls) + + + def get_ctrl(self, id = None): + if not id or not self.args.demo: id = '' + + if not id in self.ctrls: + ctrl = bbctrl.Ctrl(self.args, self.ioloop, id) + self.ctrls[id] = ctrl + + else: ctrl = self.ctrls[id] + + ctrl.lastTS = time.time() - log.info('Listening on http://%s:%d/', ctrl.args.addr, ctrl.args.port) + return ctrl # Override default logger def log_request(self, handler): + log = self.get_ctrl(handler.get_cookie('client-id')).log.get('Web') log.info("%d %s", handler.get_status(), handler._request_summary()) diff --git a/src/py/bbctrl/__init__.py b/src/py/bbctrl/__init__.py index 70f63be..70f8e07 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -32,15 +32,12 @@ import sys import signal import tornado import argparse -import logging -import datetime -import pkg_resources from pkg_resources import Requirement, resource_filename +from bbctrl.RequestHandler import RequestHandler from bbctrl.APIHandler import APIHandler from bbctrl.FileHandler import FileHandler -from bbctrl.GCodeStream import GCodeStream from bbctrl.Config import Config from bbctrl.LCD import LCD, LCDPage from bbctrl.Mach import Mach @@ -52,7 +49,6 @@ from bbctrl.I2C import I2C from bbctrl.Planner import Planner from bbctrl.Preplanner import Preplanner from bbctrl.State import State -from bbctrl.Messages import Messages from bbctrl.Comm import Comm from bbctrl.CommandQueue import CommandQueue from bbctrl.MainLCDPage import MainLCDPage @@ -60,8 +56,10 @@ from bbctrl.IPLCDPage import IPLCDPage from bbctrl.Camera import Camera, VideoHandler from bbctrl.AVR import AVR from bbctrl.AVREmu import AVREmu +from bbctrl.IOLoop import IOLoop import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 +import bbctrl.Log as log ctrl = None @@ -74,7 +72,7 @@ def get_resource(path): def on_exit(sig = 0, func = None): global ctrl - logging.info('Exit handler triggered: signal = %d', sig) + print('Exit handler triggered: signal = %d', sig) if ctrl is not None: ctrl.close() @@ -83,11 +81,6 @@ def on_exit(sig = 0, func = None): sys.exit(1) -def log_time(log, ioloop): - log.info(datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')) - ioloop.call_later(60 * 60, log_time, log, ioloop) - - def parse_args(): parser = argparse.ArgumentParser( description = 'Buildbotics Machine Controller') @@ -126,6 +119,8 @@ def parse_args(): help = 'Enter demo mode') parser.add_argument('--fast-emu', action = 'store_true', help = 'Enter demo mode') + parser.add_argument('--client-timeout', default = 5 * 60, type = int, + help = 'Demo client timeout in seconds') return parser.parse_args() @@ -135,45 +130,19 @@ def run(): args = parse_args() - # Init logging - root = logging.getLogger() - level = logging.DEBUG if args.verbose else logging.INFO - root.setLevel(logging.NOTSET) - f = logging.Formatter('{levelname[0]}:{name}:{message}', style = '{') - h = logging.StreamHandler() - h.setLevel(level) - h.setFormatter(f) - root.addHandler(h) - - if args.log: - h = logging.handlers.RotatingFileHandler(args.log, maxBytes = 1000000, - backupCount = 5) - h.setLevel(level) - h.setFormatter(f) - root.addHandler(h) - - # Log header - version = pkg_resources.require('bbctrl')[0].version - root.info('Log started v%s' % version) - # Set signal handler signal.signal(signal.SIGTERM, on_exit) # Create ioloop ioloop = tornado.ioloop.IOLoop.current() - # Write time to log periodically - log_time(root, ioloop) - - # Start controller - ctrl = Ctrl(args, ioloop) + # Start server + web = Web(args, ioloop) try: ioloop.start() except KeyboardInterrupt: on_exit() - except SystemExit: raise - except: logging.getLogger().exception('') if __name__ == '__main__': run() diff --git a/scripts/buildbotics.gc b/src/resources/buildbotics.nc similarity index 100% rename from scripts/buildbotics.gc rename to src/resources/buildbotics.nc diff --git a/src/resources/images/in-use.jpg b/src/resources/images/in-use.jpg index 17c43e6369f0f0f4c3a8d56f9a222e82d43c5eae..c82090f35dd8b78e8bb5ac750878ec556651b7c3 100644 GIT binary patch literal 11880 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 Q{2lB0x@5pP9QG{y2hAD>VE_OC literal 7921 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 diff --git a/src/resources/images/offline.jpg b/src/resources/images/offline.jpg index 86137b54ecc677098b80e589d55362b07fcf2389..1a4399bfe6b736fd38b58c413b7a7418ed559713 100644 GIT binary patch literal 12970 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 Jfg|Gn{{})#oUs4^ diff --git a/src/stylus/style.styl b/src/stylus/style.styl index 6fd5abf..5320428 100644 --- a/src/stylus/style.styl +++ b/src/stylus/style.styl @@ -41,7 +41,8 @@ tt float right margin 5px - .video img + .video + position relative float right width 174px height 130px @@ -57,6 +58,35 @@ tt margin 5px 0 height inherit + .crosshair + > * + border 1px dashed #ccc + position absolute + + .vertical + height 100% + width 0 + left 50% + margin-left -1px + + .horizontal + height 0 + width 100% + top 50% + margin-top -1px + + .box + width 16px + height 16px + top 50% + left 50% + margin-top -9px + margin-left -9px + + img + width 100% + height 100% + .banner float left padding-top 40px @@ -661,10 +691,6 @@ tr.log-debug td background-color transparent color orange -.tab-content .video - text-align center - min-height 300px - tt.save display inline-block border-radius 2px -- 2.27.0