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

0
src/py/camotics/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,106 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import array
import fcntl
import struct
from inevent import ioctl
def EVIOCGABS(axis):
return ioctl._IOR(ord('E'), 0x40 + axis, "ffffff") # get abs value/limits
class AbsAxisScaling(object):
"""
Fetches and implements the EV_ABS axis scaling.
The constructor fetches the scaling values from the given stream for the
given axis using an ioctl.
There is a scale method, which scales a given value to the range -1..+1.
"""
def __init__(self, stream, axis):
"""
Fetch the scale values for this stream and fill in the instance
variables accordingly.
"""
s = array.array("i", [1, 2, 3, 4, 5, 6])
try:
fcntl.ioctl(stream.filehandle, EVIOCGABS(axis), s)
except IOError:
self.value = self.minimum = self.maximum = self.fuzz = self.flat = \
self.resolution = 1
else:
self.value, self.minimum, self.maximum, self.fuzz, self.flat, \
self.resolution = struct.unpack("iiiiii", s)
def __str__(self):
return "Value {0} Min {1}, Max {2}, Fuzz {3}, Flat {4}, Res {5}".format(
self.value, self.minimum, self.maximum, self.fuzz, self.flat,
self.resolution)
def scale(self, value):
"""
scales the given value into the range -1..+1
"""
return (float(value) - float(self.minimum)) / \
float(self.maximum - self.minimum) * 2.0 - 1.0

124
src/py/inevent/Constants.py Normal file
View File

@@ -0,0 +1,124 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
EV_SYN = 0x00
EV_KEY = 0x01
EV_REL = 0x02
EV_ABS = 0x03
EV_MSC = 0x04
EV_SW = 0x05
EV_LED = 0x11
EV_SND = 0x12
EV_REP = 0x14
EV_FF = 0x15
EV_PWR = 0x16
EV_FF_STATUS = 0x17
ev_type_name = {}
ev_type_name[EV_SYN] = "SYN"
ev_type_name[EV_KEY] = "KEY"
ev_type_name[EV_REL] = "REL"
ev_type_name[EV_ABS] = "ABS"
ev_type_name[EV_MSC] = "MSC"
ev_type_name[EV_SW] = "SW"
ev_type_name[EV_LED] = "LED"
ev_type_name[EV_SND] = "SND"
ev_type_name[EV_REP] = "REP"
ev_type_name[EV_FF] = "FF"
ev_type_name[EV_PWR] = "PWR"
ev_type_name[EV_FF_STATUS] = "FF_STATUS"
SYN_REPORT = 0
SYN_CONFIG = 1
REL_X = 0x00
REL_Y = 0x01
REL_Z = 0x02
REL_RX = 0x03
REL_RY = 0x04
REL_RZ = 0x05
REL_HWHEEL = 0x06
REL_DIAL = 0x07
REL_WHEEL = 0x08
REL_MISC = 0x09
REL_MAX = 0x0f
ABS_X = 0x00
ABS_Y = 0x01
ABS_Z = 0x02
ABS_RX = 0x03
ABS_RY = 0x04
ABS_RZ = 0x05
ABS_THROTTLE = 0x06
ABS_RUDDER = 0x07
ABS_WHEEL = 0x08
ABS_GAS = 0x09
ABS_BRAKE = 0x0a
ABS_HAT0X = 0x10
ABS_HAT0Y = 0x11
ABS_HAT1X = 0x12
ABS_HAT1Y = 0x13
ABS_HAT2X = 0x14
ABS_HAT2Y = 0x15
ABS_HAT3X = 0x16
ABS_HAT3Y = 0x17
ABS_PRESSURE = 0x18
ABS_DISTANCE = 0x19
ABS_TILT_X = 0x1a
ABS_TILT_Y = 0x1b
ABS_TOOL_WIDTH = 0x1c
ABS_VOLUME = 0x20
ABS_MISC = 0x28
ABS_MAX = 0x3f

142
src/py/inevent/Event.py Normal file
View File

@@ -0,0 +1,142 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import struct
from inevent.Constants import *
_format = 'llHHi'
size = struct.calcsize(_format)
class Event(object):
"""
A single event from the linux input event system.
Events are tuples: (Time, Type, Code, Value)
In addition we remember the stream it came from.
Externally, only the unhandled event handler gets passed the whole event,
but the SYN handler gets the code and value. (Also the keyboard handler, but
those are renamed to key and value.)
This class is responsible for converting the Linux input event structure into
one of these objects and back again.
"""
def __init__(self, stream, time = None, type = None, code = None,
value = None):
"""
Create a new event.
Generally all but the stream parameter are left out; we will want to
populate the object from a Linux input event using decode.
"""
self.stream = stream
self.time = time
self.type = type
self.code = code
self.value = value
def get_type_name(self):
if self.type not in ev_type_name: return '0x%x' % self.type
return ev_type_name[self.type]
def get_source(self):
return "%s[%d]" % (self.stream.devType, self.stream.devIndex)
def __str__(self):
"""
Uses the stream to give the device type and whether it is currently grabbed.
"""
grabbed = "grabbed" if self.stream.grabbed else "ungrabbed"
return "Event %s %s @%f: %s 0x%x=0x%x" % (
self.get_source(), grabbed, self.time, self.get_type_name(), self.code,
self.value)
def __repr__(self):
return "Event(%s, %f, 0x%x, 0x%x, 0x%x)" % (
repr(self.stream), self.time, self.type, self.code, self.value)
def encode(self):
"""
Encode this event into a Linux input event structure.
The output is packed into a string. It is unlikely that this function
will be required, but it might as well be here.
"""
tsec = int(self.time)
tfrac = int((self.time - tsec) * 1000000)
return struct.pack(_format, tsec, tfrac, self.type, self.code, self.value)
def decode(self, s):
"""
Decode a Linux input event into the fields of this object.
Arguments:
*s*
A binary structure packed into a string.
"""
tsec, tfrac, self.type, self.code, self.value = struct.unpack(_format, s)
self.time = tsec + tfrac / 1000000.0

View File

@@ -0,0 +1,146 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from inevent.Constants import *
from inevent.EventStream import EventStream
class EventHandler(object):
"""
A class to handle events.
Four types of events are handled: REL (mouse movement), KEY (keybaord keys and
other device buttons), ABS (joysticks and gamepad analogue sticks) and SYN
(delimits simultaneous events such as mouse movements)
"""
def __init__(self):
self.buttons = dict()
def event(self, event, handler, name):
"""
Handles the given event.
If the event is passed to a handler or otherwise handled then returns None,
else returns the event. All handlers are optional.
All key events are handled by putting them in the self.buttons dict, and
optionally by calling the supplied handler.
REL X, Y and wheel V and H events are all accumulated internally and
also optionally passed to the supplied handler. All these events are
handled.
ABS X, Y, Z, RX, RY, RZ, Hat0X, Hat0Y are all accumulated internally and
also optionally passed to the supplied handler. Other ABS events are not
handled.
All SYN events are passed to the supplied handler.
There are several ABS events that we do not handle. In particular:
THROTTLE, RUDDER, WHEEL, GAS, BRAKE, HAT1, HAT2, HAT3, PRESSURE,
DISTANCE, TILT, TOOL_WIDTH. Implementing these is left as an exercise
for the interested reader.
Likewise, since one handler is handling all events for all devices, we
may get the situation where two devices return the same button. The only
way to handle that would seem to be to have a key dict for every device,
which seems needlessly profligate for a situation that may never arise.
"""
state = event.stream.state
if event.type == EV_KEY: self.buttons[event.code] = event.value
elif event.type == EV_REL: state.rel[event.code] += event.value
elif event.type == EV_ABS:
state.abs[event.code] = event.stream.scale(event.code, event.value)
if handler: handler.event(event, state, name)
def key_state(self, code):
"""
Returns the last event value for the given key code.
Key names can be converted to key codes using codeOf[str].
If the key is pressed the returned value will be 1 (pressed) or 2 (held).
If the key is not pressed, the returned value will be 0.
"""
return self.buttons.get(code, 0)
def clear_key(self, code):
"""
Clears the event value for the given key code.
Key names can be converted to key codes using codeOf[str].
This emulates a key-up but does not generate any events.
"""
self.buttons[code] = 0
def get_keys(self):
"""
Returns the first of whichever keys have been pressed.
Key names can be converted to key codes using codeOf[str].
This emulates a key-up but does not generate any events.
"""
k_list = []
for k in self.buttons:
if self.buttons[k] != 0: k_list.append(k)
return k_list

View File

@@ -0,0 +1,145 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from inevent.Constants import *
class EventState:
def __init__(self):
self.abs = [0.0] * ABS_MAX
self.rel = [0.0] * REL_MAX
def __str__(self):
return ("({:6.3f}, {:6.3f}, {:6.3f}) ".format(*self.get_joystick3d()) +
"({:6.3f}, {:6.3f}, {:6.3f}) ".format(*self.get_joystickR3d()) +
"({:2.0f}, {:2.0f}) ".format(*self.get_hat()) +
"({:0.2f}, {:0.2f}) ".format(*self.get_mouse()) +
"({:0.2f}, {:0.2f})".format(*self.get_wheel()))
def get_joystick(self):
"""
Returns the x,y coordinates for a joystick or left gamepad analogue stick.
The values are returned as a tuple. All values are -1.0 to +1.0 with
0.0 being centred.
"""
return self.abs[ABS_X], self.abs[ABS_Y]
def get_joystick3d(self):
"""
Returns the x,y,z coordinates for a joystick or left gamepad analogue stick
The values are returned as a tuple. All values are -1.0 to +1.0 with
0.0 being centred.
"""
return self.abs[ABS_X], self.abs[ABS_Y], self.abs[ABS_Z]
def get_joystickR(self):
"""
Returns the x,y coordinates for a right gamepad analogue stick.
The values are returned as a tuple. For some odd reason, the gamepad
returns values in the Z axes of both joysticks, with y being the first.
All values are -1.0 to +1.0 with 0.0 being centred.
"""
return self.abs[ABS_RZ], self.abs[ABS_Z]
def get_joystickR3d(self):
"""
Returns the x,y,z coordinates for a 2nd joystick control
The values are returned as a tuple. All values are -1.0 to +1.0 with
0.0 being centred.
"""
return self.abs[ABS_RX], self.abs[ABS_RY], self.abs[ABS_RZ]
def get_hat(self):
"""
Returns the x,y coordinates for a joystick hat or gamepad direction pad
The values are returned as a tuple. All values are -1.0 to +1.0 with
0.0 being centred.
"""
return self.abs[ABS_HAT0X], self.abs[ABS_HAT0Y]
def get_mouse(self):
return self.rel[REL_X], self.rel[REL_Y]
def get_wheel(self):
return self.rel[REL_WHEEL], self.rel[REL_HWHEEL]
def get_mouse_movement(self):
"""
Returns the accumulated REL (mouse or other relative device) movements
since the last call.
The returned value is a tuple: (X, Y, WHEEL, H-WHEEL)
"""
ret = self.get_mouse() + self.get_wheel()
self.rel[REL_X] = self.rel[REL_Y] = 0
self.rel[REL_WHEEL] = self.rel[REL_HWHEEL] = 0
return ret

View File

@@ -0,0 +1,218 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import fcntl
import os
import select
import logging
from inevent.Constants import *
from inevent import ioctl
from inevent.AbsAxisScaling import AbsAxisScaling
from inevent import Event
from inevent.EventState import EventState
log = logging.getLogger('inevent')
EVIOCGRAB = ioctl._IOW(ord('E'), 0x90, "i") # Grab/Release device
class EventStream(object):
"""
encapsulates the event* file handling
Each device is represented by a file in /dev/input called eventN, where N is
a small number. (Actually, a keybaord can be represented by two such files.)
Instances of this class open one of these files and provide means to read
events from them.
Class methods also exist to read from multiple files simultaneously, and
also to grab and ungrab all instances of a given type.
"""
axisX = 0
axisY = 1
axisZ = 2
axisRX = 3
axisRY = 4
axisRZ = 5
axisHat0X = 6
axisHat0Y = 7
axisHat1X = 8
axisHat1Y = 9
axisHat2X = 10
axisHat2Y = 11
axisHat3X = 12
axisHat3Y = 13
axisThrottle = 14
axisRudder = 15
axisWheel = 16
axisGas = 17
axisBrake = 18
axisPressure = 19
axisDistance = 20
axisTiltX = 21
axisTiltY = 22
axisToolWidth = 23
numAxes = 24
axisToEvent = [
ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ, ABS_HAT0X, ABS_HAT0Y,
ABS_HAT1X, ABS_HAT1Y, ABS_HAT2X, ABS_HAT2Y, ABS_HAT3X, ABS_HAT3Y,
ABS_THROTTLE, ABS_RUDDER, ABS_WHEEL, ABS_GAS, ABS_BRAKE, ABS_PRESSURE,
ABS_DISTANCE, ABS_TILT_X, ABS_TILT_Y, ABS_TOOL_WIDTH]
def __init__(self, devIndex, devType, devName):
"""
Opens the given /dev/input/event file and grabs it.
Also adds it to a class-global list of all existing streams.
"""
self.devIndex = devIndex
self.devType = devType
self.devName = devName
self.filename = "/dev/input/event" + str(devIndex)
self.filehandle = os.open(self.filename, os.O_RDWR)
self.state = EventState()
self.grab(True)
self.absInfo = [None] * ABS_MAX
if devType == "js":
for axis in range(ABS_MAX):
self.absInfo[axis] = AbsAxisScaling(self, axis)
def scale(self, axis, value):
"""
Scale the given value according to the given axis.
acquire_abs_info must have been previously called to acquire the data to
do the scaling.
"""
assert axis < ABS_MAX, "Axis number out of range"
if self.absInfo[axis]: return self.absInfo[axis].scale(value)
else: return value
def grab(self, grab = True):
"""
Grab (or release) exclusive access to all devices of the given type.
The devices are grabbed if grab is True and released if grab is False.
All devices are grabbed to begin with. We might want to ungrab the
keyboard for example to use it for text entry. While not grabbed, all
key-down and key-hold events are filtered out.
"""
fcntl.ioctl(self.filehandle, EVIOCGRAB, 1 if grab else 0)
self.grabbed = grab
def __iter__(self):
"""s
Required to make this class an iterator
"""
return self
def next(self): return self.__next__()
def __next__(self):
"""
Returns the next waiting event.
If no event is waiting, returns None.
"""
ready = select.select([self.filehandle], [], [], 0)[0]
if ready: return self.read()
def read(self):
"""
Read and return the next waiting event.
"""
try:
s = os.read(self.filehandle, Event.size)
if s:
event = Event.Event(self)
event.decode(s)
return event
except Exception as e:
log.info('Reading event: %s' % e)
def __enter__(self): return self
def release(self):
"Ungrabs the file and closes it."
try:
self.grab(False)
os.close(self.filehandle)
except:
pass
def __exit__(self, type, value, traceback):
"Ungrabs the file and closes it."
self.release()

View File

@@ -0,0 +1,245 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import re
import logging
from inevent.Constants import *
log = logging.getLogger('inevent')
def test_bit(nlst, b):
index = b / 32
bit = b % 32
return index < len(nlst) and nlst[index] & (1 << bit)
def EvToStr(events):
s = []
if test_bit(events, EV_SYN): s.append("EV_SYN")
if test_bit(events, EV_KEY): s.append("EV_KEY")
if test_bit(events, EV_REL): s.append("EV_REL")
if test_bit(events, EV_ABS): s.append("EV_ABS")
if test_bit(events, EV_MSC): s.append("EV_MSC")
if test_bit(events, EV_LED): s.append("EV_LED")
if test_bit(events, EV_SND): s.append("EV_SND")
if test_bit(events, EV_REP): s.append("EV_REP")
if test_bit(events, EV_FF): s.append("EV_FF" )
if test_bit(events, EV_PWR): s.append("EV_PWR")
if test_bit(events, EV_FF_STATUS): s.append("EV_FF_STATUS")
return s
class DeviceCapabilities(object):
def __init__(self, firstLine, filehandle):
self.EV_SYNevents = []
self.EV_KEYevents = []
self.EV_RELevents = []
self.EV_ABSevents = []
self.EV_MSCevents = []
self.EV_LEDevents = []
self.EV_SNDevents = []
self.EV_REPevents = []
self.EV_FFevents = []
self.EV_PWRevents = []
self.EV_FF_STATUSevents = []
self.eventTypes = []
match = re.search(".*Bus=([0-9A-Fa-f]+).*Vendor=([0-9A-Fa-f]+).*"
"Product=([0-9A-Fa-f]+).*Version=([0-9A-Fa-f]+).*",
firstLine)
if not match:
log.warning("Do not understand device ID: %s", firstLine)
self.bus = 0
self.vendor = 0
self.product = 0
self.version = 0
else:
self.bus = int(match.group(1), base = 16)
self.vendor = int(match.group(2), base = 16)
self.product = int(match.group(3), base = 16)
self.version = int(match.group(4), base = 16)
for line in filehandle:
if not line.strip(): break
if line[0] == "N":
match = re.search('Name="([^"]+)"', line)
if match: self.name = match.group(1)
else: self.name = "UNKNOWN"
elif line[0] == "P":
match = re.search('Phys=(.+)', line)
if match: self.phys = match.group(1)
else: self.phys = "UNKNOWN"
elif line[0] == "S":
match = re.search('Sysfs=(.+)', line)
if match: self.sysfs = match.group(1)
else: self.sysfs = "UNKNOWN"
elif line[0] == "U":
match = re.search('Uniq=(.*)', line)
if match: self.uniq = match.group(1)
else: self.uniq = "UNKNOWN"
elif line[0] == "H":
match = re.search('Handlers=(.+)', line)
if match: self.handlers = match.group(1).split()
else: self.handlers = []
elif line[:5] == "B: EV":
eventsNums = [int(x, base = 16) for x in line[6:].split()]
eventsNums.reverse()
self.eventTypes = eventsNums
elif line[:6] == "B: KEY":
eventsNums = [int(x, base = 16) for x in line[7:].split()]
eventsNums.reverse()
self.EV_KEYevents = eventsNums
elif line[:6] == "B: ABS":
eventsNums = [int(x, base = 16) for x in line[7:].split()]
eventsNums.reverse()
self.EV_ABSevents = eventsNums
elif line[:6] == "B: MSC":
eventsNums = [int(x, base = 16) for x in line[7:].split()]
eventsNums.reverse()
self.EV_MSCevents = eventsNums
elif line[:6] == "B: REL":
eventsNums = [int(x, base = 16) for x in line[7:].split()]
eventsNums.reverse()
self.EV_RELevents = eventsNums
elif line[:6] == "B: LED":
eventsNums = [int(x, base = 16) for x in line[7:].split()]
eventsNums.reverse()
self.EV_LEDevents = eventsNums
for handler in self.handlers:
if handler[:5] == "event": self.eventIndex = int(handler[5:])
self.isMouse = False
self.isKeyboard = False
self.isJoystick = False
def doesProduce(self, eventType, eventCode):
return test_bit(self.eventTypes, eventType) and (
(eventType == EV_SYN and test_bit(self.EV_SYNevents, eventCode)) or
(eventType == EV_KEY and test_bit(self.EV_KEYevents, eventCode)) or
(eventType == EV_REL and test_bit(self.EV_RELevents, eventCode)) or
(eventType == EV_ABS and test_bit(self.EV_ABSevents, eventCode)) or
(eventType == EV_MSC and test_bit(self.EV_MSCevents, eventCode)) or
(eventType == EV_LED and test_bit(self.EV_LEDevents, eventCode)) or
(eventType == EV_SND and test_bit(self.EV_SNDevents, eventCode)) or
(eventType == EV_REP and test_bit(self.EV_REPevents, eventCode)) or
(eventType == EV_FF and test_bit(self.EV_FFevents, eventCode)) or
(eventType == EV_PWR and test_bit(self.EV_PWRevents, eventCode)) or
(eventType == EV_FF_STATUS and
test_bit(self.EV_FF_STATUSevents, eventCode)))
def __str__(self):
return (
("%s\n"
"Bus: %s Vendor: %s Product: %s Version: %s\n"
"Phys: %s\n"
"Sysfs: %s\n"
"Uniq: %s\n"
"Handlers: %s Event Index: %s\n"
"Keyboard: %s Mouse: %s Joystick: %s\n"
"Events: %s") % (
self.name, self.bus, self.vendor, self.product, self.version, self.phys,
self.sysfs, self.uniq, self.handlers, self.eventIndex, self.isKeyboard,
self.isMouse, self.isJoystick, EvToStr(self.eventTypes)))
deviceCapabilities = []
def get_devices(filename = "/proc/bus/input/devices"):
global deviceCapabilities
with open("/proc/bus/input/devices", "r") as filehandle:
for line in filehandle:
if line[0] == "I":
deviceCapabilities.append(DeviceCapabilities(line, filehandle))
return deviceCapabilities
def print_devices():
devs = get_devices()
for dev in devs:
print(str(dev))
print(" ABS: {}"
.format([x for x in range(64) if test_bit(dev.EV_ABSevents, x)]))
print(" REL: {}"
.format([x for x in range(64) if test_bit(dev.EV_RELevents, x)]))
print(" MSC: {}"
.format([x for x in range(64) if test_bit(dev.EV_MSCevents, x)]))
print(" KEY: {}"
.format([x for x in range(512) if test_bit(dev.EV_KEYevents, x)]))
print(" LED: {}"
.format([x for x in range(64) if test_bit(dev.EV_LEDevents, x)]))
print()

288
src/py/inevent/InEvent.py Normal file
View File

@@ -0,0 +1,288 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import pyudev
import re
import select
import errno
import functools
import logging
from inevent.EventHandler import EventHandler
from inevent import Keys
from inevent.Constants import *
from inevent.EventStream import EventStream
log = logging.getLogger('inevent')
_KEYS = (k for k in vars(Keys) if not k.startswith('_'))
KEY_CODE = dict((k, getattr(Keys, k)) for k in _KEYS)
CODE_KEY = {}
for v in KEY_CODE: CODE_KEY[KEY_CODE[v]] = v
def key_to_code(key):
return KEY_CODE.get(str(key), -1) \
if isinstance(key, str) else key
def code_to_key(code): return CODE_KEY.get(code, '')
class InEvent(object):
"""Encapsulates the entire InEvent subsystem.
This is generally all you need to import.
On instantiation, we open all devices that are keyboards, mice or joysticks.
That means we might have two of one sort of another, and that might be a
problem, but it would be rather rare.
There are several ABS (joystick, touch) events that we do not handle,
specifically THROTTLE, RUDDER, WHEEL, GAS, BRAKE, HAT1, HAT2, HAT3, PRESSURE,
DISTANCE, TILT, TOOL_WIDTH. Implementing these is left as an exercise
for the interested reader. Similarly, we make no attempt to handle
multi-touch.
Handlers can be supplied, in which case they are called for each event, but
it isn't necessary; API exists for all the events.
The handler signature is:
def handler_func(event, state)
where:
event:
An Event object describing the event.
state:
An EventState object describing the current state.
Use key_to_code() to convert from the name of a key to its code,
and code_to_key() to convert a code to a name.
The keys are listed in inevent.Constants.py or /usr/include/linux/input.h
Note that the key names refer to a US keyboard.
"""
def __init__(self, ioloop, cb, types = 'kbd mouse js'.split()):
self.ioloop = ioloop
self.cb = cb
self.streams = []
self.handler = EventHandler()
self.types = types
self.udevCtx = pyudev.Context()
self.udevMon = pyudev.Monitor.from_netlink(self.udevCtx)
self.udevMon.filter_by(subsystem = 'input')
devs = list(self.find_devices(types))
for index, type, name in devs:
self.add_stream(index, type, name)
self.udevMon.start()
ioloop.add_handler(self.udevMon.fileno(), self.udev_handler, ioloop.READ)
def get_dev(self, index):
return pyudev.Device.from_name(self.udevCtx, 'input', 'event%s' % index)
def get_dev_name(self, index):
try:
dev = self.get_dev(index)
return dev.parent.attributes.asstring('name').decode('utf-8')
except: pass
def find_devices(self, types):
"""Finds the event indices of all devices of the specified types.
A type is a string on the handlers line of /proc/bus/input/devices.
Keyboards use "kbd", mice use "mouse" and joysticks (and gamepads) use "js".
Returns a list of integer indexes N, where /dev/input/eventN is the event
stream for each device.
If butNot is given it holds a list of tuples which the returned values
should not match.
All devices of each type are returned; if you have two mice, they will both
be used.
"""
with open("/proc/bus/input/devices", "r") as filehandle:
for line in filehandle:
if line[0] == "H":
for type in types:
if type in line:
match = re.search("event([0-9]+)", line)
index = match and match.group(1)
if index:
yield int(index), type, self.get_dev_name(index)
break
def process_udev_event(self):
action, device = self.udevMon.receive_device()
if device is None: return
match = re.search(r"/dev/input/event([0-9]+)", str(device.device_node))
devIndex = match and match.group(1)
if not devIndex: return
devIndex = int(devIndex)
if action == 'add':
for index, devType, devName in self.find_devices(self.types):
if index == devIndex:
self.add_stream(devIndex, devType, devName)
break
if action == 'remove': self.remove_stream(devIndex)
def stream_handler(self, fd, events):
for stream in self.streams:
if stream.filehandle == fd:
while True:
event = stream.next()
if event: self.handler.event(event, self.cb, stream.devName)
else: break
def udev_handler(self, fd, events):
self.process_udev_event()
def add_stream(self, devIndex, devType, devName):
try:
stream = EventStream(devIndex, devType, devName)
self.streams.append(stream)
self.ioloop.add_handler(stream.filehandle, self.stream_handler,
self.ioloop.READ)
log.info('Added %s[%d] %s', devType, devIndex, devName)
except OSError as e:
log.warning('Failed to add %s[%d]: %s', devType, devIndex, e)
def remove_stream(self, devIndex):
for stream in self.streams:
if stream.devIndex == devIndex:
self.streams.remove(stream)
self.ioloop.remove_handler(stream.filehandle)
stream.release()
self.cb.clear()
log.info('Removed %s[%d]', stream.devType, devIndex)
def key_state(self, key):
"""
Returns the state of the given key.
The returned value will be 0 for key-up, or 1 for key-down. This method
returns a key-held(2) as 1 to aid in using the returned value as a
movement distance.
This function accepts either the key code or the string name of the key.
It would be more efficient to look-up and store the code of
the key with KEY_CODE[], rather than using the string every time. (Which
involves a dict look-up keyed with a string for every key_state call, every
time around the loop.)
Gamepad keys are:
Select = BTN_BASE3, Start = BTN_BASE4
L1 = BTN_TOP R1 = BTN_BASE
L2 = BTN_PINKIE R2 = BTN_BASE2
The action buttons are:
BTN_THUMB
BTN_TRIGGER
BTN_TOP
BTN_THUMB2
Analogue Left Button = BTN_BASE5
Analogue Right Button = BTN_BASE6
Some of those may clash with extended mouse buttons, so if you are using
both at once, you'll see some overlap.
The direction pad is hat0 (see get_hat)
"""
return self.handler.key_state(key_to_code(key))
def clear_key(self, key):
"""
Clears the state of the given key.
Emulates a key-up, but does not call any handlers.
"""
return self.handler.clear_key(key_to_code(key))
def get_keys(self):
return [code_to_key(k) for k in self.handler.get_keys()]
def release(self):
"""
Ungrabs all streams and closes all files.
Only do this when you're finished with this object. You can't use it again.
"""
for s in self.streams: s.release()

View File

@@ -0,0 +1,153 @@
################################################################################
# #
# 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 logging
from inevent.Constants import *
log = logging.getLogger('inevent')
log.setLevel(logging.INFO)
def axes_to_string(axes):
s = ''
for axis in axes:
if s: s += ', '
else: s = '('
s += '{:6.3f}'.format(axis)
return s + ')'
def event_to_string(event, state):
s = '{} {}: '.format(event.get_source(), event.get_type_name())
if event.type == EV_ABS:
s += axes_to_string(state.get_joystick3d()) + ' ' + \
axes_to_string(state.get_joystickR3d()) + ' ' + \
'({:2.0f}, {:2.0f}) '.format(*state.get_hat())
if event.type == EV_REL:
s += '({:d}, {:d}) '.format(*state.get_mouse()) + \
'({:d}, {:d})'.format(*state.get_wheel())
if event.type == EV_KEY:
state = 'pressed' if event.value else 'released'
s += '0x{:x} {}'.format(event.code, state)
return s
class JogHandler:
def __init__(self, config):
self.config = config
self.reset()
def changed(self):
log.info(axes_to_string(self.axes) + ' x {:d}'.format(self.speed))
def up(self): log.debug('up')
def down(self): log.debug('down')
def left(self): log.debug('left')
def right(self): log.debug('right')
def reset(self):
self.axes = [0.0, 0.0, 0.0, 0.0]
self.speed = 3
self.vertical_lock = 0
self.horizontal_lock = 0
def clear(self):
self.reset()
self.changed()
def get_config(self, name):
if name in self.config: return self.config[name]
return self.config['default']
def event(self, event, state, dev_name):
if event.type not in [EV_ABS, EV_REL, EV_KEY]: return
config = self.get_config(dev_name)
changed = False
# Process event
if event.type == EV_ABS and event.code in config['axes']:
pass
elif event.type == EV_ABS and event.code in config['arrows']:
axis = config['arrows'].index(event.code)
if event.value < 0:
if axis == 1: self.up()
else: self.left()
elif 0 < event.value:
if axis == 1: self.down()
else: self.right()
elif event.type == EV_KEY and event.code in config['speed']:
old_speed = self.speed
self.speed = config['speed'].index(event.code) + 1
if self.speed != old_speed: changed = True
elif event.type == EV_KEY and event.code in config['lock']:
index = config['lock'].index(event.code)
self.horizontal_lock, self.vertical_lock = False, False
if event.value:
if index == 0: self.horizontal_lock = True
if index == 1: self.vertical_lock = True
log.debug(event_to_string(event, state))
# Update axes
old_axes = list(self.axes)
for axis in range(4):
self.axes[axis] = event.stream.state.abs[config['axes'][axis]]
self.axes[axis] *= config['dir'][axis]
if abs(self.axes[axis]) < config['deadband']:
self.axes[axis] = 0
if self.horizontal_lock and axis not in [0, 3]:
self.axes[axis] = 0
if self.vertical_lock and axis not in [1, 2]:
self.axes[axis] = 0
if old_axes != self.axes: changed = True
if changed: self.changed()

445
src/py/inevent/Keys.py Normal file
View File

@@ -0,0 +1,445 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
KEY_ESC = 1
KEY_1 = 2
KEY_2 = 3
KEY_3 = 4
KEY_4 = 5
KEY_5 = 6
KEY_6 = 7
KEY_7 = 8
KEY_8 = 9
KEY_9 = 10
KEY_0 = 11
KEY_MINUS = 12
KEY_EQUAL = 13
KEY_BACKSPACE = 14
KEY_TAB = 15
KEY_Q = 16
KEY_W = 17
KEY_E = 18
KEY_R = 19
KEY_T = 20
KEY_Y = 21
KEY_U = 22
KEY_I = 23
KEY_O = 24
KEY_P = 25
KEY_LEFTBRACE = 26
KEY_RIGHTBRACE = 27
KEY_ENTER = 28
KEY_LEFTCTRL = 29
KEY_A = 30
KEY_S = 31
KEY_D = 32
KEY_F = 33
KEY_G = 34
KEY_H = 35
KEY_J = 36
KEY_K = 37
KEY_L = 38
KEY_SEMICOLON = 39
KEY_APOSTROPHE = 40
KEY_GRAVE = 41
KEY_LEFTSHIFT = 42
KEY_BACKSLASH = 43
KEY_Z = 44
KEY_X = 45
KEY_C = 46
KEY_V = 47
KEY_B = 48
KEY_N = 49
KEY_M = 50
KEY_COMMA = 51
KEY_DOT = 52
KEY_SLASH = 53
KEY_RIGHTSHIFT = 54
KEY_KPASTERISK = 55
KEY_LEFTALT = 56
KEY_SPACE = 57
KEY_CAPSLOCK = 58
KEY_F1 = 59
KEY_F2 = 60
KEY_F3 = 61
KEY_F4 = 62
KEY_F5 = 63
KEY_F6 = 64
KEY_F7 = 65
KEY_F8 = 66
KEY_F9 = 67
KEY_F10 = 68
KEY_NUMLOCK = 69
KEY_SCROLLLOCK = 70
KEY_KP7 = 71
KEY_KP8 = 72
KEY_KP9 = 73
KEY_KPMINUS = 74
KEY_KP4 = 75
KEY_KP5 = 76
KEY_KP6 = 77
KEY_KPPLUS = 78
KEY_KP1 = 79
KEY_KP2 = 80
KEY_KP3 = 81
KEY_KP0 = 82
KEY_KPDOT = 83
KEY_ZENKAKUHANKAKU = 85
KEY_102ND = 86
KEY_F11 = 87
KEY_F12 = 88
KEY_RO = 89
KEY_KATAKANA = 90
KEY_HIRAGANA = 91
KEY_HENKAN = 92
KEY_KATAKANAHIRAGANA = 93
KEY_MUHENKAN = 94
KEY_KPJPCOMMA = 95
KEY_KPENTER = 96
KEY_RIGHTCTRL = 97
KEY_KPSLASH = 98
KEY_SYSRQ = 99
KEY_RIGHTALT = 100
KEY_LINEFEED = 101
KEY_HOME = 102
KEY_UP = 103
KEY_PAGEUP = 104
KEY_LEFT = 105
KEY_RIGHT = 106
KEY_END = 107
KEY_DOWN = 108
KEY_PAGEDOWN = 109
KEY_INSERT = 110
KEY_DELETE = 111
KEY_MACRO = 112
KEY_MUTE = 113
KEY_VOLUMEDOWN = 114
KEY_VOLUMEUP = 115
KEY_POWER = 116
KEY_KPEQUAL = 117
KEY_KPPLUSMINUS = 118
KEY_PAUSE = 119
KEY_KPCOMMA = 121
KEY_HANGUEL = 122
KEY_HANJA = 123
KEY_YEN = 124
KEY_LEFTMETA = 125
KEY_RIGHTMETA = 126
KEY_COMPOSE = 127
KEY_STOP = 128
KEY_AGAIN = 129
KEY_PROPS = 130
KEY_UNDO = 131
KEY_FRONT = 132
KEY_COPY = 133
KEY_OPEN = 134
KEY_PASTE = 135
KEY_FIND = 136
KEY_CUT = 137
KEY_HELP = 138
KEY_MENU = 139
KEY_CALC = 140
KEY_SETUP = 141
KEY_SLEEP = 142
KEY_WAKEUP = 143
KEY_FILE = 144
KEY_SENDFILE = 145
KEY_DELETEFILE = 146
KEY_XFER = 147
KEY_PROG1 = 148
KEY_PROG2 = 149
KEY_WWW = 150
KEY_MSDOS = 151
KEY_COFFEE = 152
KEY_DIRECTION = 153
KEY_CYCLEWINDOWS = 154
KEY_MAIL = 155
KEY_BOOKMARKS = 156
KEY_COMPUTER = 157
KEY_BACK = 158
KEY_FORWARD = 159
KEY_CLOSECD = 160
KEY_EJECTCD = 161
KEY_EJECTCLOSECD = 162
KEY_NEXTSONG = 163
KEY_PLAYPAUSE = 164
KEY_PREVIOUSSONG = 165
KEY_STOPCD = 166
KEY_RECORD = 167
KEY_REWIND = 168
KEY_PHONE = 169
KEY_ISO = 170
KEY_CONFIG = 171
KEY_HOMEPAGE = 172
KEY_REFRESH = 173
KEY_EXIT = 174
KEY_MOVE = 175
KEY_EDIT = 176
KEY_SCROLLUP = 177
KEY_SCROLLDOWN = 178
KEY_KPLEFTPAREN = 179
KEY_KPRIGHTPAREN = 180
KEY_F13 = 183
KEY_F14 = 184
KEY_F15 = 185
KEY_F16 = 186
KEY_F17 = 187
KEY_F18 = 188
KEY_F19 = 189
KEY_F20 = 190
KEY_F21 = 191
KEY_F22 = 192
KEY_F23 = 193
KEY_F24 = 194
KEY_PLAYCD = 200
KEY_PAUSECD = 201
KEY_PROG3 = 202
KEY_PROG4 = 203
KEY_SUSPEND = 205
KEY_CLOSE = 206
KEY_PLAY = 207
KEY_FASTFORWARD = 208
KEY_BASSBOOST = 209
KEY_PRINT = 210
KEY_HP = 211
KEY_CAMERA = 212
KEY_SOUND = 213
KEY_QUESTION = 214
KEY_EMAIL = 215
KEY_CHAT = 216
KEY_SEARCH = 217
KEY_CONNECT = 218
KEY_FINANCE = 219
KEY_SPORT = 220
KEY_SHOP = 221
KEY_ALTERASE = 222
KEY_CANCEL = 223
KEY_BRIGHTNESSDOWN = 224
KEY_BRIGHTNESSUP = 225
KEY_MEDIA = 226
KEY_UNKNOWN = 240
BTN_MISC = 0x100
BTN_0 = 0x100
BTN_1 = 0x101
BTN_2 = 0x102
BTN_3 = 0x103
BTN_4 = 0x104
BTN_5 = 0x105
BTN_6 = 0x106
BTN_7 = 0x107
BTN_8 = 0x108
BTN_9 = 0x109
BTN_MOUSE = 0x110
BTN_LEFT = 0x110
BTN_RIGHT = 0x111
BTN_MIDDLE = 0x112
BTN_SIDE = 0x113
BTN_EXTRA = 0x114
BTN_FORWARD = 0x115
BTN_BACK = 0x116
BTN_TASK = 0x117
BTN_JOYSTICK = 0x120
BTN_TRIGGER = 0x120
BTN_THUMB = 0x121
BTN_THUMB2 = 0x122
BTN_TOP = 0x123
BTN_TOP2 = 0x124
BTN_PINKIE = 0x125
BTN_BASE = 0x126
BTN_BASE2 = 0x127
BTN_BASE3 = 0x128
BTN_BASE4 = 0x129
BTN_BASE5 = 0x12a
BTN_BASE6 = 0x12b
BTN_DEAD = 0x12f
BTN_GAMEPAD = 0x130
BTN_A = 0x130
BTN_B = 0x131
BTN_C = 0x132
BTN_X = 0x133
BTN_Y = 0x134
BTN_Z = 0x135
BTN_TL = 0x136
BTN_TR = 0x137
BTN_TL2 = 0x138
BTN_TR2 = 0x139
BTN_SELECT = 0x13a
BTN_START = 0x13b
BTN_MODE = 0x13c
BTN_THUMBL = 0x13d
BTN_THUMBR = 0x13e
BTN_DIGI = 0x140
BTN_TOOL_PEN = 0x140
BTN_TOOL_RUBBER = 0x141
BTN_TOOL_BRUSH = 0x142
BTN_TOOL_PENCIL = 0x143
BTN_TOOL_AIRBRUSH = 0x144
BTN_TOOL_FINGER = 0x145
BTN_TOOL_MOUSE = 0x146
BTN_TOOL_LENS = 0x147
BTN_TOUCH = 0x14a
BTN_STYLUS = 0x14b
BTN_STYLUS2 = 0x14c
BTN_TOOL_DOUBLETAP = 0x14d
BTN_TOOL_TRIPLETAP = 0x14e
BTN_WHEEL = 0x150
BTN_GEAR_DOWN = 0x150
BTN_GEAR_UP = 0x151
KEY_OK = 0x160
KEY_SELECT = 0x161
KEY_GOTO = 0x162
KEY_CLEAR = 0x163
KEY_POWER2 = 0x164
KEY_OPTION = 0x165
KEY_INFO = 0x166
KEY_TIME = 0x167
KEY_VENDOR = 0x168
KEY_ARCHIVE = 0x169
KEY_PROGRAM = 0x16a
KEY_CHANNEL = 0x16b
KEY_FAVORITES = 0x16c
KEY_EPG = 0x16d
KEY_PVR = 0x16e
KEY_MHP = 0x16f
KEY_LANGUAGE = 0x170
KEY_TITLE = 0x171
KEY_SUBTITLE = 0x172
KEY_ANGLE = 0x173
KEY_ZOOM = 0x174
KEY_MODE = 0x175
KEY_KEYBOARD = 0x176
KEY_SCREEN = 0x177
KEY_PC = 0x178
KEY_TV = 0x179
KEY_TV2 = 0x17a
KEY_VCR = 0x17b
KEY_VCR2 = 0x17c
KEY_SAT = 0x17d
KEY_SAT2 = 0x17e
KEY_CD = 0x17f
KEY_TAPE = 0x180
KEY_RADIO = 0x181
KEY_TUNER = 0x182
KEY_PLAYER = 0x183
KEY_TEXT = 0x184
KEY_DVD = 0x185
KEY_AUX = 0x186
KEY_MP3 = 0x187
KEY_AUDIO = 0x188
KEY_VIDEO = 0x189
KEY_DIRECTORY = 0x18a
KEY_LIST = 0x18b
KEY_MEMO = 0x18c
KEY_CALENDAR = 0x18d
KEY_RED = 0x18e
KEY_GREEN = 0x18f
KEY_YELLOW = 0x190
KEY_BLUE = 0x191
KEY_CHANNELUP = 0x192
KEY_CHANNELDOWN = 0x193
KEY_FIRST = 0x194
KEY_LAST = 0x195
KEY_AB = 0x196
KEY_NEXT = 0x197
KEY_RESTART = 0x198
KEY_SLOW = 0x199
KEY_SHUFFLE = 0x19a
KEY_BREAK = 0x19b
KEY_PREVIOUS = 0x19c
KEY_DIGITS = 0x19d
KEY_TEEN = 0x19e
KEY_TWEN = 0x19f
KEY_DEL_EOL = 0x1c0
KEY_DEL_EOS = 0x1c1
KEY_INS_LINE = 0x1c2
KEY_DEL_LINE = 0x1c3
KEY_FN = 0x1d0
KEY_FN_ESC = 0x1d1
KEY_FN_F1 = 0x1d2
KEY_FN_F2 = 0x1d3
KEY_FN_F3 = 0x1d4
KEY_FN_F4 = 0x1d5
KEY_FN_F5 = 0x1d6
KEY_FN_F6 = 0x1d7
KEY_FN_F7 = 0x1d8
KEY_FN_F8 = 0x1d9
KEY_FN_F9 = 0x1da
KEY_FN_F10 = 0x1db
KEY_FN_F11 = 0x1dc
KEY_FN_F12 = 0x1dd
KEY_FN_1 = 0x1de
KEY_FN_2 = 0x1df
KEY_FN_D = 0x1e0
KEY_FN_E = 0x1e1
KEY_FN_F = 0x1e2
KEY_FN_S = 0x1e3
KEY_FN_B = 0x1e4
KEY_MAX = 0x1ff

View File

@@ -0,0 +1,57 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from .InEvent import InEvent
from .JogHandler import JogHandler

128
src/py/inevent/ioctl.py Normal file
View File

@@ -0,0 +1,128 @@
################################################################################
# #
# 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> #
# #
################################################################################
# The inevent Python module was adapted from pi3d.event from the pi3d
# project.
#
# Copyright (c) 2016, Joseph Coffland, Cauldron Development LLC.
# Copyright (c) 2015, Tim Skillman.
# Copyright (c) 2015, Paddy Gaunt.
# Copyright (c) 2015, Tom Ritchford.
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# IOCTL macros
#
# ioctl command encoding: 32 bits total, command in lower 16 bits,
# size of the parameter structure in the lower 14 bits of the
# upper 16 bits.
#
# Encoding the size of the parameter structure in the ioctl request
# is useful for catching programs compiled with old versions
# and to avoid overwriting user space outside the user buffer area.
# The highest 2 bits are reserved for indicating the ``access mode''.
# NOTE: This limits the max parameter size to 16kB - 1
#
# The following is for compatibility across the various Linux
# platforms. The generic ioctl numbering scheme doesn't really enforce
# a type field. De facto, however, the top 8 bits of the lower 16
# bits are indeed used as a type field, so we might just as well make
# this explicit here.
import struct
sizeof = struct.calcsize
_IOC_NRBITS = 8
_IOC_TYPEBITS = 8
_IOC_SIZEBITS = 14
_IOC_DIRBITS = 2
_IOC_NRMASK = (1 << _IOC_NRBITS) - 1
_IOC_TYPEMASK = (1 << _IOC_TYPEBITS) - 1
_IOC_SIZEMASK = (1 << _IOC_SIZEBITS) - 1
_IOC_DIRMASK = (1 << _IOC_DIRBITS) - 1
_IOC_NRSHIFT = 0
_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS
_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS
_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS
_IOC_NONE = 0
_IOC_WRITE = 1
_IOC_READ = 2
_IOC_RW = _IOC_READ | _IOC_WRITE
def _IOC(dir, type, nr, size):
return int(
(dir << _IOC_DIRSHIFT) |
(type << _IOC_TYPESHIFT) |
(nr << _IOC_NRSHIFT) |
(size << _IOC_SIZESHIFT))
# encode ioctl numbers
def _IO(type, nr): return _IOC(_IOC_NONE, type, nr, 0)
def _IOR(type, nr, fmt): return _IOC(_IOC_READ, type, nr, sizeof(fmt))
def _IOW(type, nr, fmt): return _IOC(_IOC_WRITE, type, nr, sizeof(fmt))
def _IOWR(type, nr, fmt): return _IOC(_IOC_RW, type, nr, sizeof(fmt))
def _IOR_BAD(type, nr, fmt): return _IOC(_IOC_READ, type, nr, sizeof(fmt))
def _IOW_BAD(type, nr, fmt): return _IOC(_IOC_WRITE, type, nr, sizeof(fmt))
def _IOWR_BAD(type, nr, fmt): return _IOC(_IOC_RW, type, nr, sizeof(fmt))
# decode ioctl numbers
def _IOC_DIR(nr): return (nr >> _IOC_DIRSHIFT) & _IOC_DIRMASK
def _IOC_TYPE(nr): return (nr >> _IOC_TYPESHIFT) & _IOC_TYPEMASK
def _IOC_NR(nr): return (nr >> _IOC_NRSHIFT) & _IOC_NRMASK
def _IOC_SIZE(nr): return (nr >> _IOC_SIZESHIFT) & _IOC_SIZEMASK
# for drivers/sound files
IOC_IN = _IOC_WRITE << _IOC_DIRSHIFT
IOC_OUT = _IOC_READ << _IOC_DIRSHIFT
IOC_INOUT = _IOC_RW << _IOC_DIRSHIFT
IOCSIZE_MASK = _IOC_SIZEMASK << _IOC_SIZESHIFT
IOCSIZE_SHIFT = _IOC_SIZESHIFT

236
src/py/lcd/__init__.py Normal file
View File

@@ -0,0 +1,236 @@
#!/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 time
import logging
log = logging.getLogger('LCD')
# Control flags
REG_SELECT_BIT = 1 << 0
READ_BIT = 1 << 1
ENABLE_BIT = 1 << 2
BACKLIGHT_BIT = 1 << 3
# Commands
LCD_CLEAR_DISPLAY = 1 << 0
LCD_RETURN_HOME = 1 << 1
LCD_ENTRY_MODE_SET = 1 << 2
LCD_DISPLAY_CONTROL = 1 << 3
LCD_CURSOR_SHIFT = 1 << 4
LCD_FUNCTION_SET = 1 << 5
LCD_SET_CGRAM_ADDR = 1 << 6
LCD_SET_DDRAM_ADDR = 1 << 7
# Entry Mode Set flags
LCD_ENTRY_SHIFT_DISPLAY = 1 << 0
LCD_ENTRY_SHIFT_INC = 1 << 1
LCD_ENTRY_SHIFT_DEC = 0 << 1
# Display Control flags
LCD_BLINK_ON = 1 << 0
LCD_BLINK_OFF = 0 << 0
LCD_CURSOR_ON = 1 << 1
LCD_CURSOR_OFF = 0 << 1
LCD_DISPLAY_ON = 1 << 2
LCD_DISPLAY_OFF = 0 << 2
# Cursor Shift flags
LCD_SHIFT_RIGHT = 1 << 2
LCD_SHIFT_LEFT = 0 << 2
LCD_SHIFT_DISPLAY = 1 << 3
LCD_SHIFT_CURSOR = 0 << 3
# Function Set flags
LCD_5x11_DOTS = 1 << 2
LCD_5x8_DOTS = 0 << 2
LCD_2_LINE = 1 << 3
LCD_1_LINE = 0 << 3
LCD_8_BIT_MODE = 1 << 4
LCD_4_BIT_MODE = 0 << 4
# Text justification flags
JUSTIFY_LEFT = 0
JUSTIFY_RIGHT = 1
JUSTIFY_CENTER = 2
class LCD:
def __init__(self, i2c, addr, height = 4, width = 20):
self.addr = addr
self.height = height
self.width = width
self.i2c = i2c
self.backlight = True
self.reset()
def reset(self):
self.clear()
time.sleep(0.050)
self.write_nibble(3 << 4) # Home
time.sleep(0.050)
self.write_nibble(3 << 4) # Home
time.sleep(0.050)
self.write_nibble(3 << 4) # Home
self.write_nibble(2 << 4) # 4-bit
self.write(LCD_FUNCTION_SET | LCD_2_LINE | LCD_5x8_DOTS |
LCD_4_BIT_MODE)
self.write(LCD_DISPLAY_CONTROL | LCD_DISPLAY_ON)
self.write(LCD_ENTRY_MODE_SET | LCD_ENTRY_SHIFT_INC)
def write_i2c(self, data):
if self.backlight: data |= BACKLIGHT_BIT
self.i2c.write(self.addr, data)
time.sleep(0.0001)
# Write half of a command to LCD
def write_nibble(self, data):
self.write_i2c(data)
# Strobe
self.write_i2c(data | ENABLE_BIT)
time.sleep(0.0005)
self.write_i2c(data & ~ENABLE_BIT)
time.sleep(0.0001)
# Write an 8-bit command to LCD
def write(self, cmd, flags = 0):
self.write_nibble(flags | (cmd & 0xf0))
self.write_nibble(flags | ((cmd << 4) & 0xf0))
def set_cursor(self, on, blink):
data = LCD_DISPLAY_CONTROL
if on: data |= LCD_CURSOR_ON
if blink: data |= LCD_BLINK_ON
self.write(data)
def set_backlight(self, enable):
self.backlight = enable
self.write_i2c(0)
def program_char(self, addr, data):
if addr < 0 or 8 <= addr: return
self.write(LCD_SET_CGRAM_ADDR | (addr << 3))
for x in data:
self.write(x, REG_SELECT_BIT)
def goto(self, x, y):
if x < 0 or self.width <= x or y < 0 or self.height <= y: return
self.write(LCD_SET_DDRAM_ADDR | (0, 64, 20, 84)[y] + int(x))
def put_char(self, c):
self.write(ord(c), REG_SELECT_BIT)
def text(self, msg, x = None, y = None):
if x is not None and y is not None: self.goto(x, y)
for c in msg: self.put_char(c)
def display(self, line, msg, justify = JUSTIFY_LEFT):
if justify == JUSTIFY_RIGHT: x = self.width - len(msg)
elif justify == JUSTIFY_CENTER: x = (self.width - len(msg)) / 2
else: x = 0
if x < 0: x = 0
self.text(msg, x, line)
def shift(self, count = 1, right = True, display = True):
cmd = LCD_CURSOR_SHIFT
if right: cmd |= LCD_SHIFT_RIGHT
if display: cmd |= LCD_SHIFT_DISPLAY
for i in range(count): self.write(cmd)
# Clear LCD and move cursor home
def clear(self):
self.write(LCD_CLEAR_DISPLAY)
self.write(LCD_RETURN_HOME)
if __name__ == "__main__":
lcd = LCD(1, 0x27)
lcd.clear()
lcd.program_char(0, (0b11011,
0b11011,
0b00000,
0b01100,
0b01100,
0b00000,
0b11011,
0b11011))
lcd.program_char(1, (0b11000,
0b01100,
0b00110,
0b00011,
0b00011,
0b00110,
0b01100,
0b11000))
lcd.program_char(2, (0b00011,
0b00110,
0b01100,
0b11000,
0b11000,
0b01100,
0b00110,
0b00011))
lcd.display(0, '\0' * lcd.width)
lcd.display(1, 'Hello world!', JUSTIFY_CENTER)
lcd.display(2, '\1\2' * (lcd.width / 2))
lcd.display(3, '12345678901234567890')

38
src/py/lcd/splash.py Normal file
View File

@@ -0,0 +1,38 @@
#!/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 lcd
if __name__ == "__main__":
screen = lcd.LCD(1, 0x27)
screen.clear()
screen.display(0, 'Buildbotics', lcd.JUSTIFY_CENTER)
screen.display(1, 'Controller', lcd.JUSTIFY_CENTER)
screen.display(3, 'Booting...', lcd.JUSTIFY_CENTER)