Verison 1.0.3 Release

Based on Buildbotics 0.4.14
This commit is contained in:
OneFinityCNC
2020-08-27 23:20:27 -04:00
parent 6137475077
commit 24dfa6c64d
302 changed files with 58865 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import json
import traceback
import bbctrl
from tornado.web import HTTPError
import tornado.httpclient
class APIHandler(bbctrl.RequestHandler):
def delete(self, *args, **kwargs):
self.delete_ok(*args, **kwargs)
self.write_json('ok')
def delete_ok(self): raise HTTPError(405)
def put(self, *args, **kwargs):
self.put_ok(*args, **kwargs)
self.write_json('ok')
def put_ok(self): raise HTTPError(405)
def prepare(self):
self.json = {}
if self.request.body:
try:
self.json = tornado.escape.json_decode(self.request.body)
except ValueError:
raise HTTPError(400, 'Unable to parse JSON')
def set_default_headers(self):
self.set_header('Content-Type', 'application/json')
def write_error(self, status_code, **kwargs):
e = {}
if 'message' in kwargs: e['message'] = kwargs['message']
elif 'exc_info' in kwargs:
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
self.write_json(e)
def write_json(self, data, pretty = False):
if pretty: data = json.dumps(data, indent = 2, separators = (',', ': '))
else: data = json.dumps(data, separators = (',', ':'))
self.write(data)

162
src/py/bbctrl/AVR.py Normal file
View File

@@ -0,0 +1,162 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import serial
import time
import traceback
import ctypes
import bbctrl
import bbctrl.Cmd as Cmd
class serial_struct(ctypes.Structure):
_fields_ = [
('type', ctypes.c_int),
('line', ctypes.c_int),
('port', ctypes.c_uint),
('irq', ctypes.c_int),
('flags', ctypes.c_int),
('xmit_fifo_size', ctypes.c_int),
('custom_divisor', ctypes.c_int),
('baud_base', ctypes.c_int),
('close_delay', ctypes.c_ushort),
('io_type', ctypes.c_byte),
('reserved', ctypes.c_byte),
('hub6', ctypes.c_int),
('closing_wait', ctypes.c_ushort),
('closing_wait2', ctypes.c_ushort),
('iomem_base', ctypes.c_char_p),
('iomem_reg_shift', ctypes.c_ushort),
('port_high', ctypes.c_uint),
('iomap_base', ctypes.c_ulong),
]
def serial_set_low_latency(sp):
import fcntl
import termios
ASYNCB_LOW_LATENCY = 13
ss = serial_struct()
fcntl.ioctl(sp, termios.TIOCGSERIAL, ss)
ss.flags |= 1 << ASYNCB_LOW_LATENCY # pylint: disable=no-member
fcntl.ioctl(sp, termios.TIOCSSERIAL, ss)
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,
rtscts = 1, timeout = 0, write_timeout = 0)
self.sp.nonblocking()
#serial_set_low_latency(self.sp)
except Exception as e:
self.sp = None
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,
self.ctrl.ioloop.READ)
def set_handlers(self, read_cb, write_cb):
if self.read_cb is not None or self.write_cb is not None:
raise Exception('Handler already set')
self.read_cb = read_cb
self.write_cb = write_cb
self._start()
def enable_write(self, enable):
if self.sp is None: return
flags = self.ctrl.ioloop.READ
if enable: flags |= self.ctrl.ioloop.WRITE
self.ctrl.ioloop.update_handler(self.sp, flags)
def _serial_write(self):
self.write_cb(lambda data: self.sp.write(data))
def _serial_read(self):
try:
data = ''
data = self.sp.read(self.sp.in_waiting)
self.read_cb(data)
except Exception as e:
self.log.warning('%s: %s', e, data)
def _serial_handler(self, fd, events):
try:
if self.ctrl.ioloop.READ & events: self._serial_read()
if self.ctrl.ioloop.WRITE & events: self._serial_write()
except Exception as e:
self.log.warning('Serial handler error: %s', traceback.format_exc())
def i2c_command(self, cmd, byte = None, word = None, block = None):
self.log.info('I2C: %s b=%s w=%s d=%s' % (cmd, byte, word, block))
retry = 10
cmd = ord(cmd[0])
while True:
try:
self.ctrl.i2c.write(self.i2c_addr, cmd, byte, word, block)
break
except Exception as e:
retry -= 1
if retry:
self.log.warning('I2C failed, retrying: %s' % e)
time.sleep(0.25)
continue
else:
self.log.error('I2C failed: %s' % e)
raise

207
src/py/bbctrl/AVREmu.py Normal file
View File

@@ -0,0 +1,207 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import sys
import traceback
import signal
import bbctrl
import bbctrl.Cmd as Cmd
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
self.read_cb = None
self.write_cb = None
self.pid = None
def close(self):
# Close pipes
def _close(fd, withHandle):
if fd is None: return
try:
if withHandle: self.ctrl.ioloop.remove_handler(fd)
except: pass
try:
os.close(fd)
except: pass
_close(self.avrOut, True)
_close(self.avrIn, True)
_close(self.i2cOut, False)
self.avrOut, self.avrIn, self.i2cOut = None, None, None
# Kill process and wait for it
if self.pid is not None:
os.kill(self.pid, signal.SIGKILL)
os.waitpid(self.pid, 0)
self.pid = None
def _start(self):
try:
self.close()
# Create pipes
stdinFDs = os.pipe()
stdoutFDs = os.pipe()
i2cFDs = os.pipe()
self.pid = os.fork()
if not self.pid:
# Dup child ends
os.dup2(stdinFDs[0], 0)
os.dup2(stdoutFDs[1], 1)
os.dup2(i2cFDs[0], 3)
# Close orig fds
os.close(stdinFDs[0])
os.close(stdoutFDs[1])
os.close(i2cFDs[0])
# Close parent ends
os.close(stdinFDs[1])
os.close(stdoutFDs[0])
os.close(i2cFDs[1])
cmd = ['bbemu']
if self.ctrl.args.fast_emu: cmd.append('--fast')
os.execvp(cmd[0], cmd)
os._exit(1) # In case of failure
# Parent, close child ends
os.close(stdinFDs[0])
os.close(stdoutFDs[1])
os.close(i2cFDs[0])
# Non-blocking IO
os.set_blocking(stdinFDs[1], False)
os.set_blocking(stdoutFDs[0], False)
os.set_blocking(i2cFDs[1], False)
self.avrOut = stdinFDs[1]
self.avrIn = stdoutFDs[0]
self.i2cOut = i2cFDs[1]
ioloop = self.ctrl.ioloop
ioloop.add_handler(self.avrOut, self._avr_write_handler,
ioloop.WRITE | ioloop.ERROR)
ioloop.add_handler(self.avrIn, self._avr_read_handler,
ioloop.READ | ioloop.ERROR)
self.write_enabled = True
except Exception:
self.close()
self.log.exception('Failed to start bbemu')
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')
self.read_cb = read_cb
self.write_cb = write_cb
self._start()
def enable_write(self, enable):
if self.avrOut is None: return
flags = self.ctrl.ioloop.WRITE if enable else 0
self.ctrl.ioloop.update_handler(self.avrOut, flags)
self.write_enabled = enable
def _avr_write(self, data):
try:
length = os.write(self.avrOut, data)
self.continue_write = length and length == len(data)
return length
except BlockingIOError: pass
except BrokenPipeError: pass
return 0
def _avr_write_handler(self, fd, events):
if self.avrOut is None: return
if events & self.ctrl.ioloop.ERROR:
self._start()
return
try:
while True:
self.continue_write = False
self.write_cb(self._avr_write)
if not self.continue_write: break
except Exception as e:
self.log.warning('AVR write handler error: %s',
traceback.format_exc())
def _avr_read_handler(self, fd, events):
if self.avrIn is None: return
if events & self.ctrl.ioloop.ERROR:
self._start()
return
try:
data = os.read(self.avrIn, 4096)
if data is not None: self.read_cb(data)
except Exception as e:
self.log.warning('AVR read handler error: %s %s' %
(data, traceback.format_exc()))
def i2c_command(self, cmd, byte = None, word = None, block = None):
if byte is not None: data = chr(byte)
elif word is not None: data = word
elif block is not None: data = block
else: data = ''
try:
if self.i2cOut is not None:
os.write(self.i2cOut, bytes(cmd + data + '\n', 'utf-8'))
except BrokenPipeError: pass

510
src/py/bbctrl/Camera.py Normal file
View File

@@ -0,0 +1,510 @@
#!/usr/bin/env python3
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/icenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import fcntl
import select
import struct
import mmap
import pyudev
import base64
import socket
import ctypes
from tornado import gen, web, iostream
import bbctrl
try:
import v4l2
except:
import bbctrl.v4l2 as v4l2
def array_to_string(a):
def until_zero(a):
for c in a:
if c == 0: return
yield c
return ''.join([chr(i) for i in until_zero(a)])
def fourcc_to_string(i):
return \
chr((i >> 0) & 0xff) + \
chr((i >> 8) & 0xff) + \
chr((i >> 16) & 0xff) + \
chr((i >> 24) & 0xff)
def string_to_fourcc(s): return v4l2.v4l2_fourcc(s[0], s[1], s[2], s[3])
def format_frame(frame):
frame = [b'--', VideoHandler.boundary.encode('utf8'), b'\r\n',
b'Content-type: image/jpeg\r\n',
b'Content-length: %d\r\n\r\n' % len(frame), frame]
return b''.join(frame)
def get_image_resource(path):
path = bbctrl.get_resource(path)
with open(path, 'rb') as f:
return format_frame(f.read())
class VideoDevice(object):
def __init__(self, path = '/dev/video0'):
self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC)
self.buffers = []
def fileno(self): return self.fd
def get_audio(self):
b = v4l2.v4l2_audio()
b.index = 0
l = []
while True:
try:
fcntl.ioctl(self, v4l2.VIDIOC_ENUMAUDIO, b)
l.append((array_to_string(b.name), b.capability, b.mode))
b.index += 1
except OSError: break
return l
def get_formats(self):
b = v4l2.v4l2_fmtdesc()
b.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
b.index = 0
l = []
while True:
try:
fcntl.ioctl(self, v4l2.VIDIOC_ENUM_FMT, b)
l.append((fourcc_to_string(b.pixelformat),
array_to_string(b.description)))
b.index += 1
except OSError: break
return l
def get_frame_sizes(self, fourcc):
b = v4l2.v4l2_frmsizeenum()
b.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
b.pixel_format = fourcc
sizes = []
while True:
try:
fcntl.ioctl(self, v4l2.VIDIOC_ENUM_FRAMESIZES, b)
if b.type == v4l2.V4L2_FRMSIZE_TYPE_DISCRETE:
sizes.append((b.discrete.width, b.discrete.height))
else:
sizes.append((b.stepwise.min_width, b.stepwise.max_width,
b.stepwise.step_width, b.stepwise.min_height,
b.stepwise.max_height,
b.stepwise.step_height))
b.index += 1 # pylint: disable=no-member
except OSError: break
return sizes
def set_format(self, width, height, fourcc):
fmt = v4l2.v4l2_format()
fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
fcntl.ioctl(self, v4l2.VIDIOC_G_FMT, fmt)
fmt.fmt.pix.width = width
fmt.fmt.pix.height = height
fmt.fmt.pix.pixelformat = fourcc
fcntl.ioctl(self, v4l2.VIDIOC_S_FMT, fmt)
def create_buffers(self, count):
# Create buffers
rbuf = v4l2.v4l2_requestbuffers()
rbuf.count = count;
rbuf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE;
rbuf.memory = v4l2.V4L2_MEMORY_MMAP;
fcntl.ioctl(self, v4l2.VIDIOC_REQBUFS, rbuf)
for i in range(rbuf.count):
# Get buffer
buf = v4l2.v4l2_buffer()
buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
buf.memory = v4l2.V4L2_MEMORY_MMAP
buf.index = i
fcntl.ioctl(self, v4l2.VIDIOC_QUERYBUF, buf)
# Mem map buffer
mm = mmap.mmap(self.fileno(), buf.length, mmap.MAP_SHARED,
mmap.PROT_READ | mmap.PROT_WRITE,
offset = buf.m.offset)
self.buffers.append(mm)
# Queue the buffer for capture
fcntl.ioctl(self, v4l2.VIDIOC_QBUF, buf)
def _dqbuf(self):
buf = v4l2.v4l2_buffer()
buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
buf.memory = v4l2.V4L2_MEMORY_MMAP
fcntl.ioctl(self, v4l2.VIDIOC_DQBUF, buf)
return buf
def _qbuf(self, buf):
fcntl.ioctl(self, v4l2.VIDIOC_QBUF, buf)
def read_frame(self):
buf = self._dqbuf()
mm = self.buffers[buf.index]
frame = mm.read(buf.bytesused)
mm.seek(0)
self._qbuf(buf)
return frame
def flush_frame(self): self._qbuf(self._dqbuf())
def get_info(self):
caps = v4l2.v4l2_capability()
fcntl.ioctl(self, v4l2.VIDIOC_QUERYCAP, caps)
caps._driver = array_to_string(caps.driver)
caps._card = array_to_string(caps.card)
caps._bus_info = array_to_string(caps.bus_info)
l = []
c = caps.capabilities
if c & v4l2.V4L2_CAP_VIDEO_CAPTURE: l.append('video_capture')
if c & v4l2.V4L2_CAP_VIDEO_OUTPUT: l.append('video_output')
if c & v4l2.V4L2_CAP_VIDEO_OVERLAY: l.append('video_overlay')
if c & v4l2.V4L2_CAP_VBI_CAPTURE: l.append('vbi_capture')
if c & v4l2.V4L2_CAP_VBI_OUTPUT: l.append('vbi_output')
if c & v4l2.V4L2_CAP_SLICED_VBI_CAPTURE: l.append('sliced_vbi_capture')
if c & v4l2.V4L2_CAP_SLICED_VBI_OUTPUT: l.append('sliced_vbi_output')
if c & v4l2.V4L2_CAP_RDS_CAPTURE: l.append('rds_capture')
if c & v4l2.V4L2_CAP_VIDEO_OUTPUT_OVERLAY:
l.append('video_output_overlay')
if c & v4l2.V4L2_CAP_HW_FREQ_SEEK: l.append('hw_freq_seek')
if c & v4l2.V4L2_CAP_RDS_OUTPUT: l.append('rds_output')
if c & v4l2.V4L2_CAP_TUNER: l.append('tuner')
if c & v4l2.V4L2_CAP_AUDIO: l.append('audio')
if c & v4l2.V4L2_CAP_RADIO: l.append('radio')
if c & v4l2.V4L2_CAP_MODULATOR: l.append('modulator')
if c & v4l2.V4L2_CAP_READWRITE: l.append('readwrite')
if c & v4l2.V4L2_CAP_ASYNCIO: l.append('asyncio')
if c & v4l2.V4L2_CAP_STREAMING: l.append('streaming')
caps._caps = l
return caps
def set_fps(self, fps):
setfps = v4l2.v4l2_streamparm()
setfps.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE;
setfps.parm.capture.timeperframe.numerator = 1
setfps.parm.capture.timeperframe.denominator = fps
fcntl.ioctl(self, v4l2.VIDIOC_S_PARM, setfps)
def start(self):
buf_type = v4l2.v4l2_buf_type(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE)
fcntl.ioctl(self, v4l2.VIDIOC_STREAMON, buf_type)
def stop(self):
buf_type = v4l2.v4l2_buf_type(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE)
fcntl.ioctl(self, v4l2.VIDIOC_STREAMOFF, buf_type)
def close(self):
if self.fd is None: return
try:
os.close(self.fd)
finally: self.fd = None
class Camera(object):
def __init__(self, ioloop, args, log):
self.ioloop = ioloop
self.log = log.get('Camera')
self.width = args.width
self.height = args.height
self.fps = args.fps
self.fourcc = 'MJPG'
self.max_clients = args.camera_clients
self.overtemp = False
self.dev = None
self.clients = []
self.path = None
self.have_camera = False
# Find connected cameras
for i in range(4):
path = '/dev/video%d' % i
if os.path.exists(path):
self.have_camera = True
self.open(path)
break
# Get notifications of camera (un)plug events
self.udevCtx = pyudev.Context()
self.udevMon = pyudev.Monitor.from_netlink(self.udevCtx)
self.udevMon.filter_by(subsystem = 'video4linux')
ioloop.add_handler(self.udevMon, self._udev_handler, ioloop.READ)
self.udevMon.start()
def _udev_handler(self, fd, events):
action, device = self.udevMon.receive_device()
if device is None or self.dev is not None: return
path = str(device.device_node)
if action == 'add':
self.have_camera = True
self.open(path)
if action == 'remove' and path == self.path:
self.have_camera = False
self.close()
def _send_frame(self, frame):
if not len(self.clients): return
try:
frame = format_frame(frame)
for i in range(self.max_clients):
if i < len(self.clients):
self.clients[-(i + 1)].write_frame(frame)
except Exception as e:
self.log.warning('Failed to write frame to client: %s' % e)
def _fd_handler(self, fd, events):
try:
if len(self.clients):
frame = self.dev.read_frame()
self._send_frame(frame)
else: self.dev.flush_frame()
except Exception as e:
if isinstance(e, BlockingIOError): return
self.log.warning('Failed to read from camera.')
self.ioloop.remove_handler(fd)
self.close()
def _update_client_image(self):
if self.have_camera and not self.overtemp: return
if self.overtemp and self.have_camera: img = 'overtemp'
else: img = 'offline'
if len(self.clients): self.clients[-1].write_img(img)
def open(self, path):
try:
self._update_client_image()
self.path = path
if self.overtemp: return
self.dev = VideoDevice(path)
caps = self.dev.get_info()
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.')
fourcc = string_to_fourcc(self.fourcc)
formats = self.dev.get_formats()
sizes = self.dev.get_frame_sizes(fourcc)
self.log.info('Formats: %s', formats)
self.log.info('Sizes: %s', sizes)
self.log.info('Audio: %s', self.dev.get_audio())
hasFormat = False
for name, description in formats:
if name == self.fourcc: hasFormat = True
if not hasFormat:
raise Exception(self.fourcc + ' video format not supported.')
self.dev.set_format(self.width, self.height, fourcc = fourcc)
self.dev.set_fps(self.fps)
self.dev.create_buffers(4)
self.dev.start()
self.ioloop.add_handler(self.dev, self._fd_handler,
self.ioloop.READ)
self.log.info('Opened camera ' + path)
except Exception as e:
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, overtemp = False):
self._update_client_image()
if self.dev is None: return
try:
self.ioloop.remove_handler(self.dev)
try:
self.dev.stop()
except: pass
self._close_dev()
self.log.info('Closed camera')
except: self.log.exception('Exception while closing camera')
finally: self.dev = None
def add_client(self, client):
self.log.info('Adding camera client: %d' % len(self.clients))
if self.max_clients <= len(self.clients):
self.clients[-self.max_clients].write_img('in-use')
self.clients.append(client)
self._update_client_image()
def remove_client(self, client):
self.log.info('Removing camera client')
try:
self.clients.remove(client)
except: pass
def set_overtemp(self, overtemp):
if self.overtemp == overtemp: return
self.overtemp = overtemp
if overtemp: self.close(True)
elif self.path is not None: self.open(self.path)
class VideoHandler(web.RequestHandler):
boundary = '-f36a3a39e5c955484390e0e3a6b031d1---'
def __init__(self, app, request, **kwargs):
super().__init__(app, request, **kwargs)
self.camera = app.camera
@web.asynchronous
def get(self):
self.request.connection.stream.max_write_buffer_size = 10000
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '
'pre-check=0, post-check=0, max-age=0')
self.set_header('Connection', 'close')
self.set_header('Content-Type', 'multipart/x-mixed-replace;boundary=' +
self.boundary)
self.set_header('Expires', 'Mon, 3 Jan 2000 12:34:56 GMT')
self.set_header('Pragma', 'no-cache')
if self.camera is None: self.write_img('offline')
else: self.camera.add_client(self)
def write_img(self, name):
self.write_frame_twice(get_image_resource('http/images/%s.jpg' % name))
def write_frame(self, frame):
# Don't allow too many frames to queue up
min_size = len(frame) * 2
if self.request.connection.stream.max_write_buffer_size < min_size:
self.request.connection.stream.max_write_buffer_size = min_size
try:
self.write(frame)
self.flush()
except iostream.StreamBufferFullError:
pass # Drop frame if buffer is full
def write_frame_twice(self, frame):
self.write_frame(frame)
self.write_frame(frame)
def on_connection_close(self): self.camera.remove_client(self)

280
src/py/bbctrl/Cmd.py Normal file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import struct
import base64
import json
# Keep this in sync with AVR code command.def
SET = '$'
SET_SYNC = '#'
MODBUS_READ = 'm'
MODBUS_WRITE = 'M'
SEEK = 's'
SET_AXIS = 'a'
LINE = 'l'
SYNC_SPEED = '%'
SPEED = 'p'
INPUT = 'I'
DWELL = 'd'
PAUSE = 'P'
STOP = 'S'
UNPAUSE = 'U'
JOG = 'j'
REPORT = 'r'
REBOOT = 'R'
RESUME = 'c'
ESTOP = 'E'
SHUTDOWN = 'X'
CLEAR = 'C'
FLUSH = 'F'
DUMP = 'D'
HELP = 'h'
SEEK_ACTIVE = 1 << 0
SEEK_ERROR = 1 << 1
def encode_float(x):
return base64.b64encode(struct.pack('<f', x))[:-2].decode("utf-8")
def decode_float(s):
return struct.unpack('<f', base64.b64decode(s + '=='))[0]
def encode_axes(axes):
data = ''
for axis in 'xyzabc':
if axis in axes:
data += axis + encode_float(axes[axis])
elif axis.upper() in axes:
data += axis + encode_float(axes[axis.upper()])
return data
def set_sync(name, value):
if isinstance(value, float): return set_float(name, value)
else: return SET_SYNC + '%s=%s' % (name, value)
def set(name, value):
if isinstance(value, float): return set_float(name, value)
else: return SET + '%s=%s' % (name, value)
def set_float(name, value):
return SET_SYNC + '%s=:%s' % (name, encode_float(value))
def modbus_read(addr): return MODBUS_READ + '%d' % addr
def modbus_write(addr, value): return MODBUS_WRITE + '%d=%d' % (addr, value)
def set_axis(axis, position): return SET_AXIS + axis + encode_float(position)
def line(target, exitVel, maxAccel, maxJerk, times, speeds):
cmd = LINE
cmd += encode_float(exitVel)
cmd += encode_float(maxAccel)
cmd += encode_float(maxJerk)
cmd += encode_axes(target)
# S-Curve time parameters
for i in range(7):
if times[i]:
cmd += str(i) + encode_float(times[i] / 60000) # to mins
# Speeds
for dist, speed in speeds:
cmd += '\n' + sync_speed(dist, speed)
return cmd
def speed(value): return SPEED + encode_float(value)
def sync_speed(dist, speed):
return SYNC_SPEED + encode_float(dist) + encode_float(speed)
def input(port, mode, timeout):
type, index, m = 'd', 0, 0
# Analog/digital & port index
if port == 'digital-in-0': type, index = 'd', 0
if port == 'digital-in-1': type, index = 'd', 1
if port == 'digital-in-2': type, index = 'd', 2
if port == 'digital-in-3': type, index = 'd', 3
if port == 'analog-in-0': type, index = 'a', 0
if port == 'analog-in-1': type, index = 'a', 1
if port == 'analog-in-2': type, index = 'a', 2
if port == 'analog-in-3': type, index = 'a', 3
# Mode
if mode == 'immediate': m = 0
if mode == 'rise': m = 1
if mode == 'fall': m = 2
if mode == 'high': m = 3
if mode == 'low': m = 4
return '%s%s%d%d%s' % (INPUT, type, index, m, encode_float(timeout))
def output(port, value):
if port == 'mist': return '#1oa=' + ('1' if value else '0')
if port == 'flood': return '#2oa=' + ('1' if value else '0')
raise Exception('Unsupported output "%s"' % port)
def dwell(seconds): return DWELL + encode_float(seconds)
def pause(type):
if type == 'program': type = 1
elif type == 'optional': type = 2
elif type == 'pallet-change': type = 1
else: raise Exception('Unknown pause type "%s"' % type)
return '%s%d' % (PAUSE, type)
def jog(axes): return JOG + encode_axes(axes)
def seek(switch, active, error):
# cmd = SEEK + str(switch)
cmd = SEEK + '%x' % switch
flags = 0
if active: flags |= SEEK_ACTIVE
if error: flags |= SEEK_ERROR
cmd += chr(flags + ord('0'))
return cmd
def decode_command(cmd):
if not len(cmd): return
data = {}
if cmd[0] == SET or cmd[0] == SET_SYNC:
data['type'] = 'set'
if cmd[0] == SET_SYNC: data['sync'] = True
equal = cmd.find('=')
data['name'] = cmd[1:equal]
value = cmd[equal + 1:]
if value.lower() == 'true': value = True
elif value.lower() == 'false': value = False
elif value.find('.') == -1: data['value'] = int(value)
else: data['value'] = float(value)
elif cmd[0] == JOG:
data['type'] = 'jog'
cmd = cmd[1:]
while len(cmd):
name = cmd[0]
value = decode_float(cmd[1:7])
cmd = cmd[7:]
if name in 'xyzabcuvw': data[name] = value
elif cmd[0] == SEEK:
data['type'] = 'seek'
data['port'] = int(cmd[2], 16)
flags = int(cmd[2], 16)
data['active'] = bool(flags & SEEK_ACTIVE)
data['error'] = bool(flags & SEEK_ERROR)
elif cmd[0] == LINE:
data['type'] = 'line'
data['exit-vel'] = decode_float(cmd[1:7])
data['max-accel'] = decode_float(cmd[7:13])
data['max-jerk'] = decode_float(cmd[13:19])
data['target'] = {}
data['times'] = [0] * 7
cmd = cmd[19:]
while len(cmd):
name = cmd[0]
value = decode_float(cmd[1:7])
cmd = cmd[7:]
if name in 'xyzabcuvw': data['target'][name] = value
else: data['times'][int(name)] = value
elif cmd[0] == SYNC_SPEED:
data['type'] = 'speed'
data['offset'] = decode_float(cmd[1:7])
data['speed'] = decode_float(cmd[7:13])
elif cmd[0] == REPORT: data['type'] = 'report'
elif cmd[0] == PAUSE: data['type'] = 'pause'
elif cmd[0] == UNPAUSE: data['type'] = 'unpause'
elif cmd[0] == ESTOP: data['type'] = 'estop'
elif cmd[0] == SHUTDOWN: data['type'] = 'shutdown'
elif cmd[0] == CLEAR: data['type'] = 'clear'
elif cmd[0] == FLUSH: data['type'] = 'flush'
elif cmd[0] == RESUME: data['type'] = 'resume'
return data
def decode(cmd):
for line in cmd.split('\n'):
yield decode_command(line.strip())
def decode_and_print(cmd):
for data in decode(cmd):
print(json.dumps(data))
if __name__ == "__main__":
import sys
if 1 < len(sys.argv):
for arg in sys.argv[1:]:
decode_and_print(arg)
else:
for line in sys.stdin:
decode_and_print(str(line).strip())

254
src/py/bbctrl/Comm.py Normal file
View File

@@ -0,0 +1,254 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import serial
import json
import time
import traceback
from collections import deque
import bbctrl
import bbctrl.Cmd as Cmd
# Must be kept in sync with drv8711.h
DRV8711_STATUS_OTS_bm = 1 << 0
DRV8711_STATUS_AOCP_bm = 1 << 1
DRV8711_STATUS_BOCP_bm = 1 << 2
DRV8711_STATUS_APDF_bm = 1 << 3
DRV8711_STATUS_BPDF_bm = 1 << 4
DRV8711_STATUS_UVLO_bm = 1 << 5
DRV8711_STATUS_STD_bm = 1 << 6
DRV8711_STATUS_STDLAT_bm = 1 << 7
DRV8711_COMM_ERROR_bm = 1 << 8
# Ignoring stall and stall latch flags for now
DRV8711_MASK = ~(DRV8711_STATUS_STD_bm | DRV8711_STATUS_STDLAT_bm)
def _driver_flags_to_string(flags):
if DRV8711_STATUS_OTS_bm & flags: yield 'over temp'
if DRV8711_STATUS_AOCP_bm & flags: yield 'over current a'
if DRV8711_STATUS_BOCP_bm & flags: yield 'over current b'
if DRV8711_STATUS_APDF_bm & flags: yield 'driver fault a'
if DRV8711_STATUS_BPDF_bm & flags: yield 'driver fault b'
if DRV8711_STATUS_UVLO_bm & flags: yield 'undervoltage'
if DRV8711_STATUS_STD_bm & flags: yield 'stall'
if DRV8711_STATUS_STDLAT_bm & flags: yield 'stall latch'
if DRV8711_COMM_ERROR_bm & flags: yield 'comm error'
def driver_flags_to_string(flags):
return ', '.join(_driver_flags_to_string(flags))
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
self.last_motor_flags = [0] * 4
avr.set_handlers(self._read, self._write)
self._poll_cb(False)
def comm_next(self): raise Exception('Not implemented')
def comm_error(self): raise Exception('Not implemented')
def is_active(self): return len(self.queue) or self.command is not None
def i2c_command(self, cmd, byte = None, word = None, block = None):
self.log.info('I2C: %s b=%s w=%s d=%s' % (cmd, byte, word, block))
self.avr.i2c_command(cmd, byte, word, block)
def flush(self): self.avr.enable_write(True)
def _load_next_command(self, cmd):
self.log.info('< ' + json.dumps(cmd).strip('"'))
self.command = bytes(cmd.strip() + '\n', 'utf-8')
def resume(self): self.queue_command(Cmd.RESUME)
def queue_command(self, cmd):
self.queue.append(cmd)
self.flush()
def _poll_cb(self, now = True):
# Checks periodically for new commands from planner via comm_next()
if now: self.flush()
self.ctrl.ioloop.call_later(1, self._poll_cb)
def _write(self, write_cb):
# Finish writing current command
if self.command is not None:
try:
count = write_cb(self.command)
except Exception as e:
self.command = None
raise e
self.command = self.command[count:]
if len(self.command): return # There's more
self.command = None
# Load next command from queue
if len(self.queue): self._load_next_command(self.queue.popleft())
# Load next command from callback
else:
cmd = self.comm_next() # pylint: disable=assignment-from-no-return
if cmd is None: self.avr.enable_write(False) # Stop writing
else: self._load_next_command(cmd)
def _update_vars(self, msg):
try:
self.ctrl.state.set_machine_vars(msg['variables'])
self.ctrl.configure()
self.queue_command(Cmd.DUMP) # Refresh all vars
# Set axis positions
for axis in 'xyzabc':
position = self.ctrl.state.get(axis + 'p', 0)
self.queue_command(Cmd.set_axis(axis, position))
except Exception as e:
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')
where = msg.get('where')
msg = msg['msg']
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()
# Treat machine alarmed warning as an error
if level == 'warning' and 'code' in msg and msg['code'] == 11:
self.comm_error()
def _log_motor_flags(self, update):
for motor in range(3):
var = '%ddf' % motor
if var in update:
flags = update[var] & DRV8711_MASK
if self.last_motor_flags[motor] == flags: continue
self.last_motor_flags[motor] = flags
flags = driver_flags_to_string(flags)
self.log.info('Motor %d flags: %s' % (motor, flags))
def _update_state(self, update):
self.ctrl.state.update(update)
if 'xx' in update: # State change
self.ctrl.ready() # We've received data from AVR
self.flush() # May have more data to send now
self._log_motor_flags(update)
def _read(self, data):
self.in_buf += data.decode('utf-8')
# Parse incoming serial data into lines
while True:
i = self.in_buf.find('\n')
if i == -1: break
line = self.in_buf[0:i].strip()
self.in_buf = self.in_buf[i + 1:]
if line:
self.log.info('> ' + line)
try:
msg = json.loads(line)
except Exception as e:
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:
self.log.info('AVR firmware rebooted')
self.connect()
else: self._update_state(msg)
def estop(self):
if self.ctrl.state.get('xx', '') != 'ESTOPPED':
self.i2c_command(Cmd.ESTOP)
def clear(self):
if self.ctrl.state.get('xx', '') == 'ESTOPPED':
self.i2c_command(Cmd.CLEAR)
def pause(self):
self.i2c_command(Cmd.PAUSE, byte = ord('0')) # User pause
def reboot(self): self.queue_command(Cmd.REBOOT)
def connect(self):
try:
# Resume once current queue of GCode commands has flushed
self.queue_command(Cmd.RESUME)
self.queue_command(Cmd.HELP) # Load AVR commands and variables
except Exception as e:
self.log.warning('Connect failed: %s', e)
self.ctrl.ioloop.call_later(1, self.connect)

View File

@@ -0,0 +1,84 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import bbctrl
from collections import deque
# 16-bit less with wrap around
def id_less(a, b): return (1 << 15) < (a - b) & ((1 << 16) - 1)
class CommandQueue():
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()
def is_active(self): return len(self.q)
def clear(self):
self.lastEnqueueID = 0
self.releaseID = 0
self.q.clear()
def enqueue(self, id, cb, *args, **kwargs):
self.log.info('add(#%d) releaseID=%d', id, self.releaseID)
self.lastEnqueueID = id
self.q.append([id, cb, args, kwargs])
self._release()
def _release(self):
while len(self.q):
id, cb, args, kwargs = self.q[0]
# Execute commands <= releaseID
if id_less(self.releaseID, id): return
self.log.info('releasing id=%d' % id)
self.q.popleft()
try:
if cb is not None: cb(*args, **kwargs)
except Exception:
self.log.exception('During command queue callback')
def release(self, id):
if id and not id_less(self.releaseID, id):
self.log.debug('id out of order %d <= %d' % (id, self.releaseID))
self.releaseID = id
self._release()

244
src/py/bbctrl/Config.py Normal file
View File

@@ -0,0 +1,244 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import json
import pkg_resources
import subprocess
import copy
from pkg_resources import Requirement, resource_filename
def get_resource(path):
return resource_filename(Requirement.parse('bbctrl'), 'bbctrl/' + path)
class Config(object):
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('Config')
self.values = {}
try:
self.version = pkg_resources.require('bbctrl')[0].version
# Load config template
with open(get_resource('http/config-template.json'), 'r',
encoding = 'utf-8') as f:
self.template = json.load(f)
except Exception: self.log.exception()
def get(self, name, default = None):
return self.values.get(name, default)
def get_index(self, name, index, default = None):
return self.values.get(name, {}).get(str(index), None)
def load(self):
path = self.ctrl.get_path('config.json')
try:
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: self.log.exception()
except Exception as e:
self.log.warning('%s', e)
config = {'version': self.version}
self._defaults(config)
return config
def _valid_value(self, template, value):
type = template['type']
try:
if type == 'int': value = int(value)
if type == 'float': value = float(value)
if type == 'text': value = str(value)
if type == 'bool': value = bool(value)
except:
return False
if 'values' in template and value not in template['values']:
return False
return True
def __defaults(self, config, name, template):
if 'type' in template:
if (not name in config or
not self._valid_value(template, config[name])):
config[name] = template['default']
elif 'max' in template and template['max'] < config[name]:
config[name] = template['max']
elif 'min' in template and config[name] < template['min']:
config[name] = template['min']
if template['type'] == 'list':
config = config[name]
for i in range(len(template['index'])):
if len(config) <= i: config.append({})
for name, tmpl in template['template'].items():
self.__defaults(config[i], name, tmpl)
else:
for name, tmpl in template.items():
self.__defaults(config, name, tmpl)
def _defaults(self, config):
for name, tmpl in self.template.items():
if not 'type' in tmpl:
if not name in config: config[name] = {}
conf = config[name]
else: conf = config
self.__defaults(conf, name, tmpl)
def upgrade(self, config):
version = tuple(map(int, config['version'].split('.')))
if version < (0, 2, 4):
for motor in config['motors']:
for key in 'max-jerk max-velocity'.split():
if key in motor: motor[key] /= 1000
if version < (0, 3, 4):
for motor in config['motors']:
for key in 'max-accel latch-velocity search-velocity'.split():
if key in motor: motor[key] /= 1000
if version <= (0, 3, 22):
if 'tool' in config:
if 'spindle-type' in config['tool']:
type = config['tool']['spindle-type']
if type == 'PWM': type = 'PWM Spindle'
if type == 'Huanyang': type = 'Huanyang VFD'
config['tool']['tool-type'] = type
del config['tool']['spindle-type']
if 'spin-reversed' in config['tool']:
reversed = config['tool']['spin-reversed']
config['tool']['tool-reversed'] = reversed
del config['tool']['spin-reversed']
if version <= (0, 4, 6):
for motor in config['motors']:
if 2 < motor.get('idle-current', 0): motor['idle-current'] = 2
if 'enabled' not in motor:
motor['enabled'] = motor.get('power-mode', '') != 'disabled'
config['version'] = self.version
def save(self, config):
self.upgrade(config)
self._update(config, False)
with open(self.ctrl.get_path('config.json'), 'w') as f:
json.dump(config, f)
os.sync()
self.ctrl.preplanner.invalidate_all()
self.log.info('Saved')
def reset(self):
if os.path.exists('config.json'): os.unlink('config.json')
self.reload()
self.ctrl.preplanner.invalidate_all()
def _encode(self, name, index, config, tmpl, with_defaults):
# Handle category
if not 'type' in tmpl:
for name, entry in tmpl.items():
if 'type' in entry and config is not None:
conf = config.get(name, None)
else: conf = config
self._encode(name, index, conf, entry, with_defaults)
return
# Handle defaults
if config is not None: value = config
elif with_defaults: value = tmpl['default']
else: return
# Handle list
if tmpl['type'] == 'list':
for i in range(len(tmpl['index'])):
if config is not None and i < len(config): conf = config[i]
else: conf = None
self._encode(name, index + tmpl['index'][i], conf,
tmpl['template'], with_defaults)
return
# Update config values
if index:
if not name in self.values: self.values[name] = {}
self.values[name][index] = value
else: self.values[name] = value
# Update state variable
if not 'code' in tmpl: return
if tmpl['type'] == 'enum':
if value in tmpl['values']: value = tmpl['values'].index(value)
else: value = tmpl['default']
elif tmpl['type'] == 'bool': value = 1 if value else 0
elif tmpl['type'] == 'percent': value /= 100.0
self.ctrl.state.config(index + tmpl['code'], value)
def _update(self, config, with_defaults):
for name, tmpl in self.template.items():
conf = config.get(name, None)
self._encode(name, '', conf, tmpl, with_defaults)
def reload(self): self._update(self.load(), True)

115
src/py/bbctrl/Ctrl.py Normal file
View File

@@ -0,0 +1,115 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import time
import bbctrl
class Ctrl(object):
def __init__(self, args, ioloop, id):
self.args = args
self.ioloop = bbctrl.IOLoop(ioloop)
self.id = id
self.timeout = None # Used in demo mode
if id and not os.path.exists(id): os.mkdir(id)
# Start log
if args.demo: log_path = self.get_path(filename = 'bbctrl.log')
else: log_path = args.log
self.log = bbctrl.log.Log(args, self.ioloop, log_path)
self.state = bbctrl.State(self)
self.config = bbctrl.Config(self)
self.log.get('Ctrl').info('Starting %s' % self.id)
try:
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, self.avr)
self.preplanner = bbctrl.Preplanner(self)
if not args.demo: self.jog = bbctrl.Jog(self)
self.pwr = bbctrl.Pwr(self)
self.mach.connect()
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
self.lcd.add_new_page(bbctrl.IPLCDPage(self.lcd))
os.environ['GCODE_SCRIPT_PATH'] = self.get_upload()
except Exception: self.log.get('Ctrl').exception()
def __del__(self): print('Ctrl deleted')
def clear_timeout(self):
if self.timeout is not None: self.ioloop.remove_timeout(self.timeout)
self.timeout = None
def set_timeout(self, cb, *args, **kwargs):
self.clear_timeout()
t = self.args.client_timeout
self.timeout = self.ioloop.call_later(t, cb, *args, **kwargs)
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):
# Indirectly configures state via calls to config() and the AVR
self.config.reload()
def ready(self):
# This is used to synchronize the start of the preplanner
self.preplanner.start()
def close(self):
self.log.get('Ctrl').info('Closing %s' % self.id)
self.ioloop.close()
self.avr.close()
self.mach.planner.close()

View File

@@ -0,0 +1,85 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import bbctrl
import glob
import html
from tornado import gen
from tornado.web import HTTPError
def safe_remove(path):
try:
os.unlink(path)
except OSError: pass
class FileHandler(bbctrl.APIHandler):
def prepare(self): pass
def delete_ok(self, filename):
if not filename:
# Delete everything
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(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'].replace('\\', '/'))
filename = filename.replace('#', '-').replace('?', '-')
if not os.path.exists(self.get_upload()): os.mkdir(self.get_upload())
with open(self.get_upload(filename).encode('utf8'), 'wb') as f:
f.write(gcode['body'])
os.sync()
self.get_ctrl().preplanner.invalidate(filename)
self.get_ctrl().state.add_file(filename)
self.get_log('FileHandler').info('GCode received: ' + filename)
@gen.coroutine
def get(self, filename):
if not filename: raise HTTPError(400, 'Missing filename')
filename = os.path.basename(filename)
with open(self.get_upload(filename).encode('utf8'), 'r') as f:
self.write(f.read())
self.get_ctrl().state.select_file(filename)

91
src/py/bbctrl/I2C.py Normal file
View File

@@ -0,0 +1,91 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import errno
try:
try:
import smbus
except:
import smbus2 as smbus
except:
smbus = None
class I2C(object):
def __init__(self, port, disabled):
self.port = port
self.i2c_bus = None
self.disabled = disabled or smbus is None
def connect(self):
if self.disabled: return
if self.i2c_bus is None:
try:
self.i2c_bus = smbus.SMBus(self.port)
except OSError as e:
self.i2c_bus = None
if e.errno == errno.ENOENT: self.disabled = True
else: raise type(e)('I2C failed to open device: %s' % e)
def read_word(self, addr):
self.connect()
if self.disabled: return
try:
return self.i2c_bus.read_word_data(addr, 0)
except IOError as e:
self.i2c_bus.close()
self.i2c_bus = None
raise type(e)('I2C read word failed: %s' % e)
def write(self, addr, cmd, byte = None, word = None, block = None):
self.connect()
if self.disabled: return
try:
if byte is not None:
self.i2c_bus.write_byte_data(addr, cmd, byte)
elif word is not None:
self.i2c_bus.write_word_data(addr, cmd, word)
elif block is not None:
if isinstance(block, str): block = list(map(ord, block))
self.i2c_bus.write_i2c_block_data(addr, cmd, block)
else: self.i2c_bus.write_byte(addr, cmd)
except IOError as e:
self.i2c_bus.close()
self.i2c_bus = None
raise type(e)('I2C write failed: %s' % e)

93
src/py/bbctrl/IOLoop.py Normal file
View File

@@ -0,0 +1,93 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import tornado.ioloop
import bbctrl
class CB(object):
def __init__(self, ioloop, delay, cb, *args, **kwargs):
self.ioloop = ioloop
self.cb = cb
io = ioloop.ioloop
self.h = io.call_later(delay, self._cb, *args, **kwargs)
ioloop.callbacks[self.h] = self
def _cb(self, *args, **kwarg):
del self.ioloop.callbacks[self.h]
return self.cb(*args, **kwarg)
class IOLoop(object):
READ = tornado.ioloop.IOLoop.READ
WRITE = tornado.ioloop.IOLoop.WRITE
ERROR = tornado.ioloop.IOLoop.ERROR
def __init__(self, ioloop):
self.ioloop = ioloop
self.fds = set()
self.handles = set()
self.callbacks = {}
def close(self):
for fd in list(self.fds): self.ioloop.remove_handler(fd)
for h in list(self.handles): self.ioloop.remove_timeout(h)
for h in list(self.callbacks): self.ioloop.remove_timeout(h)
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 remove_handler(self, h):
self.ioloop.remove_handler(h)
if hasattr(h, 'fileno'): h = h.fileno()
self.fds.remove(h)
def update_handler(self, fd, events): self.ioloop.update_handler(fd, events)
def call_later(self, delay, callback, *args, **kwargs):
cb = CB(self, delay, callback, *args, **kwargs)
return cb.h
def remove_timeout(self, h):
self.ioloop.remove_timeout(h)
if h in self.handles: self.handles.remove(h)
if h in self.callbacks: del self.callbacks[h]
def add_callback(self, cb, *args, **kwargs):
self.ioloop.add_callback(cb, *args, **kwargs)

View File

@@ -0,0 +1,48 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import subprocess
import bbctrl
class IPLCDPage(bbctrl.LCDPage):
# From bbctrl.LCDPage
def activate(self):
p = subprocess.Popen(['hostname', '-I'], stdout = subprocess.PIPE)
ips = p.communicate()[0].decode('utf-8').split()
p = subprocess.Popen(['hostname'], stdout = subprocess.PIPE)
hostname = p.communicate()[0].decode('utf-8').strip()
self.clear()
self.text('Host: %s' % hostname[0:14], 0, 0)
for i in range(min(3, len(ips))):
if len(ips[i]) <= 16:
self.text('IP: %s' % ips[i], 0, i + 1)

93
src/py/bbctrl/Jog.py Normal file
View File

@@ -0,0 +1,93 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import inevent
from inevent.Constants import *
# 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": {
"deadband": 0.1,
"axes": [ABS_X, ABS_Y, ABS_RZ, ABS_Z],
"dir": [1, -1, -1, 1],
"arrows": [ABS_HAT0X, ABS_HAT0Y],
"speed": [0x120, 0x121, 0x122, 0x123],
"lock": [0x124, 0x125],
},
"default": {
"deadband": 0.1,
"axes": [ABS_X, ABS_Y, ABS_RY, ABS_RX],
"dir": [1, -1, -1, 1],
"arrows": [ABS_HAT0X, ABS_HAT0Y],
"speed": [0x133, 0x130, 0x131, 0x134],
"lock": [0x136, 0x137],
}
}
super().__init__(config)
self.v = [0.0] * 4
self.lastV = self.v
self.callback()
self.processor = inevent.InEvent(ctrl.ioloop, self, types = ['js'])
def up(self): self.ctrl.lcd.page_up()
def down(self): self.ctrl.lcd.page_down()
def left(self): self.ctrl.lcd.page_left()
def right(self): self.ctrl.lcd.page_right()
def callback(self):
if self.v != self.lastV:
self.lastV = self.v
try:
axes = {}
for i in range(len(self.v)): axes["xyzabc"[i]] = self.v[i]
self.ctrl.mach.jog(axes)
except Exception as e:
self.log.warning('Jog: %s', e)
self.ctrl.ioloop.call_later(0.25, self.callback)
def changed(self):
scale = 1.0
if self.speed == 1: scale = 1.0 / 128.0
if self.speed == 2: scale = 1.0 / 32.0
if self.speed == 3: scale = 1.0 / 4.0
self.v = [x * scale for x in self.axes]

207
src/py/bbctrl/LCD.py Normal file
View File

@@ -0,0 +1,207 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import lcd
import atexit
class LCDPage:
def __init__(self, lcd, text = None):
self.lcd = lcd
self.data = lcd.new_screen()
if text is not None:
self.text(text, (lcd.width - len(text)) // 2, 1)
def activate(self): pass
def deactivate(self): pass
def put(self, c, x, y):
y += x // self.lcd.width
x %= self.lcd.width
y %= self.lcd.height
if self.data[x][y] != c:
self.data[x][y] = c
if self == self.lcd.page: self.lcd.update()
def text(self, s, x, y):
for c in s:
self.put(c, x, y)
x += 1
def clear(self):
self.data = self.lcd.new_screen()
self.lcd.redraw = True
def shift_left(self): pass
def shift_right(self): pass
def shift_up(self): pass
def shift_down(self): pass
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]
self.addr_num = 0
self.width = 20
self.height = 4
self.lcd = None
self.timeout = None
self.reset = False
self.page = None
self.pages = []
self.current_page = 0
self.screen = self.new_screen()
self.set_message('Loading...')
self._redraw(False)
if not ctrl.args.demo: atexit.register(self.goodbye)
def set_message(self, msg):
try:
self.load_page(LCDPage(self, msg))
self._update()
except IOError as e:
self.log.warning('LCD communication failed: %s' % e)
def new_screen(self):
return [[' ' for y in range(self.height)] for x in range(self.width)]
def new_page(self): return LCDPage(self)
def add_page(self, page): self.pages.append(page)
def add_new_page(self, page = None):
if page is None: page = self.new_page()
page.id = len(self.pages)
self.add_page(page)
return page
def load_page(self, page):
if self.page != page:
if self.page is not None: self.page.deactivate()
page.activate()
self.page = page
self.redraw = True
self.update()
def set_current_page(self, current_page):
self.current_page = current_page % len(self.pages)
self.load_page(self.pages[self.current_page])
def page_up(self): pass
def page_down(self): pass
def page_right(self): self.set_current_page(self.current_page + 1)
def page_left(self): self.set_current_page(self.current_page - 1)
def update(self):
if self.timeout is None:
self.timeout = self.ctrl.ioloop.call_later(0.25, self._update)
def _redraw(self, now = True):
if now:
self.redraw = True
self.update()
self.redraw_timer = self.ctrl.ioloop.call_later(5, self._redraw)
def _update(self):
self.timeout = None
try:
if self.lcd is None:
self.lcd = lcd.LCD(self.ctrl.i2c, self.addr, self.height,
self.width)
if self.reset:
self.lcd.reset()
self.redraw = True
self.reset = False
cursorX, cursorY = -1, -1
for y in range(self.height):
for x in range(self.width):
c = self.page.data[x][y]
if self.redraw or self.screen[x][y] != c:
if cursorX != x or cursorY != y:
self.lcd.goto(x, y)
cursorX, cursorY = x, y
self.lcd.put_char(c)
cursorX += 1
self.screen[x][y] = c
self.redraw = False
except IOError as e:
# Try next address
#self.addr_num += 1
#if len(self.addrs) <= self.addr_num: self.addr_num = 0
#self.addr = self.addrs[self.addr_num]
#self.lcd = None
#self.log.warning('LCD communication failed, ' +
# 'retrying on address 0x%02x: %s' % (self.addr, e))
#self.log.warning('LCD not present.')
#self.reset = True
self.reset = False
#self.timeout = self.ctrl.ioloop.call_later(1, self._update)
def goodbye(self, message = ''):
if self.timeout:
self.ctrl.ioloop.remove_timeout(self.timeout)
self.timeout = None
if self.redraw_timer:
self.ctrl.ioloop.remove_timeout(self.redraw_timer)
self.redraw_timer = None
if self.lcd is not None: self.set_message(message)

185
src/py/bbctrl/Log.py Normal file
View File

@@ -0,0 +1,185 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import sys
import io
import datetime
import traceback
import pkg_resources
from inspect import getframeinfo, stack
import bbctrl
DEBUG = 0
INFO = 1
MESSAGE = 2
WARNING = 3
ERROR = 4
level_names = 'debug info message warning error'.split()
def get_level_name(level): return level_names[level]
# Get this file's name
_srcfile = os.path.normcase(get_level_name.__code__.co_filename)
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 _find_caller(self):
f = sys._getframe()
if f is not None: f = f.f_back
while hasattr(f, 'f_code'):
co = f.f_code
filename = os.path.normcase(co.co_filename)
if filename == _srcfile:
f = f.f_back
continue
return co.co_filename, f.f_lineno, co.co_name
return '(unknown file)', 0, '(unknown function)'
def _log(self, level, msg, *args, **kwargs):
if not self._enabled(level): return
if not 'where' in kwargs:
filename, line, func = self._find_caller()
kwargs['where'] = '%s:%d' % (os.path.basename(filename), line)
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 message(self, *args, **kwargs): self._log(MESSAGE, *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, args, ioloop, path):
self.path = path
self.listeners = []
self.loggers = {}
self.level = DEBUG if args.verbose else INFO
# Open log, rotate if necessary
self.f = None
self._open()
# Log header
version = pkg_resources.require('bbctrl')[0].version
self._log('Log started v%s' % version)
self._log_time(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:' % ('DIMWE'[level], prefix)
s = hdr + ('\n' + hdr).join(msg.split('\n'))
if self.f is not None:
if 1e22 <= self.bytes_written + len(s) + 1: self._open()
self.f.write(s + '\n')
self.f.flush()
self.bytes_written += len(s) + 1
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))
def _open(self):
if self.path is None: return
if self.f is not None: self.f.close()
self._rotate(self.path)
self.f = open(self.path, 'w')
self.bytes_written = 0
def _rotate(self, path, n = None):
fullpath = '%s.%d' % (path, n) if n is not None else path
nextN = (0 if n is None else n) + 1
if os.path.exists(fullpath):
if n == 16: os.unlink(fullpath)
else: self._rotate(path, nextN)
os.rename(fullpath, '%s.%d' % (path, nextN))

351
src/py/bbctrl/Mach.py Normal file
View File

@@ -0,0 +1,351 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import bbctrl
from bbctrl.Comm import Comm
import bbctrl.Cmd as Cmd
# Axis homing procedure:
#
# Mark axis unhomed
# Set feed rate to search_vel
# Seek closed by search_dist
# Set feed rate to latch_vel
# Seek open latch_backoff
# Seek closed latch_backoff * -1.5
# Rapid to zero_backoff
# Mark axis homed and set absolute position
axis_homing_procedure = '''
G28.2 %(axis)s0 F[#<_%(axis)s_search_velocity>]
G38.6 %(axis)s[#<_%(axis)s_home_travel>]
G38.8 %(axis)s[#<_%(axis)s_latch_backoff>] F[#<_%(axis)s_latch_velocity>]
G38.6 %(axis)s[#<_%(axis)s_latch_backoff> * -8]
G91 G0 G53 %(axis)s[#<_%(axis)s_zero_backoff>]
G90 G28.3 %(axis)s[#<_%(axis)s_home_position>]
'''
stall_homing_procedure = '''
G28.2 %(axis)s0 F[#<_%(axis)s_search_velocity>]
G38.6 %(axis)s[#<_%(axis)s_home_travel>]
G91 G1 G53 %(axis)s[#<_%(axis)s_zero_backoff>] F100
G90 G28.3 %(axis)s[#<_%(axis)s_home_position>]
'''
motor_fault_error = '''\
Motor %d driver fault. A potentially damaging electrical condition was \
detected and the motor driver was shutdown. Please power down the controller \
and check your motor cabling. See the "Motor Faults" table on the "Indicators" \
for more information.\
'''
def overrides(interface_class):
def overrider(method):
if not method.__name__ in dir(interface_class):
raise Exception('%s does not override %s' % (
method.__name__, interface_class.__name__))
return method
return overrider
class Mach(Comm):
def __init__(self, ctrl, avr):
super().__init__(ctrl, avr)
self.ctrl = ctrl
self.mlog = self.ctrl.log.get('Mach')
self.planner = bbctrl.Planner(ctrl)
self.unpausing = False
ctrl.state.set('cycle', 'idle')
ctrl.state.add_listener(self._update)
super().reboot()
def _get_state(self): return self.ctrl.state.get('xx', '')
def _is_estopped(self): return self._get_state() == 'ESTOPPED'
def _is_holding(self): return self._get_state() == 'HOLDING'
def _is_ready(self): return self._get_state() == 'READY'
def _get_pause_reason(self): return self.ctrl.state.get('pr', '')
def _get_cycle(self): return self.ctrl.state.get('cycle', 'idle')
def _is_paused(self):
if not self._is_holding() or self.unpausing: return False
return self._get_pause_reason() in (
'User pause', 'Program pause', 'Optional pause')
def _set_cycle(self, cycle): self.ctrl.state.set('cycle', cycle)
def _begin_cycle(self, cycle):
current = self._get_cycle()
if current == cycle: return # No change
if current != 'idle':
raise Exception('Cannot enter %s cycle while in %s cycle' %
(cycle, current))
# TODO handle jogging during pause
# if current == 'idle' or (cycle == 'jogging' and self._is_paused()):
self._set_cycle(cycle)
def _update(self, update):
# Detect motor faults
for motor in range(4):
key = '%ddf' % motor
if key in update and update[key] & 0x1f:
self.mlog.error(motor_fault_error % motor)
# Get state
state_changed = 'xc' in update
state = self._get_state()
# Handle EStop
if state_changed and state == 'ESTOPPED': self.planner.reset(False)
# Exit cycle if state changed to READY
if (state_changed and self._get_cycle() != 'idle' and
self._is_ready() and not self.planner.is_busy() and
not super().is_active()):
self.planner.position_change()
self._set_cycle('idle')
# Unpause sync
if state_changed and state != 'HOLDING': self.unpausing = False
# Entering HOLDING state
if state_changed and state == 'HOLDING':
# Always flush queue after pause
super().i2c_command(Cmd.FLUSH)
super().resume()
# Automatically unpause after seek or stop hold
# Must be after holding commands above
op = self.ctrl.state.get('optional_pause', False)
pr = self._get_pause_reason()
if ((state_changed or 'pr' in update) and self._is_holding() and
(pr in ('Switch found', 'User stop') or
(pr == 'Optional pause' and not op))):
self._unpause()
def _unpause(self):
pause_reason = self._get_pause_reason()
self.mlog.info('Unpause: ' + pause_reason)
if pause_reason == 'User stop':
self.planner.stop()
self.ctrl.state.set('line', 0)
else: self.planner.restart()
super().i2c_command(Cmd.UNPAUSE)
self.unpausing = True
def _reset(self): self.planner.reset()
def _i2c_block(self, block):
super().i2c_command(block[0], block = block[1:])
def _i2c_set(self, name, value): self._i2c_block(Cmd.set(name, value))
@overrides(Comm)
def comm_next(self):
if self.planner.is_running() and not self._is_holding():
return self.planner.next()
@overrides(Comm)
def comm_error(self): self._reset()
@overrides(Comm)
def connect(self):
self._reset()
super().connect()
def _query_var(self, cmd):
equal = cmd.find('=')
if equal == -1:
self.mlog.info('%s=%s' % (cmd, self.ctrl.state.get(cmd[1:])))
else:
name, value = cmd[1:equal], cmd[equal + 1:]
if value.lower() == 'true': value = True
elif value.lower() == 'false': value = False
else:
try:
value = float(value)
except: pass
self.ctrl.state.config(name, value)
def mdi(self, cmd, with_limits = True):
if not len(cmd): return
if cmd[0] == '$': self._query_var(cmd)
elif cmd[0] == '\\': super().queue_command(cmd[1:])
else:
self._begin_cycle('mdi')
self.planner.mdi(cmd, with_limits)
super().resume()
def set(self, code, value):
super().queue_command('${}={}'.format(code, value))
def jog(self, axes):
self._begin_cycle('jogging')
self.planner.position_change()
super().queue_command(Cmd.jog(axes))
def home(self, axis, position = None):
state = self.ctrl.state
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 mode.startswith('stall-'): procedure = stall_homing_procedure
else: procedure = axis_homing_procedure
self.planner.mdi(procedure % {'axis': axis}, False)
# self.planner.mdi(axis_homing_procedure % {'axis': axis}, False)
super().resume()
def unhome(self, axis): self.mdi('G28.2 %c0' % axis)
def estop(self): super().estop()
def clear(self):
if self._is_estopped():
self._reset()
super().clear()
def start(self):
filename = self.ctrl.state.get('selected', '')
if not filename: return
self._begin_cycle('running')
self.planner.load(filename)
super().resume()
def step(self):
raise Exception('NYI') # TODO
if self._get_cycle() != 'running': self.start()
else: super().i2c_command(Cmd.UNPAUSE)
def stop(self): super().i2c_command(Cmd.STOP)
def pause(self): super().pause()
def unpause(self):
if self._is_paused():
self.ctrl.state.set('optional_pause', False)
self._unpause()
def optional_pause(self, enable = True):
self.ctrl.state.set('optional_pause', enable)
def set_position(self, axis, position):
axis = axis.lower()
state = self.ctrl.state
if state.is_axis_homed(axis):
# If homed, change the offset rather than the absolute position
self.mdi('G92%s%f' % (axis, position))
elif state.is_axis_enabled(axis):
if self._get_cycle() != 'idle' and not self._is_paused():
raise Exception('Cannot set position during ' +
self._get_cycle())
# Set the absolute position both locally and via the AVR
target = position + state.get('offset_' + axis)
state.set(axis + 'p', target)
super().queue_command(Cmd.set_axis(axis, target))
def override_feed(self, override):
self._i2c_set('fo', int(1000 * override))
def override_speed(self, override):
self._i2c_set('so', int(1000 * override))
def modbus_read(self, addr): self._i2c_block(Cmd.modbus_read(addr))
def modbus_write(self, addr, value):
self._i2c_block(Cmd.modbus_write(addr, value))

View File

@@ -0,0 +1,76 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import bbctrl
class MainLCDPage(bbctrl.LCDPage):
def __init__(self, ctrl):
bbctrl.LCDPage.__init__(self, ctrl.lcd)
self.ctrl = ctrl
self.install = True
ctrl.state.add_listener(self.update)
def update(self, update):
state = self.ctrl.state
# Must be after machine vars have loaded
if self.install and hasattr(self, 'id'):
self.install = False
self.ctrl.lcd.set_current_page(self.id)
self.text('%-9s' % state.get('xx', ''), 0, 0)
metric = not state.get('imperial', False)
scale = 1 if metric else 25.4
# Show enabled axes
row = 0
for axis in 'xyzabc':
if state.is_axis_faulted(axis):
self.text(' FAULT %s' % axis.upper(), 9, row)
row += 1
elif state.is_axis_enabled(axis):
position = state.get(axis + 'p', 0)
position += state.get('offset_' + axis, 0)
position /= scale
self.text('% 10.3f%s' % (position, axis.upper()), 9, row)
row += 1
while row < 4:
self.text(' ' * 11, 9, row)
row += 1
# Show tool, units, feed and speed
self.text('%2uT' % state.get('tool', 0), 6, 1)
self.text('%-6s' % 'MM' if metric else 'INCH', 0, 1)
self.text('%8uF' % (state.get('feed', 0) / scale), 0, 2)
self.text('%8dS' % state.get('speed', 0), 0, 3)

View File

@@ -0,0 +1,104 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import time
def read_temp():
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
return round(int(f.read()) / 1000)
def set_max_freq(freq):
filename = '/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq'
with open(filename, 'w') as f: f.write('%d\n' % freq)
class MonitorTemp(object):
def __init__(self, app):
self.app = app
self.ctrl = app.get_ctrl()
self.log = self.ctrl.log.get('Mon')
self.ioloop = self.ctrl.ioloop
self.last_temp_warn = 0
self.temp_thresh = 80
self.min_temp = 60
self.max_temp = 80
self.min_freq = 600000
self.max_freq = 1200000
self.low_camera_temp = 75
self.high_camera_temp = 80
self.callback()
# Scale max CPU based on temperature
def scale_cpu(self, temp):
if temp < self.min_temp: cpu_freq = self.max_freq
elif self.max_temp < temp: cpu_freq = self.min_freq
else:
r = 1 - float(temp - self.min_temp) / \
(self.max_temp - self.min_temp)
cpu_freq = self.min_freq + (self.max_freq - self.min_freq) * r
set_max_freq(cpu_freq)
def update_camera(self, temp):
if self.app.camera is None: return
# Disable camera if temp too high
if temp < self.low_camera_temp: self.app.camera.set_overtemp(False)
elif self.high_camera_temp < temp:
self.app.camera.set_overtemp(True)
def log_warnings(self, temp):
# Reset temperature warning threshold after timeout
if time.time() < self.last_temp_warn + 60: self.temp_thresh = 80
if self.temp_thresh < temp:
self.last_temp_warn = time.time()
self.temp_thresh = temp
self.log.info('Hot RaspberryPi at %d°C' % temp)
def callback(self):
try:
temp = read_temp()
self.ctrl.state.set('rpi_temp', temp)
self.scale_cpu(temp)
self.update_camera(temp)
self.log_warnings(temp)
except: self.log.exception()
self.ioloop.call_later(5, self.callback)

1223
src/py/bbctrl/ObjGraph.py Normal file

File diff suppressed because it is too large Load Diff

388
src/py/bbctrl/Planner.py Normal file
View File

@@ -0,0 +1,388 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import json
import math
import re
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
reLogLine = re.compile(
r'^(?P<level>[A-Z])[0-9 ]:'
r'((?P<file>[^:]+):)?'
r'((?P<line>\d+):)?'
r'((?P<column>\d+):)?'
r'(?P<msg>.*)$')
def log_floats(o):
if isinstance(o, float): return round(o, 2)
if isinstance(o, dict): return {k: log_floats(v) for k, v in o.items()}
if isinstance(o, (list, tuple)): return [log_floats(x) for x in o]
return o
def log_json(o): return json.dumps(log_floats(o))
class Planner():
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('Planner')
self.cmdq = CommandQueue(ctrl)
self.planner = None
self._position_dirty = False
self.where = ''
ctrl.state.add_listener(self._update)
self.reset(False)
self._report_time()
def is_busy(self): return self.is_running() or self.cmdq.is_active()
def is_running(self): return self.planner.is_running()
def position_change(self): self._position_dirty = True
def _sync_position(self, force = False):
if not force and not self._position_dirty: return
self._position_dirty = False
self.planner.set_position(self.ctrl.state.get_position())
def get_config(self, mdi, with_limits):
state = self.ctrl.state
config = self.ctrl.config
cfg = {
# NOTE Must get current units not configured default units
'default-units': 'METRIC' if state.get('metric') else 'IMPERIAL',
'max-vel': state.get_axis_vector('vm', 1000),
'max-accel': state.get_axis_vector('am', 1000000),
'max-jerk': state.get_axis_vector('jm', 1000000),
'rapid-auto-off': config.get('rapid-auto-off'),
'max-blend-error': config.get('max-deviation'),
'max-merge-error': config.get('max-deviation'),
'junction-accel': config.get('junction-accel'),
}
if with_limits:
minLimit = state.get_soft_limit_vector('tn', -math.inf)
maxLimit = state.get_soft_limit_vector('tm', math.inf)
# If max <= min then no limit
for axis in 'xyzabc':
if maxLimit[axis] <= minLimit[axis]:
minLimit[axis], maxLimit[axis] = -math.inf, math.inf
cfg['min-soft-limit'] = minLimit
cfg['max-soft-limit'] = maxLimit
if not mdi:
program_start = config.get('program-start')
if program_start: cfg['program-start'] = program_start
overrides = {}
tool_change = config.get('tool-change')
if tool_change: overrides['M6'] = tool_change
program_end = config.get('program-end')
if program_end: overrides['M2'] = program_end
if overrides: cfg['overrides'] = overrides
self.log.info('Config:' + log_json(cfg))
return cfg
def _update(self, update):
if 'id' in update:
id = update['id']
self.planner.set_active(id) # Release planner commands
self.cmdq.release(id) # Synchronize planner variables
def _get_var_cb(self, name, units):
value = 0
if len(name) and name[0] == '_':
value = self.ctrl.state.get(name[1:], 0)
try:
float(value)
if units == 'IMPERIAL': value /= 25.4 # Assume metric
except ValueError: value = 0
self.log.info('Get: %s=%s (units=%s)' % (name, value, units))
return value
def _log_cb(self, line):
line = line.strip()
m = reLogLine.match(line)
if not m: return
level = m.group('level')
msg = m.group('msg')
filename = m.group('file')
line = m.group('line')
column = m.group('column')
where = ':'.join(filter(None.__ne__, [filename, line, column]))
if line is not None: line = int(line)
if column is not None: column = int(column)
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 _add_message(self, text):
self.ctrl.state.add_message(text)
line = self.ctrl.state.get('line', 0)
if 0 <= line: where = '%s:%d' % (self.where, line)
else: where = self.where
self.log.message(text, where = where)
def _enqueue_set_cmd(self, id, name, value):
self.log.info('set(#%d, %s, %s)', id, name, value)
self.cmdq.enqueue(id, self.ctrl.state.set, name, value)
def _report_time(self):
state = self.ctrl.state.get('xx', '')
if state in ('STOPPING', 'RUNNING') and self.move_start:
delta = time.time() - self.move_start
if self.move_time < delta: delta = self.move_time
plan_time = self.current_plan_time + delta
self.ctrl.state.set('plan_time', round(plan_time))
elif state != 'HOLDING': self.ctrl.state.set('plan_time', 0)
self.ctrl.ioloop.call_later(1, self._report_time)
def _plan_time_restart(self):
self.plan_time = self.ctrl.state.get('plan_time', 0)
def _update_time(self, plan_time, move_time):
self.current_plan_time = plan_time
self.move_time = move_time
self.move_start = time.time()
def _enqueue_line_time(self, block):
if block.get('first', False) or block.get('seeking', False): return
# Sum move times
move_time = sum(block['times']) / 1000 # To seconds
self.cmdq.enqueue(block['id'], self._update_time, self.plan_time,
move_time)
self.plan_time += move_time
def _enqueue_dwell_time(self, block):
self.cmdq.enqueue(block['id'], self._update_time, self.plan_time,
block['seconds'])
self.plan_time += block['seconds']
def __encode(self, block):
type, id = block['type'], block['id']
if type != 'set': self.log.info('Cmd:' + log_json(block))
if type == 'line':
self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'],
block['times'], block.get('speeds', []))
if type == 'set':
name, value = block['name'], block['value']
if name == 'message':
self.cmdq.enqueue(id, self._add_message, value)
if name in ['line', 'tool']: self._enqueue_set_cmd(id, name, value)
if name == 'speed':
self._enqueue_set_cmd(id, name, value)
return Cmd.speed(value)
if len(name) and name[0] == '_':
# Don't queue axis positions, can be triggered by new position
if len(name) != 2 or name[1] not in 'xyzabc':
self._enqueue_set_cmd(id, name[1:], value)
if name == '_feed': # Must come after _enqueue_set_cmd() above
return Cmd.set_sync('if', 1 / value if value else 0)
if name[0:1] == '_' and name[1:2] in 'xyzabc':
if name[2:] == '_home': return Cmd.set_axis(name[1], value)
if name[2:] == '_homed':
motor = self.ctrl.state.find_motor(name[1])
if motor is not None:
return Cmd.set_sync('%dh' % motor, value)
return
if type == 'input':
# TODO handle timeout
self.planner.synchronize(0) # TODO Fix this
return Cmd.input(block['port'], block['mode'], block['timeout'])
if type == 'output':
return Cmd.output(block['port'], int(float(block['value'])))
if type == 'dwell':
self._enqueue_dwell_time(block)
return Cmd.dwell(block['seconds'])
if type == 'pause': return Cmd.pause(block['pause-type'])
if type == 'seek':
sw = self.ctrl.state.get_switch_id(block['switch'])
return Cmd.seek(sw, block['active'], block['error'])
if type == 'end': return '' # Sends id
raise Exception('Unknown planner command "%s"' % type)
def _encode(self, block):
cmd = self.__encode(block)
if cmd is not None:
self.cmdq.enqueue(block['id'], None)
return Cmd.set_sync('id', block['id']) + '\n' + cmd
def reset_times(self):
self.move_start = 0
self.move_time = 0
self.plan_time = 0
self.current_plan_time = 0
def close(self):
# Release planner callbacks
if self.planner is not None:
self.planner.set_resolver(None)
self.planner.set_logger(None)
def reset(self, stop = True):
if stop: self.ctrl.mach.stop()
self.planner = gplan.Planner()
self.planner.set_resolver(self._get_var_cb)
# TODO logger is global and will not work correctly in demo mode
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
self._position_dirty = True
self.cmdq.clear()
self.reset_times()
self.ctrl.state.reset()
def mdi(self, cmd, with_limits = True):
self.where = '<mdi>'
self.log.info('MDI:' + cmd)
self._sync_position()
self.planner.load_string(cmd, self.get_config(True, with_limits))
self.reset_times()
def load(self, path):
self.where = path
path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path)
self._sync_position()
self.planner.load(path, self.get_config(False, True))
self.reset_times()
def stop(self):
try:
self.planner.stop()
self.cmdq.clear()
except:
self.log.exception()
self.reset()
def restart(self):
try:
id = self.ctrl.state.get('id')
position = self.ctrl.state.get_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:
self.log.exception()
self.stop()
def next(self):
try:
while self.planner.has_more():
cmd = self.planner.next()
cmd = self._encode(cmd)
if cmd is not None: return cmd
except RuntimeError as e:
# Pass on the planner message
self.log.error(str(e));
self.stop()
except:
self.log.exception()
self.stop()

271
src/py/bbctrl/Preplanner.py Normal file
View File

@@ -0,0 +1,271 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import time
import json
import hashlib
import glob
import tempfile
import signal
from concurrent.futures import Future
from tornado import gen, process, iostream
import bbctrl
def hash_dump(o):
s = json.dumps(o, separators = (',', ':'), sort_keys = True)
return s.encode('utf8')
def plan_hash(path, config):
h = hashlib.sha256()
h.update('v4'.encode('utf8'))
h.update(hash_dump(config))
with open(path, 'rb') as f:
while True:
buf = f.read(1024 * 1024)
if not buf: break
h.update(buf)
return h.hexdigest()
def safe_remove(path):
try:
os.unlink(path)
except: pass
class Plan(object):
def __init__(self, preplanner, ctrl, filename):
self.preplanner = preplanner
# Copy planner state
self.state = ctrl.state.snapshot()
self.config = ctrl.mach.planner.get_config(False, False)
del self.config['default-units']
self.progress = 0
self.cancel = False
self.pid = None
root = ctrl.get_path()
self.gcode = '%s/upload/%s' % (root, filename)
self.base = '%s/plans/%s' % (root, filename)
self.hid = plan_hash(self.gcode, self.config)
fbase = '%s.%s.' % (self.base, self.hid)
self.files = [
fbase + 'json',
fbase + 'positions.gz',
fbase + 'speeds.gz']
self.future = Future()
ctrl.ioloop.add_callback(self._load)
def terminate(self):
if self.cancel: return
self.cancel = True
if self.pid is not None:
try:
os.kill(self.pid, signal.SIGKILL)
except: pass
def delete(self):
files = glob.glob(self.base + '.*')
for path in files: safe_remove(path)
def clean(self, max = 2):
plans = glob.glob(self.base + '.*.json')
if len(plans) <= max: return
# Delete oldest plans
plans = [(os.path.getmtime(path), path) for path in plans]
plans.sort()
for mtime, path in plans[:len(plans) - max]:
safe_remove(path)
safe_remove(path[:-4] + 'positions.gz')
safe_remove(path[:-4] + 'speeds.gz')
def _exists(self):
for path in self.files:
if not os.path.exists(path): return False
return True
def _read(self):
if self.cancel: return
try:
with open(self.files[0], 'r') as f: meta = json.load(f)
with open(self.files[1], 'rb') as f: positions = f.read()
with open(self.files[2], 'rb') as f: speeds = f.read()
return meta, positions, speeds
except:
self.preplanner.log.exception()
# Clean
for path in self.files:
if os.path.exists(path):
os.remove(path)
@gen.coroutine
def _exec(self):
self.clean() # Clean up old plans
with tempfile.TemporaryDirectory() as tmpdir:
cmd = (
'/usr/bin/env', 'python3',
bbctrl.get_resource('plan.py'),
os.path.abspath(self.gcode), json.dumps(self.state),
json.dumps(self.config),
'--max-time=%s' % self.preplanner.max_plan_time,
'--max-loop=%s' % self.preplanner.max_loop_time
)
self.preplanner.log.info('Running: %s', cmd)
proc = process.Subprocess(cmd, stdout = process.Subprocess.STREAM,
stderr = process.Subprocess.STREAM,
cwd = tmpdir)
errs = ''
self.pid = proc.proc.pid
try:
try:
while True:
line = yield proc.stdout.read_until(b'\n')
self.progress = float(line.strip())
if self.cancel: return
except iostream.StreamClosedError: pass
self.progress = 1
ret = yield proc.wait_for_exit(False)
if ret:
errs = yield proc.stderr.read_until_close()
raise Exception('Plan failed: ' + errs.decode('utf8'))
finally:
proc.stderr.close()
proc.stdout.close()
if not self.cancel:
os.rename(tmpdir + '/meta.json', self.files[0])
os.rename(tmpdir + '/positions.gz', self.files[1])
os.rename(tmpdir + '/speeds.gz', self.files[2])
os.sync()
@gen.coroutine
def _load(self):
try:
if self._exists():
data = self._read()
if data is not None:
self.future.set_result(data)
return
if not self._exists(): yield self._exec()
self.future.set_result(self._read())
except:
self.preplanner.log.exception()
class Preplanner(object):
def __init__(self, ctrl, 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
path = self.ctrl.get_plan()
if not os.path.exists(path): os.mkdir(path)
self.started = Future()
self.plans = {}
def start(self):
if not self.started.done():
self.log.info('Preplanner started')
self.started.set_result(True)
def invalidate(self, filename):
if filename in self.plans:
self.plans[filename].terminate()
del self.plans[filename]
def invalidate_all(self):
for filename, plan in self.plans.items():
plan.terminate()
self.plans = {}
def delete_all_plans(self):
files = glob.glob(self.ctrl.get_plan('*'))
for path in files: safe_remove(path)
self.invalidate_all()
def delete_plans(self, filename):
if filename in self.plans:
self.plans[filename].delete()
self.invalidate(filename)
@gen.coroutine
def get_plan(self, filename):
if filename is None: raise Exception('Filename cannot be None')
# Wait until state is fully initialized
yield self.started
if filename in self.plans: plan = self.plans[filename]
else:
plan = Plan(self, self.ctrl, filename)
self.plans[filename] = plan
data = yield plan.future
return data
def get_plan_progress(self, filename):
return self.plans[filename].progress if filename in self.plans else 0

194
src/py/bbctrl/Pwr.py Normal file
View File

@@ -0,0 +1,194 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import bbctrl
import bbctrl.Cmd as Cmd
# Must match regs in pwr firmware
TEMP_REG = 0
VIN_REG = 1
VOUT_REG = 2
MOTOR_REG = 3
LOAD1_REG = 4
LOAD2_REG = 5
VDD_REG = 6
FLAGS_REG = 7
VERSION_REG = 8
# Must be kept in sync with pwr firmware
UNDER_VOLTAGE_FLAG = 1 << 0
OVER_VOLTAGE_FLAG = 1 << 1
OVER_CURRENT_FLAG = 1 << 2
SENSE_ERROR_FLAG = 1 << 3
SHUNT_OVERLOAD_FLAG = 1 << 4
MOTOR_OVERLOAD_FLAG = 1 << 5
LOAD1_SHUTDOWN_FLAG = 1 << 6
LOAD2_SHUTDOWN_FLAG = 1 << 7
MOTOR_UNDER_VOLTAGE_FLAG = 1 << 8
MOTOR_VOLTAGE_SENSE_ERROR_FLAG = 1 << 9
MOTOR_CURRENT_SENSE_ERROR_FLAG = 1 << 10
LOAD1_SENSE_ERROR_FLAG = 1 << 11
LOAD2_SENSE_ERROR_FLAG = 1 << 12
VDD_CURRENT_SENSE_ERROR_FLAG = 1 << 13
POWER_SHUTDOWN_FLAG = 1 << 14
SHUNT_ERROR_FLAG = 1 << 15
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
self.lcd_page = ctrl.lcd.add_new_page()
self.failures = 0
self._update_cb(False)
def check_fault(self, var, status):
status = bool(status)
if not self.ctrl.state.has(var) or status != self.ctrl.state.get(var):
self.ctrl.state.set(var, status)
if status: return True
return False
def check_faults(self):
flags = self.regs[FLAGS_REG]
if self.check_fault('under_voltage', flags & UNDER_VOLTAGE_FLAG):
self.log.warning('Device under voltage')
if self.check_fault('over_voltage', flags & OVER_VOLTAGE_FLAG):
self.log.warning('Device over voltage')
if self.check_fault('over_current', flags & OVER_CURRENT_FLAG):
self.log.warning('Device total current limit exceeded')
if self.check_fault('sense_error', flags & SENSE_ERROR_FLAG):
self.log.warning('Power sense error')
if self.check_fault('shunt_overload', flags & SHUNT_OVERLOAD_FLAG):
self.log.warning('Power shunt overload')
if self.check_fault('motor_overload', flags & MOTOR_OVERLOAD_FLAG):
self.log.warning('Motor power overload')
if self.check_fault('load1_shutdown', flags & LOAD1_SHUTDOWN_FLAG):
self.log.warning('Load 1 over temperature shutdown')
if self.check_fault('load2_shutdown', flags & LOAD2_SHUTDOWN_FLAG):
self.log.warning('Load 2 over temperature shutdown')
if self.check_fault('motor_under_voltage',
flags & MOTOR_UNDER_VOLTAGE_FLAG):
self.log.warning('Motor under voltage')
if self.check_fault('motor_voltage_sense_error',
flags & MOTOR_VOLTAGE_SENSE_ERROR_FLAG):
self.log.warning('Motor voltage sense error')
if self.check_fault('motor_current_sense_error',
flags & MOTOR_CURRENT_SENSE_ERROR_FLAG):
self.log.warning('Motor current sense error')
if self.check_fault('load1_sense_error',
flags & LOAD1_SENSE_ERROR_FLAG):
self.log.warning('Load1 sense error')
if self.check_fault('load2_sense_error',
flags & LOAD2_SENSE_ERROR_FLAG):
self.log.warning('Load2 sense error')
if self.check_fault('vdd_current_sense_error',
flags & VDD_CURRENT_SENSE_ERROR_FLAG):
self.log.warning('Vdd current sense error')
if self.check_fault('power_shutdown', flags & POWER_SHUTDOWN_FLAG):
self.log.warning('Power shutdown')
self.ctrl.mach.i2c_command(Cmd.SHUTDOWN)
if self.check_fault('shunt_error', flags & SHUNT_ERROR_FLAG):
self.log.warning('Shunt error')
def _update_cb(self, now = True):
if now: self._update()
self.ctrl.ioloop.call_later(1, self._update_cb)
def _update(self):
update = {}
try:
for i in range(len(self.regs)):
value = self.ctrl.i2c.read_word(self.i2c_addr + i)
if value is None: return # Handle lack of i2c port
if i == TEMP_REG: value -= 273
elif i == FLAGS_REG or i == VERSION_REG: pass
else: value /= 100.0
key = reg_names[i]
self.ctrl.state.set(key, value)
if self.regs[i] != value:
update[key] = value
self.regs[i] = value
if i == FLAGS_REG: self.check_faults()
except Exception as e:
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: self.log.info(msg)
else:
self.log.warning(msg)
self.failures = 0
return
self.lcd_page.text('%3dC Tmp' % self.regs[TEMP_REG], 0, 0)
self.lcd_page.text('%5.1fV In' % self.regs[VIN_REG], 0, 1)
self.lcd_page.text('%5.1fV Out' % self.regs[VOUT_REG], 0, 2)
self.lcd_page.text(' %04x Flg' % self.regs[FLAGS_REG], 0, 3)
self.lcd_page.text('%5.1fA Mot' % self.regs[MOTOR_REG], 10, 0)
self.lcd_page.text('%5.1fA Ld1' % self.regs[LOAD1_REG], 10, 1)
self.lcd_page.text('%5.1fA Ld2' % self.regs[LOAD2_REG], 10, 2)
self.lcd_page.text('%5.1fA Vdd' % self.regs[VDD_REG], 10, 3)
if len(update): self.ctrl.state.update(update)
self.failures = 0

View File

@@ -0,0 +1,63 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import traceback
import bbctrl
from tornado.web import HTTPError
import tornado.web
class RequestHandler(tornado.web.RequestHandler):
def __init__(self, app, request, **kwargs):
super().__init__(app, request, **kwargs)
self.app = app
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)
def get_path(self, path = None, filename = None):
return self.get_ctrl().get_path(path, filename)
def get_upload(self, filename = None):
return self.get_ctrl().get_upload(filename)
# Override exception logging
def log_exception(self, typ, value, tb):
if (isinstance(value, HTTPError) and
400 <= value.status_code and value.status_code < 500): return
log = self.get_log()
log.set_level(bbctrl.log.DEBUG)
log.error(str(value))
trace = ''.join(traceback.format_exception(typ, value, tb))
log.debug(trace)

450
src/py/bbctrl/State.py Normal file
View File

@@ -0,0 +1,450 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import traceback
import copy
import uuid
import os
import bbctrl
class State(object):
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('State')
self.callbacks = {}
self.changes = {}
self.listeners = []
self.timeout = None
self.machine_var_set = set()
self.message_id = 0
# Defaults
self.vars = {
'line': -1,
'messages': [],
'tool': 0,
'feed': 0,
'speed': 0,
'sid': str(uuid.uuid4()),
'demo': ctrl.args.demo,
}
# Add computed variable callbacks for each motor.
#
# NOTE, variable callbacks must return metric values only because
# the planner will scale returned values when in imperial mode.
for i in range(4):
self.set_callback(str(i) + 'home_position',
lambda name, i = i: self.motor_home_position(i))
self.set_callback(str(i) + 'home_travel',
lambda name, i = i: self.motor_home_travel(i))
self.set_callback(str(i) + 'latch_backoff',
lambda name, i = i: self.motor_latch_backoff(i))
self.set_callback(str(i) + 'zero_backoff',
lambda name, i = i: self.motor_zero_backoff(i))
self.set_callback(str(i) + 'search_velocity',
lambda name, i = i: self.motor_search_velocity(i))
self.set_callback(str(i) + 'latch_velocity',
lambda name, i = i: self.motor_latch_velocity(i))
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.reset()
self.load_files()
def is_metric(self): return self.get('units', 'METRIC') == 'METRIC'
def reset(self):
# Unhome all motors
for i in range(4): self.set('%dhomed' % i, False)
# Zero offsets and positions
for axis in 'xyzabc':
self.set(axis + 'p', 0)
self.set('offset_' + axis, 0)
def load_files(self):
self.files = []
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)
if len(self.files): self.select_file(self.files[0])
else: self.select_file('')
def clear_files(self):
self.select_file('')
self.files = []
self.changes['files'] = self.files
def add_file(self, filename):
if not filename in self.files:
self.files.append(filename)
self.files.sort()
self.changes['files'] = self.files
self.select_file(filename)
def remove_file(self, filename):
if filename in self.files:
self.files.remove(filename)
self.changes['files'] = self.files
if self.get('selected', filename) == filename:
if len(self.files): self.select_file(self.files[0])
else: self.select_file('')
def select_file(self, filename):
self.set('selected', filename)
time = os.path.getmtime(self.ctrl.get_upload(filename))
self.set('selected_time', time)
def set_bounds(self, bounds):
for axis in 'xyzabc':
for name in ('min', 'max'):
var = '%s_%s' % (axis, name)
value = bounds[name][axis] if axis in bounds[name] else 0
self.set(var, value)
def ack_message(self, id):
self.log.info('Message %d acknowledged' % id)
msgs = self.vars['messages']
msgs = list(filter(lambda m: id < m['id'], msgs))
self.set('messages', msgs)
def add_message(self, text):
msg = dict(text = text, id = self.message_id)
self.message_id += 1
msgs = self.vars['messages']
msgs = msgs + [msg] # It's important we make a new list here
self.set('messages', msgs)
def _notify(self):
if not self.changes: return
for listener in self.listeners:
try:
listener(self.changes)
except Exception as e:
self.log.warning('Updating state listener: %s',
traceback.format_exc())
self.changes = {}
self.timeout = None
def resolve(self, name):
# Resolve axis prefixes to motor numbers
if 2 < len(name) and name[1] == '_' and name[0] in 'xyzabc':
motor = self.find_motor(name[0])
if motor is not None: return str(motor) + name[2:]
return name
def has(self, name): return self.resolve(name) in self.vars
def set_callback(self, name, cb): self.callbacks[self.resolve(name)] = cb
def set(self, name, value):
name = self.resolve(name)
if not name in self.vars or self.vars[name] != value:
self.vars[name] = value
self.changes[name] = value
# Trigger listener notify
if self.timeout is None:
self.timeout = self.ctrl.ioloop.call_later(0.25, self._notify)
def update(self, update):
for name, value in update.items():
self.set(name, value)
def get(self, name, default = None):
name = self.resolve(name)
if name in self.vars: return self.vars[name]
if name in self.callbacks: return self.callbacks[name](name)
if default is None:
self.log.warning('State variable "%s" not found' % name)
return default
def snapshot(self):
vars = copy.deepcopy(self.vars)
for name in self.callbacks:
if not name in vars:
vars[name] = self.callbacks[name](name)
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
axis_vars = {}
for name, value in vars.items():
if name[0] in '0123':
motor = int(name[0])
for axis in 'xyzabc':
if motor == axis_motors[axis]:
axis_vars[axis + '_' + name[1:]] = value
vars.update(axis_vars)
return vars
def config(self, code, value):
# Set machine variables via mach, others directly
if code in self.machine_var_set: self.ctrl.mach.set(code, value)
else: self.set(code, value)
def add_listener(self, listener):
self.listeners.append(listener)
listener(self.vars)
def remove_listener(self, listener): self.listeners.remove(listener)
def set_machine_vars(self, vars):
# Record all machine vars, indexed or otherwise
self.machine_var_set = set()
for code, spec in vars.items():
if 'index' in spec:
for index in spec['index']:
self.machine_var_set.add(index + code)
else: self.machine_var_set.add(code)
def get_position(self):
position = {}
for axis in 'xyzabc':
if self.is_axis_enabled(axis) and self.has(axis + 'p'):
position[axis] = self.get(axis + 'p')
return position
def get_axis_vector(self, name, scale = 1):
v = {}
for axis in 'xyzabc':
motor = self.find_motor(axis)
if motor is not None and self.motor_enabled(motor):
value = self.get(str(motor) + name, None)
if value is not None: v[axis] = value * scale
return v
def get_soft_limit_vector(self, var, default):
limit = self.get_axis_vector(var, 1)
for axis in 'xyzabc':
if not axis in limit or not self.is_axis_homed(axis):
limit[axis] = default
return limit
def find_motor(self, axis):
for motor in range(4):
if not ('%dan' % motor) in self.vars: continue
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
return motor
def is_axis_homed(self, axis): return self.get('%s_homed' % axis, False)
def is_axis_enabled(self, axis):
motor = self.find_motor(axis)
return motor is not None and self.motor_enabled(motor)
def get_enabled_axes(self):
axes = []
for axis in 'xyzabc':
if self.is_axis_enabled(axis):
axes.append(axis)
return axes
def is_motor_faulted(self, motor):
return self.get('%ddf' % motor, 0) & 0x1f
def is_axis_faulted(self, axis):
motor = self.find_motor(axis)
return motor is not None and self.is_motor_faulted(motor)
def axis_homing_mode(self, axis):
motor = self.find_motor(axis)
if motor is None: return 'disabled'
return self.motor_homing_mode(motor)
def axis_home_fail_reason(self, axis):
motor = self.find_motor(axis)
if motor is None: return 'Not mapped to motor'
if not self.motor_enabled(motor): return 'Motor disabled'
mode = self.motor_homing_mode(motor)
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'
softMin = int(self.get(axis + '_tn', 0))
softMax = int(self.get(axis + '_tm', 0))
if softMax <= softMin + 1:
return 'max-soft-limit must be at least 1mm greater ' \
'than min-soft-limit'
def motor_enabled(self, motor):
return bool(int(self.vars.get('%dme' % motor, 0)))
def motor_homing_mode(self, motor):
mode = str(self.vars.get('%dho' % motor, 0))
if mode == '0': return 'manual'
if mode == '1': return 'switch-min'
if mode == '2': return 'switch-max'
if mode == '3': return 'stall-min'
if mode == '4': return 'stall-max'
raise Exception('Unrecognized homing mode "%s"' % mode)
def motor_home_direction(self, motor):
mode = self.motor_homing_mode(motor)
if mode.endswith('-min'): return -1
if mode.endswith('-max'): return 1
return 0 # Disabled
def motor_home_position(self, motor):
mode = self.motor_homing_mode(motor)
# Return soft limit positions
if mode.endswith('-min'): return self.vars['%dtn' % motor]
if mode.endswith('-max'): return self.vars['%dtm' % motor]
return 0 # Disabled
def motor_home_travel(self, motor):
tmin = self.get(str(motor) + 'tm', 0)
tmax = self.get(str(motor) + 'tn', 0)
hdir = self.motor_home_direction(motor)
# (travel_max - travel_min) * 1.5 * home_dir
return (tmin - tmax) * 1.5 * hdir
def motor_latch_backoff(self, motor):
lb = self.get(str(motor) + 'lb', 0)
hdir = self.motor_home_direction(motor)
return -(lb * hdir) # -latch_backoff * home_dir
def motor_zero_backoff(self, motor):
zb = self.get(str(motor) + 'zb', 0)
hdir = self.motor_home_direction(motor)
return -(zb * hdir) # -zero_backoff * home_dir
def motor_search_velocity(self, motor):
return 1000 * self.get(str(motor) + 'sv', 0)
def motor_latch_velocity(self, motor):
return 1000 * self.get(str(motor) + 'lv', 0)
def get_axis_switch(self, axis, side):
axis = axis.lower()
if not axis in 'xyzabc':
raise Exception('Unsupported switch "%s-%s"' % (axis, side))
if not self.is_axis_enabled(axis):
raise Exception('Switch "%s-%s" axis not enabled' % (axis, side))
motor = self.find_motor(axis)
# This must match the switch ID enum in avr/src/switch.h
hmode = self.motor_homing_mode(motor)
if hmode.startswith('stall-'): return motor + 10
return 2 * motor + 2 + (0 if side.lower() == 'min' else 1)
def get_switch_id(self, switch):
# TODO Support other input switches in CAMotics gcode/machine/PortType.h
switch = switch.lower()
if switch == 'probe': return 1
if switch[1:] == '-min': return self.get_axis_switch(switch[0], 'min')
if switch[1:] == '-max': return self.get_axis_switch(switch[0], 'max')
raise Exception('Unsupported switch "%s"' % switch)

595
src/py/bbctrl/Web.py Normal file
View File

@@ -0,0 +1,595 @@
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import sys
import json
import tornado
import sockjs.tornado
import datetime
import shutil
import tarfile
import subprocess
import socket
import time
from tornado.web import HTTPError
from tornado import web, gen
import bbctrl
def call_get_output(cmd):
p = subprocess.Popen(cmd, stdout = subprocess.PIPE)
s = p.communicate()[0].decode('utf-8')
if p.returncode: raise HTTPError(400, 'Command failed')
return s
def get_username():
return call_get_output(['getent', 'passwd', '1001']).split(':')[0]
def set_username(username):
if subprocess.call(['usermod', '-l', username, get_username()]):
raise HTTPError(400, 'Failed to set username to "%s"' % username)
def check_password(password):
# Get current password
s = call_get_output(['getent', 'shadow', get_username()])
current = s.split(':')[1].split('$')
# Check password type
if len(current) < 2 or current[1] != '1':
raise HTTPError(401, "Password invalid")
# Check current password
cmd = ['openssl', 'passwd', '-salt', current[2], '-1', password]
s = call_get_output(cmd).strip()
if s.split('$') != current: raise HTTPError(401, 'Wrong password')
class RebootHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().lcd.goodbye('Rebooting...')
subprocess.Popen('reboot')
class LogHandler(bbctrl.RequestHandler):
def get(self):
with open(self.get_ctrl().log.get_path(), 'r') as f:
self.write(f.read())
def set_default_headers(self):
fmt = socket.gethostname() + '-%Y%m%d.log'
filename = datetime.date.today().strftime(fmt)
self.set_header('Content-Disposition', 'filename="%s"' % filename)
self.set_header('Content-Type', 'text/plain')
class MessageAckHandler(bbctrl.APIHandler):
def put_ok(self, id):
self.get_ctrl().state.ack_message(int(id))
class BugReportHandler(bbctrl.RequestHandler):
def get(self):
import tarfile, io
buf = io.BytesIO()
tar = tarfile.open(mode = 'w:bz2', fileobj = buf)
def check_add(path, arcname = None):
if os.path.isfile(path):
if arcname is None: arcname = path
tar.add(path, self.basename + '/' + arcname)
def check_add_basename(path):
check_add(path, os.path.basename(path))
ctrl = self.get_ctrl()
path = ctrl.log.get_path()
check_add_basename(path)
for i in range(1, 8):
check_add_basename('%s.%d' % (path, i))
check_add_basename('/var/log/syslog')
check_add('config.json')
check_add(ctrl.get_upload(ctrl.state.get('selected', '')))
tar.close()
self.write(buf.getvalue())
def set_default_headers(self):
fmt = socket.gethostname() + '-%Y%m%d-%H%M%S'
self.basename = datetime.datetime.now().strftime(fmt)
filename = self.basename + '.tar.bz2'
self.set_header('Content-Disposition', 'filename="%s"' % filename)
self.set_header('Content-Type', 'application/x-bzip2')
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:
self.write_json('ok')
return
raise HTTPError(400, 'Failed to set hostname')
class WifiHandler(bbctrl.APIHandler):
def get(self):
data = {'ssid': '', 'channel': 0}
try:
data = json.loads(call_get_output(['config-wifi', '-j']))
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']
if mode == 'disabled': cmd += ['-d']
elif 'ssid' in self.json:
cmd += ['-s', self.json['ssid']]
if mode == 'ap':
cmd += ['-a']
if 'channel' in self.json:
cmd += ['-c', self.json['channel']]
if 'pass' in self.json:
cmd += ['-p', self.json['pass']]
if subprocess.call(cmd) == 0:
self.write_json('ok')
return
raise HTTPError(400, 'Failed to configure wifi')
class UsernameHandler(bbctrl.APIHandler):
def get(self): self.write_json(get_username())
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'])
# Set password
s = '%s:%s' % (get_username(), self.json['password'])
s = s.encode('utf-8')
p = subprocess.Popen(['chpasswd', '-c', 'MD5'],
stdin = subprocess.PIPE)
p.communicate(input = s)
if p.returncode == 0:
self.write_json('ok')
return
raise HTTPError(401, 'Failed to set password')
class ConfigLoadHandler(bbctrl.APIHandler):
def get(self): self.write_json(self.get_ctrl().config.load())
class ConfigDownloadHandler(bbctrl.APIHandler):
def set_default_headers(self):
fmt = socket.gethostname() + '-%Y%m%d.json'
filename = datetime.date.today().strftime(fmt)
self.set_header('Content-Type', 'application/octet-stream')
self.set_header('Content-Disposition',
'attachment; filename="%s"' % filename)
def get(self):
self.write_json(self.get_ctrl().config.load(), pretty = True)
class ConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().config.save(self.json)
class ConfigResetHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().config.reset()
class FirmwareUpdateHandler(bbctrl.APIHandler):
def prepare(self): pass
def put_ok(self):
if not 'password' in self.request.arguments:
raise HTTPError(401, 'Missing "password"')
if not 'firmware' in self.request.files:
raise HTTPError(401, 'Missing "firmware"')
check_password(self.request.arguments['password'][0])
firmware = self.request.files['firmware'][0]
if not os.path.exists('firmware'): os.mkdir('firmware')
with open('firmware/update.tar.bz2', 'wb') as f:
f.write(firmware['body'])
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.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(self.get_upload(filename)):
raise HTTPError(404, 'File not found')
preplanner = self.get_ctrl().preplanner
future = preplanner.get_plan(filename)
try:
delta = datetime.timedelta(seconds = 1)
data = yield gen.with_timeout(delta, future)
except gen.TimeoutError:
progress = preplanner.get_plan_progress(filename)
self.write_json(dict(progress = progress))
return
try:
if data is None: return
meta, positions, speeds = data
if dataType == '/positions': data = positions
elif dataType == '/speeds': data = speeds
else:
self.get_ctrl().state.set_bounds(meta['bounds'])
self.write_json(meta)
return
filename = filename + '-' + dataType[1:] + '.gz'
self.set_header('Content-Disposition', 'filename="%s"' % filename)
self.set_header('Content-Type', 'application/octet-stream')
self.set_header('Content-Encoding', 'gzip')
self.set_header('Content-Length', str(len(data)))
# Respond with chunks to avoid long delays
SIZE = 102400
chunks = [data[i:i + SIZE] for i in range(0, len(data), SIZE)]
for chunk in chunks:
self.write(chunk)
yield self.flush()
except tornado.iostream.StreamClosedError as e: pass
class HomeHandler(bbctrl.APIHandler):
def put_ok(self, axis, action, *args):
if axis is not None: axis = ord(axis[1:2].lower())
if action == '/set':
if not 'position' in self.json:
raise HTTPError(400, 'Missing "position"')
self.get_ctrl().mach.home(axis, self.json['position'])
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.get_ctrl().mach.start()
class EStopHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.estop()
class ClearHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.clear()
class StopHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.stop()
class PauseHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.pause()
class UnpauseHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.unpause()
class OptionalPauseHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.optional_pause()
class StepHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.step()
class PositionHandler(bbctrl.APIHandler):
def put_ok(self, axis):
self.get_ctrl().mach.set_position(axis, float(self.json['position']))
class OverrideFeedHandler(bbctrl.APIHandler):
def put_ok(self, value): self.get_ctrl().mach.override_feed(float(value))
class OverrideSpeedHandler(bbctrl.APIHandler):
def put_ok(self, value): self.get_ctrl().mach.override_speed(float(value))
class ModbusReadHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().mach.modbus_read(int(self.json['address']))
class ModbusWriteHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().mach.modbus_write(int(self.json['address']),
int(self.json['value']))
class JogHandler(bbctrl.APIHandler):
def put_ok(self):
# Handle possible out of order jog command processing
if 'ts' in self.json:
ts = self.json['ts']
id = self.get_cookie('client-id')
if not hasattr(self.app, 'last_jog'):
self.app.last_jog = {}
last = self.app.last_jog.get(id, 0)
self.app.last_jog[id] = ts
if ts < last: return # Out of order
self.get_ctrl().mach.jog(self.json)
# Base class for Web Socket connections
class ClientConnection(object):
def __init__(self, app):
self.app = app
self.count = 0
def heartbeat(self):
self.timer = self.app.ioloop.call_later(3, self.heartbeat)
self.send({'heartbeat': self.count})
self.count += 1
def send(self, msg): raise HTTPError(400, 'Not implemented')
def on_open(self, id = None):
self.ctrl = self.app.get_ctrl(id)
self.ctrl.state.add_listener(self.send)
self.ctrl.log.add_listener(self.send)
self.is_open = True
self.heartbeat()
self.app.opened(self.ctrl)
def on_close(self):
self.app.ioloop.remove_timeout(self.timer)
self.ctrl.state.remove_listener(self.send)
self.ctrl.log.remove_listener(self.send)
self.is_open = False
self.app.closed(self.ctrl)
def on_message(self, data): self.ctrl.mach.mdi(data)
# Used by CAMotics
class WSConnection(ClientConnection, tornado.websocket.WebSocketHandler):
def __init__(self, app, request, **kwargs):
ClientConnection.__init__(self, app)
tornado.websocket.WebSocketHandler.__init__(
self, app, request, **kwargs)
def send(self, msg): self.write_message(msg)
def open(self): self.on_open()
# Used by Web frontend
class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
def __init__(self, session):
ClientConnection.__init__(self, session.server.app)
sockjs.tornado.SockJSConnection.__init__(self, session)
def send(self, msg):
try:
sockjs.tornado.SockJSConnection.send(self, msg)
except:
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',
'no-store, no-cache, must-revalidate, max-age=0')
class Web(tornado.web.Application):
def __init__(self, args, ioloop):
self.args = args
self.ioloop = ioloop
self.ctrls = {}
# Init camera
if not args.disable_camera:
if self.args.demo: log = bbctrl.log.Log(args, ioloop, 'camera.log')
else: log = self.get_ctrl().log
self.camera = bbctrl.Camera(ioloop, args, log)
else: self.camera = None
# Init controller
if not self.args.demo:
self.get_ctrl()
self.monitor = bbctrl.MonitorTemp(self)
handlers = [
(r'/websocket', WSConnection),
(r'/api/log', LogHandler),
(r'/api/message/(\d+)/ack', MessageAckHandler),
(r'/api/bugreport', BugReportHandler),
(r'/api/reboot', RebootHandler),
(r'/api/hostname', HostnameHandler),
(r'/api/wifi', WifiHandler),
(r'/api/remote/username', UsernameHandler),
(r'/api/remote/password', PasswordHandler),
(r'/api/config/load', ConfigLoadHandler),
(r'/api/config/download', ConfigDownloadHandler),
(r'/api/config/save', ConfigSaveHandler),
(r'/api/config/reset', ConfigResetHandler),
(r'/api/firmware/update', FirmwareUpdateHandler),
(r'/api/upgrade', UpgradeHandler),
(r'/api/file(/[^/]+)?', bbctrl.FileHandler),
(r'/api/path/([^/]+)((/positions)|(/speeds))?', PathHandler),
(r'/api/home(/[xyzabcXYZABC]((/set)|(/clear))?)?', HomeHandler),
(r'/api/start', StartHandler),
(r'/api/estop', EStopHandler),
(r'/api/clear', ClearHandler),
(r'/api/stop', StopHandler),
(r'/api/pause', PauseHandler),
(r'/api/unpause', UnpauseHandler),
(r'/api/pause/optional', OptionalPauseHandler),
(r'/api/step', StepHandler),
(r'/api/position/([xyzabcXYZABC])', PositionHandler),
(r'/api/override/feed/([\d.]+)', OverrideFeedHandler),
(r'/api/override/speed/([\d.]+)', OverrideSpeedHandler),
(r'/api/modbus/read', ModbusReadHandler),
(r'/api/modbus/write', ModbusWriteHandler),
(r'/api/jog', JogHandler),
(r'/api/video', bbctrl.VideoHandler),
(r'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}),
]
router = sockjs.tornado.SockJSRouter(SockJSConnection, '/sockjs')
router.app = self
tornado.web.Application.__init__(self, router.urls + handlers)
try:
self.listen(args.port, address = args.addr)
except Exception as e:
raise Exception('Failed to bind %s:%d: %s' % (
args.addr, args.port, e))
print('Listening on http://%s:%d/' % (args.addr, args.port))
def opened(self, ctrl): ctrl.clear_timeout()
def closed(self, ctrl):
# Time out clients in demo mode
if self.args.demo: ctrl.set_timeout(self._reap_ctrl, ctrl)
def _reap_ctrl(self, ctrl):
ctrl.close()
del self.ctrls[ctrl.id]
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]
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())

190
src/py/bbctrl/__init__.py Normal file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import os
import sys
import signal
import tornado
import argparse
import datetime
from pkg_resources import Requirement, resource_filename
from bbctrl.RequestHandler import RequestHandler
from bbctrl.APIHandler import APIHandler
from bbctrl.FileHandler import FileHandler
from bbctrl.Config import Config
from bbctrl.LCD import LCD, LCDPage
from bbctrl.Mach import Mach
from bbctrl.Web import Web
from bbctrl.Jog import Jog
from bbctrl.Ctrl import Ctrl
from bbctrl.Pwr import Pwr
from bbctrl.I2C import I2C
from bbctrl.Planner import Planner
from bbctrl.Preplanner import Preplanner
from bbctrl.State import State
from bbctrl.Comm import Comm
from bbctrl.CommandQueue import CommandQueue
from bbctrl.MainLCDPage import MainLCDPage
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
from bbctrl.MonitorTemp import MonitorTemp
import bbctrl.Cmd as Cmd
import bbctrl.v4l2 as v4l2
import bbctrl.Log as log
import bbctrl.ObjGraph as ObjGraph
ctrl = None
def get_resource(path):
return resource_filename(Requirement.parse('bbctrl'), 'bbctrl/' + path)
def on_exit(sig = 0, func = None):
global ctrl
print('Exit handler triggered: signal = %d', sig)
if ctrl is not None:
ctrl.close()
ctrl = None
sys.exit(0)
def time_str():
return datetime.datetime.now().strftime('%Y%m%d-%H:%M:%S')
class Debugger:
def __init__(self, ioloop, freq = 60 * 15, depth = 100):
self.ioloop = ioloop
self.freq = freq
self.depth = depth
self._callback()
def _callback(self):
with open('bbctrl-debug-%s.log' % time_str(), 'w') as log:
def line(name):
log.write('==== ' + name + ' ' + '=' * (74 - len(name)) + '\n')
line('Common')
ObjGraph.show_most_common_types(limit = self.depth, file = log)
log.write('\n')
line('Growth')
ObjGraph.show_growth(limit = self.depth, file = log)
log.write('\n')
line('New IDs')
ObjGraph.get_new_ids(limit = self.depth, file = log)
log.flush()
self.ioloop.call_later(self.freq, self._callback)
def parse_args():
parser = argparse.ArgumentParser(
description = 'Buildbotics Machine Controller')
parser.add_argument('-p', '--port', default = 80,
type = int, help = 'HTTP port')
parser.add_argument('-a', '--addr', metavar = 'IP', default = '0.0.0.0',
help = 'HTTP address to bind')
parser.add_argument('-s', '--serial', default = '/dev/ttyAMA0',
help = 'Serial device')
parser.add_argument('-b', '--baud', default = 230400, type = int,
help = 'Serial baud rate')
parser.add_argument('--i2c-port', default = 1, type = int,
help = 'I2C port')
parser.add_argument('--lcd-addr', default = [0x27, 0x3f], type = int,
help = 'LCD I2C address')
parser.add_argument('--avr-addr', default = 0x2b, type = int,
help = 'AVR I2C address')
parser.add_argument('--pwr-addr', default = 0x60, type = int,
help = 'Power AVR I2C address')
parser.add_argument('-v', '--verbose', action = 'store_true',
help = 'Verbose output')
parser.add_argument('-l', '--log', metavar = "FILE",
help = 'Set a log file')
parser.add_argument('--disable-camera', action = 'store_true',
help = 'Disable the camera')
parser.add_argument('--width', default = 640, type = int,
help = 'Camera width')
parser.add_argument('--height', default = 480, type = int,
help = 'Camera height')
parser.add_argument('--fps', default = 15, type = int,
help = 'Camera frames per second')
parser.add_argument('--camera-clients', default = 4,
help = 'Maximum simultaneous camera clients')
parser.add_argument('--demo', action = 'store_true',
help = 'Enter demo mode')
parser.add_argument('--debug', default = 0, type = int,
help = 'Enable debug mode and set frequency in seconds')
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()
def run():
global ctrl
args = parse_args()
# Set signal handler
signal.signal(signal.SIGTERM, on_exit)
# Create ioloop
ioloop = tornado.ioloop.IOLoop.current()
# Set ObjGraph signal handler
if args.debug: Debugger(ioloop, args.debug)
# Start server
web = Web(args, ioloop)
try:
ioloop.start()
except KeyboardInterrupt: on_exit()
if __name__ == '__main__': run()

1
src/py/bbctrl/http Normal file
View File

@@ -0,0 +1 @@
../../../build/http/

324
src/py/bbctrl/plan.py Normal file
View File

@@ -0,0 +1,324 @@
#!/usr/bin/env python3
################################################################################
# #
# 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 <http://www.gnu.org/licenses/>. #
# #
# 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 #
# <http://www.gnu.org/licenses/>. #
# #
# For information regarding this software email: #
# "Joseph Coffland" <joseph@buildbotics.com> #
# #
################################################################################
import sys
import argparse
import json
import time
import math
import os
import re
import gzip
import struct
import math
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
reLogLine = re.compile(
r'^(?P<level>[A-Z])[0-9 ]:'
r'((?P<file>[^:]+):)?'
r'((?P<line>\d+):)?'
r'((?P<column>\d+):)?'
r'(?P<msg>.*)$')
def compute_unit(a, b):
unit = dict()
length = 0
for axis in 'xyz':
if axis in a and axis in b:
unit[axis] = b[axis] - a[axis]
length += unit[axis] * unit[axis]
length = math.sqrt(length)
if length:
for axis in 'xyz':
if axis in unit: unit[axis] /= length
return unit
def compute_move(start, unit, dist):
move = dict()
for axis in 'xyz':
if axis in unit and axis in start:
move[axis] = start[axis] + unit[axis] * dist
return move
class Plan(object):
def __init__(self, path, state, config):
self.path = path
self.state = state
self.config = config
self.lines = sum(1 for line in open(path, 'rb'))
self.planner = gplan.Planner()
self.planner.set_resolver(self.get_var_cb)
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
self.planner.load(self.path, config)
self.messages = []
self.levels = dict(I = 'info', D = 'debug', W = 'warning', E = 'error',
C = 'critical')
# Initialized axis states and bounds
self.bounds = dict(min = {}, max = {})
for axis in 'xyz':
self.bounds['min'][axis] = math.inf
self.bounds['max'][axis] = -math.inf
self.maxSpeed = 0
self.currentSpeed = None
self.lastProgress = None
self.lastProgressTime = 0
self.time = 0
def add_to_bounds(self, axis, value):
if value < self.bounds['min'][axis]: self.bounds['min'][axis] = value
if self.bounds['max'][axis] < value: self.bounds['max'][axis] = value
def get_bounds(self):
# Remove infinity from bounds
for axis in 'xyz':
if self.bounds['min'][axis] == math.inf:
del self.bounds['min'][axis]
if self.bounds['max'][axis] == -math.inf:
del self.bounds['max'][axis]
return self.bounds
def update_speed(self, s):
if self.currentSpeed == s: return False
self.currentSpeed = s
if self.maxSpeed < s: self.maxSpeed = s
return True
def get_var_cb(self, name, units):
value = 0
if len(name) and name[0] == '_':
value = self.state.get(name[1:], 0)
if units == 'IMPERIAL': value /= 25.4
return value
def log_cb(self, level, msg, filename, line, column):
if level in self.levels: level = self.levels[level]
# Ignore missing tool warning
if (level == 'warning' and
msg.startswith('Auto-creating missing tool')):
return
self.messages.append(
dict(level = level, msg = msg, filename = filename, line = line,
column = column))
def _log_cb(self, line):
line = line.strip()
m = reLogLine.match(line)
if not m: return
level = m.group('level')
msg = m.group('msg')
filename = m.group('file')
line = m.group('line')
column = m.group('column')
where = ':'.join(filter(None.__ne__, [filename, line, column]))
if line is not None: line = int(line)
if column is not None: column = int(column)
self.log_cb(level, msg, filename, line, column)
def progress(self, x):
if time.time() - self.lastProgressTime < 1 and x != 1: return
self.lastProgressTime = time.time()
p = '%.4f\n' % x
if self.lastProgress == p: return
self.lastProgress = p
sys.stdout.write(p)
sys.stdout.flush()
def _run(self):
start = time.clock()
line = 0
maxLine = 0
maxLineTime = time.clock()
position = {axis: 0 for axis in 'xyz'}
rapid = False
# Execute plan
try:
while self.planner.has_more():
cmd = self.planner.next()
self.planner.set_active(cmd['id']) # Release plan
# Cannot synchronize with actual machine so fake it
if self.planner.is_synchronizing(): self.planner.synchronize(0)
if cmd['type'] == 'line':
if not (cmd.get('first', False) or
cmd.get('seeking', False)):
self.time += sum(cmd['times']) / 1000
target = cmd['target']
move = {}
startPos = dict()
for axis in 'xyz':
if axis in target:
startPos[axis] = position[axis]
position[axis] = target[axis]
move[axis] = target[axis]
self.add_to_bounds(axis, move[axis])
if 'rapid' in cmd: move['rapid'] = cmd['rapid']
if 'speeds' in cmd:
unit = compute_unit(startPos, target)
for d, s in cmd['speeds']:
cur = self.currentSpeed
if self.update_speed(s):
m = compute_move(startPos, unit, d)
if cur is not None:
m['s'] = cur
yield m
move['s'] = s
yield move
elif cmd['type'] == 'set':
if cmd['name'] == 'line':
line = cmd['value']
if maxLine < line:
maxLine = line
maxLineTime = time.clock()
elif cmd['name'] == 'speed':
s = cmd['value']
if self.update_speed(s): yield {'s': s}
elif cmd['type'] == 'dwell': self.time += cmd['seconds']
if args.max_time < time.clock() - start:
raise Exception('Max planning time (%d sec) exceeded.' %
args.max_time)
if args.max_loop < time.clock() - maxLineTime:
raise Exception('Max loop time (%d sec) exceeded.' %
args.max_loop)
if self.lines: self.progress(maxLine / self.lines)
except Exception as e:
self.log_cb('error', str(e), os.path.basename(self.path), line, 0)
def run(self):
lastS = 0
speed = 0
first = True
x, y, z = 0, 0, 0
with gzip.open('positions.gz', 'wb') as f1:
with gzip.open('speeds.gz', 'wb') as f2:
for move in self._run():
x = move.get('x', x)
y = move.get('y', y)
z = move.get('z', z)
rapid = move.get('rapid', False)
speed = move.get('s', speed)
s = struct.pack('<f', math.nan if rapid else speed)
if not first and s != lastS:
f1.write(p)
f2.write(s)
lastS = s
first = False
p = struct.pack('<fff', x, y, z)
f1.write(p)
f2.write(s)
with open('meta.json', 'w') as f:
meta = dict(
time = self.time,
lines = self.lines,
maxSpeed = self.maxSpeed,
bounds = self.get_bounds(),
messages = self.messages)
json.dump(meta, f)
parser = argparse.ArgumentParser(description = 'Buildbotics GCode Planner')
parser.add_argument('gcode', help = 'The GCode file to plan')
parser.add_argument('state', help = 'GCode state variables')
parser.add_argument('config', help = 'Planner config')
parser.add_argument('--max-time', default = 600,
type = int, help = 'Maximum planning time in seconds')
parser.add_argument('--max-loop', default = 30,
type = int, help = 'Maximum time in loop in seconds')
parser.add_argument('--nice', default = 10,
type = int, help = 'Set "nice" process priority')
args = parser.parse_args()
state = json.loads(args.state)
config = json.loads(args.config)
os.nice(args.nice)
plan = Plan(args.gcode, state, config)
plan.run()

1976
src/py/bbctrl/v4l2.py Normal file

File diff suppressed because it is too large Load Diff