From d62aeb27a5b5003f89c366929ce60b93de0eec35 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 6 Sep 2017 17:57:13 -0700 Subject: [PATCH 001/172] Remove unused files (#345) * Delete virtualterminal.py * Delete framebuffer.py --- ev3dev/framebuffer.py | 181 -------------------------------------- ev3dev/virtualterminal.py | 99 --------------------- 2 files changed, 280 deletions(-) delete mode 100644 ev3dev/framebuffer.py delete mode 100644 ev3dev/virtualterminal.py diff --git a/ev3dev/framebuffer.py b/ev3dev/framebuffer.py deleted file mode 100644 index 47ba870..0000000 --- a/ev3dev/framebuffer.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python - -# framebuffer.py -# -# Helper class for handling framebuffers for ev3dev - -# The MIT License (MIT) -# -# Copyright (c) 2016 David Lechner -# -# 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 ctypes import Structure, c_char, c_ulong, c_uint16, c_uint32 -from enum import Enum -from fcntl import ioctl -from collections import namedtuple - -class FrameBuffer(object): - - # ioctls - _FBIOGET_VSCREENINFO = 0x4600 - _FBIOGET_FSCREENINFO = 0x4602 - _FBIOGET_CON2FBMAP = 0x460F - - class Type(Enum): - PACKED_PIXELS = 0 # Packed Pixels - PLANES = 1 # Non interleaved planes - INTERLEAVED_PLANES = 2 # Interleaved planes - TEXT = 3 # Text/attributes - VGA_PLANES = 4 # EGA/VGA planes - FOURCC = 5 # Type identified by a V4L2 FOURCC - - class Visual(Enum): - MONO01 = 0 # Monochrome 1=Black 0=White - MONO10 = 1 # Monochrome 1=White 0=Black - TRUECOLOR = 2 # True color - PSEUDOCOLOR = 3 # Pseudo color (like atari) - DIRECTCOLOR = 4 # Direct color - STATIC_PSEUDOCOLOR = 5 # Pseudo color readonly - FOURCC = 6 # Visual identified by a V4L2 FOURCC - - class _FixedScreenInfo(Structure): - _fields_ = [ - ('id', c_char * 16), # identification string eg "TT Builtin" - ('smem_start', c_ulong), # Start of frame buffer mem (physical address) - ('smem_len', c_uint32), # Length of frame buffer mem - ('type', c_uint32), # see FB_TYPE_* - ('type_aux', c_uint32), # Interleave for interleaved Planes - ('visual', c_uint32), # see FB_VISUAL_* - ('xpanstep', c_uint16), # zero if no hardware panning - ('ypanstep', c_uint16), # zero if no hardware panning - ('ywrapstep', c_uint16), # zero if no hardware ywrap - ('line_length', c_uint32), # length of a line in bytes - ('mmio_start', c_ulong), # Start of Memory Mapped I/O (physical address) - ('mmio_len', c_uint32), # Length of Memory Mapped I/O - ('accel', c_uint32), # Indicate to driver which specific chip/card we have - ('capabilities', c_uint16), # see FB_CAP_* - ('reserved', c_uint16 * 2), # Reserved for future compatibility - ] - - class _VariableScreenInfo(Structure): - - class _Bitfield(Structure): - _fields_ = [ - ('offset', c_uint32), # beginning of bitfield - ('length', c_uint32), # length of bitfield - ('msb_right', c_uint32), # != 0 : Most significant bit is right - ] - - _fields_ = [ - ('xres', c_uint32), # visible resolution - ('yres', c_uint32), - ('xres_virtual', c_uint32), # virtual resolution - ('yres_virtual', c_uint32), - ('xoffset', c_uint32), # offset from virtual to visible - ('yoffset', c_uint32), # resolution - ('bits_per_pixel', c_uint32), # guess what - ('grayscale', c_uint32), # 0 = color, 1 = grayscale, >1 = FOURCC - ('red', _Bitfield), # bitfield in fb mem if true color, - ('green', _Bitfield), # else only length is significant - ('blue', _Bitfield), - ('transp', _Bitfield), # transparency - ('nonstd', c_uint32), # != 0 Non standard pixel format - ('activate', c_uint32), # see FB_ACTIVATE_* - ('height', c_uint32), # height of picture in mm - ('width', c_uint32), # width of picture in mm - ('accel_flags', c_uint32), # (OBSOLETE) see fb_info.flags - # Timing: All values, in pixclocks, except pixclock (of course) - ('pixclock', c_uint32), # pixel clock in ps (pico seconds) - ('left_margin', c_uint32), # time from sync to picture - ('right_margin', c_uint32), # time from picture to sync - ('upper_margin', c_uint32), # time from sync to picture - ('lower_margin', c_uint32), - ('hsync_len', c_uint32), # length of horizontal sync - ('vsync_len', c_uint32), # length of vertical sync - ('sync', c_uint32), # see FB_SYNC_* - ('vmode', c_uint32), # see FB_VMODE_* - ('rotate', c_uint32), # angle we rotate counter clockwise - ('colorspace', c_uint32), # colorspace for FOURCC-based modes - ('reserved', c_uint32 * 4), # Reserved for future compatibility - ] - - class _Console2FrameBufferMap(Structure): - _fields_ = [ - ('console', c_uint32), - ('framebuffer', c_uint32), - ] - - def __init__(self, device='/dev/fb0'): - self._fd = open(device, mode='r+b', buffering=0) - self._fixed_info = self._FixedScreenInfo() - ioctl(self._fd, self._FBIOGET_FSCREENINFO, self._fixed_info) - self._variable_info = self._VariableScreenInfo() - ioctl(self._fd, self._FBIOGET_VSCREENINFO, self._variable_info) - - def close(self): - self._fd.close() - - def clear(self): - self._fd.seek(0) - self._fd.write(b'\0' * self._fixed_info.smem_len) - - def write_raw(self, data): - self._fd.seek(0) - self._fd.write(data) - - @staticmethod - def get_fb_for_console(console): - with open('/dev/fb0', mode='r+b') as fd: - m = FrameBuffer._Console2FrameBufferMap() - m.console = console - ioctl(fd, FrameBuffer._FBIOGET_CON2FBMAP, m) - return FrameBuffer('/dev/fb{}'.format(m.framebuffer)) - - @property - def type(self): - return self.Type(self._fixed_info.type) - - @property - def visual(self): - return self.Visual(self._fixed_info.visual) - - @property - def line_length(self): - return self._fixed_info.line_length - - @property - def resolution(self): - """Visible resolution""" - Resolution = namedtuple('Resolution', 'x y') - return Resolution(self._variable_info.xres, self._variable_info.yres) - - @property - def bits_per_pixel(self): - return self._variable_info.bits_per_pixel - - @property - def grayscale(self): - return self._variable_info.grayscale - - @property - def size(self): - """Size of picture in mm""" - Size = namedtuple('Size', 'width height') - return Size(self._variable_info.width, self._variable_info.height) diff --git a/ev3dev/virtualterminal.py b/ev3dev/virtualterminal.py deleted file mode 100644 index 34fe6aa..0000000 --- a/ev3dev/virtualterminal.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python - -# virtualterminal.py -# -# Helper class for handling framebuffers for ev3dev - -# The MIT License (MIT) -# -# Copyright (c) 2016 David Lechner -# -# 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 ctypes import Structure, c_char, c_short, c_ushort, c_uint -from enum import Enum -from fcntl import ioctl - -class VirtualTerminal(object): - - # ioctls - _VT_OPENQRY = 0x5600 # find next available vt - _VT_GETMODE = 0x5601 # get mode of active vt - _VT_SETMODE = 0x5602 # set mode of active vt - _VT_GETSTATE = 0x5603 # get global vt state info - _VT_SENDSIG = 0x5604 # signal to send to bitmask of vts - _VT_RELDISP = 0x5605 # release display - _VT_ACTIVATE = 0x5606 # make vt active - _VT_WAITACTIVE = 0x5607 # wait for vt active - _VT_DISALLOCATE = 0x5608 # free memory associated to vt - _VT_SETACTIVATE = 0x560F # Activate and set the mode of a console - _KDSETMODE = 0x4B3A # set text/graphics mode - - class _VtMode(Structure): - _fields_ = [ - ('mode', c_char), # vt mode - ('waitv', c_char), # if set, hang on writes if not active - ('relsig', c_short), # signal to raise on release request - ('acqsig', c_short), # signal to raise on acquisition - ('frsig', c_short), # unused (set to 0) - ] - - class VtMode(Enum): - AUTO = 0 - PROCESS = 1 - ACKACQ = 2 - - class _VtState(Structure): - _fields_ = [ - ('v_active', c_ushort), # active vt - ('v_signal', c_ushort), # signal to send - ('v_state', c_ushort), # vt bitmask - ] - - class KdMode(Enum): - TEXT = 0x00 - GRAPHICS = 0x01 - TEXT0 = 0x02 # obsolete - TEXT1 = 0x03 # obsolete - - def __init__(self): - self._fd = open('/dev/tty', 'r') - - def close(self): - self._fd.close() - - def get_next_available(self): - n = c_uint() - ioctl(self._fd, self._VT_OPENQRY, n) - return n.value - - def activate(self, num): - ioctl(self._fd, self._VT_ACTIVATE, num) - ioctl(self._fd, self._VT_WAITACTIVE, num) - - def get_active(self): - state = VirtualTerminal._VtState() - ioctl(self._fd, self._VT_GETSTATE, state) - return state.v_active - - def set_graphics_mode(self): - ioctl(self._fd, self._KDSETMODE, self.KdMode.GRAPHICS.value) - - def set_text_mode(self): - ioctl(self._fd, self._KDSETMODE, self.KdMode.TEXT.value) From af4c11d2bc83add9c10c11a72264ef5dbddf1323 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 6 Sep 2017 23:15:44 -0400 Subject: [PATCH 002/172] Remove hard coded outA on port4 assumption (#337) --- tests/motor/ev3dev_port_logger.py | 2 +- tests/motor/motor_motion_unittest.py | 2 +- tests/motor/motor_param_unittest.py | 31 +++++++++++++++++++----- tests/motor/motor_run_direct_unittest.py | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) mode change 100644 => 100755 tests/motor/ev3dev_port_logger.py mode change 100644 => 100755 tests/motor/motor_motion_unittest.py mode change 100644 => 100755 tests/motor/motor_param_unittest.py diff --git a/tests/motor/ev3dev_port_logger.py b/tests/motor/ev3dev_port_logger.py old mode 100644 new mode 100755 index 1ed6deb..9a5c637 --- a/tests/motor/ev3dev_port_logger.py +++ b/tests/motor/ev3dev_port_logger.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import json import argparse diff --git a/tests/motor/motor_motion_unittest.py b/tests/motor/motor_motion_unittest.py old mode 100644 new mode 100755 index 52fb3c4..e2bb152 --- a/tests/motor/motor_motion_unittest.py +++ b/tests/motor/motor_motion_unittest.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # Based on the parameterized test case technique described here: # diff --git a/tests/motor/motor_param_unittest.py b/tests/motor/motor_param_unittest.py old mode 100644 new mode 100755 index 6b58a4a..4c2da56 --- a/tests/motor/motor_param_unittest.py +++ b/tests/motor/motor_param_unittest.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # Based on the parameterized test case technique described here: # @@ -10,6 +10,7 @@ import ev3dev.ev3 as ev3 import parameterizedtestcase as ptc +import os from motor_info import motor_info @@ -495,12 +496,13 @@ def test_time_sp_after_reset(self): class TestTachoMotorDummy(ptc.ParameterizedTestCase): def test_dummy_no_message(self): + try: self.assertEqual(self._param['motor'].speed_d, 100, "Some clever error message {0}".format(self._param['motor'].speed_d)) - except: + except Exception: # Remove traceback info as we don't need it unittest_exception = sys.exc_info() - raise unittest_exception[0], unittest_exception[1], unittest_exception[2].tb_next + print("%s,%s,%s" % (unittest_exception[0], unittest_exception[1], unittest_exception[2].tb_next)) # Add all the tests to the suite - some tests apply only to certain drivers! @@ -535,8 +537,26 @@ def AddTachoMotorParameterTestsToSuite( suite, driver_name, params ): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDummy, param=params)) if __name__ == '__main__': - for k in motor_info: - file = open('/sys/class/lego-port/port4/set_device', 'w') + port_for_outA = None + + # which port has outA? + for filename in os.listdir('/sys/class/lego-port/'): + address_filename = '/sys/class/lego-port/%s/address' % filename + + if os.path.exists(address_filename): + with open(address_filename, 'r') as fh: + for line in fh: + if line.rstrip().endswith(':outA'): + port_for_outA = filename + break + + if port_for_outA: + break + else: + raise Exception("Could not find port /sys/class/lego-port/ for outA") + + for k in motor_info: + file = open('/sys/class/lego-port/%s/set_device' % port_for_outA, 'w') file.write('{0}\n'.format(k)) file.close() time.sleep(0.5) @@ -548,4 +568,3 @@ def AddTachoMotorParameterTestsToSuite( suite, driver_name, params ): AddTachoMotorParameterTestsToSuite( suite, k, params ) print( '-------------------- TESTING {0} --------------'.format(k)) unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) - diff --git a/tests/motor/motor_run_direct_unittest.py b/tests/motor/motor_run_direct_unittest.py index c0d63de..b3347f9 100755 --- a/tests/motor/motor_run_direct_unittest.py +++ b/tests/motor/motor_run_direct_unittest.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # Based on the parameterized test case technique described here: # From b93b22a44400b90e03397b0c5c9449abe6c8cf9e Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 6 Sep 2017 23:19:08 -0400 Subject: [PATCH 003/172] Added class MotorSet (#336) * Added class MotorSet * Added MotorPair set_speed_ratio() and set_speed_percentage() * Changed set_speed_ratio to set_speed_steering * Change set_speed_steering power variable to speed * Updated doc string for MotorPair set_ functions --- ev3dev/GyroBalancer.py | 6 +- ev3dev/helper.py | 317 +++++++++++++++++++++++++++++++++++------ ev3dev/webserver.py | 6 +- 3 files changed, 276 insertions(+), 53 deletions(-) diff --git a/ev3dev/GyroBalancer.py b/ev3dev/GyroBalancer.py index 4e97ce0..df28e50 100644 --- a/ev3dev/GyroBalancer.py +++ b/ev3dev/GyroBalancer.py @@ -29,7 +29,7 @@ import time from collections import deque from ev3dev.auto import * -from ev3dev.helper import Tank +from ev3dev.helper import LargeMotorPair log = logging.getLogger(__name__) @@ -63,7 +63,7 @@ def SetDuty(motorDutyFileHandle, duty): FastWrite(motorDutyFileHandle, duty) -class GyroBalancer(Tank): +class GyroBalancer(LargeMotorPair): """ Base class for a robot that stands on two wheels and uses a gyro sensor to keep its balance. @@ -77,7 +77,7 @@ def __init__(self, gainMotorAngleErrorAccumulated, # For every radian x s of accumulated motor angle, apply this amount of duty cycle left_motor=OUTPUT_D, right_motor=OUTPUT_A): - Tank.__init__(self, left_motor, right_motor) + LargeMotorPair.__init__(self, left_motor, right_motor) # magic numbers self.gainGyroAngle = gainGyroAngle diff --git a/ev3dev/helper.py b/ev3dev/helper.py index c44f3bc..7de2fde 100644 --- a/ev3dev/helper.py +++ b/ev3dev/helper.py @@ -7,6 +7,7 @@ import sys import time import ev3dev.auto +from collections import OrderedDict from ev3dev.auto import (RemoteControl, list_motors, INPUT_1, INPUT_2, INPUT_3, INPUT_4, OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D) @@ -146,6 +147,253 @@ class MediumMotor(ev3dev.auto.MediumMotor, MotorMixin): pass +class MotorSet(object): + """ + motor_specs is a dictionary such as + { + OUTPUT_A : LargeMotor, + OUTPUT_B : MediumMotor, + } + """ + + def __init__(self, motor_specs, desc=None): + + for motor_port in motor_specs.keys(): + if motor_port not in OUTPUTS: + log.error("%s in an invalid motor, choices are %s" % (motor_port, ', '.join(OUTPUTS))) + sys.exit(1) + + self.motors = OrderedDict() + for motor_port in sorted(motor_specs.keys()): + motor_class = motor_specs[motor_port] + self.motors[motor_port] = motor_class(motor_port) + + self.desc = desc + self.verify_connected() + + def __str__(self): + + if self.desc: + return self.desc + else: + return self.__class__.__name__ + + def verify_connected(self): + for motor in self.motors.values(): + if not motor.connected: + log.error("%s: %s is not connected" % (self, motor)) + sys.exit(1) + + def set_args(self, **kwargs): + motors = kwargs.get('motors', self.motors.values()) + + for motor in motors: + for key in kwargs: + if key != 'motors': + try: + setattr(motor, key, kwargs[key]) + except AttributeError as e: + log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) + raise e + + def set_polarity(self, polarity, motors=None): + valid_choices = ('normal', 'inversed') + assert polarity in valid_choices,\ + "%s is an invalid polarity choice, must be %s" % (polarity, ', '.join(valid_choices)) + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.polarity = polarity + + def _run_command(self, **kwargs): + motors = kwargs.get('motors', self.motors.values()) + + for motor in motors: + for key in kwargs: + if key not in ('motors', 'commands'): + log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) + setattr(motor, key, kwargs[key]) + + for motor in motors: + motor.command = kwargs['command'] + log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) + + def run_forever(self, **kwargs): + kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_FOREVER + self._run_command(**kwargs) + + def run_to_abs_pos(self, **kwargs): + kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_TO_ABS_POS + self._run_command(**kwargs) + + def run_to_rel_pos(self, **kwargs): + kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_TO_REL_POS + self._run_command(**kwargs) + + def run_timed(self, **kwargs): + kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_TIMED + self._run_command(**kwargs) + + def run_direct(self, **kwargs): + kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_DIRECT + self._run_command(**kwargs) + + def reset(self, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.reset() + + def stop(self, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.stop() + + def _is_state(self, motors, state): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + if state not in motor.state: + return False + + return True + + @property + def is_running(self, motors=None): + return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_RUNNING) + + @property + def is_ramping(self, motors=None): + return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_RAMPING) + + @property + def is_holding(self, motors=None): + return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_HOLDING) + + @property + def is_overloaded(self, motors=None): + return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_OVERLOADED) + + @property + def is_stalled(self): + return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_STALLED) + + def wait(self, cond, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait(cond, timeout) + + def wait_until_not_moving(self, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_until_not_moving(timeout) + + def wait_until(self, s, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_until(s, timeout) + + def wait_while(self, s, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_while(s, timeout) + + +class MotorPair(MotorSet): + + def __init__(self, motor_specs, desc=None): + MotorSet.__init__(self, motor_specs, desc) + (self.left_motor, self.right_motor) = self.motors.values() + self.max_speed = self.left_motor.max_speed + + def set_speed_steering(self, direction, speed_outer_motor=100): + """ + Set the speed_sp for each motor in a pair to achieve the specified + steering. Note that calling this function alone will not make the + motors move, it only sets the speed. A run_* function must be called + afterwards to make the motors move. + + direction [-100, 100]: + * -100 means turn left as fast as possible, + * 0 means drive in a straight line, and + * 100 means turn right as fast as possible. + + speed_outer_motor: + The speed that should be applied to the outmost motor (the one + rotating faster). The speed of the other motor will be computed + automatically. + """ + + assert direction >= -100 and direction <= 100,\ + "%s is an invalid direction, must be between -100 and 100 (inclusive)" % direction + + left_speed = speed_outer_motor + right_speed = speed_outer_motor + speed = (50 - abs(float(direction))) / 50 + + if direction >= 0: + right_speed *= speed + else: + left_speed *= speed + + left_speed = int(left_speed) + right_speed = int(right_speed) + self.left_motor.speed_sp = left_speed + self.right_motor.speed_sp = right_speed + + log.debug("%s: direction %d, %s speed %d, %s speed %d" % + (self, direction, self.left_motor, left_speed, self.right_motor, right_speed)) + + def set_speed_percentage(self, left_motor_percentage, right_motor_percentage): + """ + Set the speeds of the left_motor vs right_motor by percentage of + their maximum speed. The minimum valid percentage is -100, the + maximum is 100. + + Note that calling this function alone will not make the motors move, it + only sets the speed. A run_* function must be called afterwards to make + the motors move. + """ + + assert left_motor_percentage >= -100 and left_motor_percentage <= 100,\ + "%s is an invalid percentage, must be between -100 and 100 (inclusive)" % left_motor_percentage + + assert right_motor_percentage >= -100 and right_motor_percentage <= 100,\ + "%s is an invalid percentage, must be between -100 and 100 (inclusive)" % right_motor_percentage + + # Convert left_motor_percentage and right_motor_percentage to fractions + left_motor_percentage = left_motor_percentage / 100.0 + right_motor_percentage = right_motor_percentage / 100.0 + + self.left_motor.speed_sp = int(self.max_speed * left_motor_percentage) + self.right_motor.speed_sp = int(self.max_speed * right_motor_percentage) + + +class LargeMotorPair(MotorPair): + + def __init__(self, left_motor, right_motor, desc=None): + motor_specs = { + left_motor : LargeMotor, + right_motor : LargeMotor, + } + MotorPair.__init__(self, motor_specs, desc) + + +class MediumMotorPair(MotorPair): + + def __init__(self, left_motor, right_motor, desc=None): + motor_specs = { + left_motor : MediumMotor, + right_motor : MediumMotor, + } + MotorPair.__init__(self, motor_specs, desc) + + class ColorSensorMixin(object): def rgb(self): @@ -167,57 +415,34 @@ class ColorSensor(ev3dev.auto.ColorSensor, ColorSensorMixin): # ============ # Tank classes # ============ -class Tank(object): - - def __init__(self, left_motor, right_motor, polarity='normal', name='Tank'): - - for motor in (left_motor, right_motor): - if motor not in OUTPUTS: - log.error("%s in an invalid motor, choices are %s" % (motor, ', '.join(OUTPUTS))) - sys.exit(1) - - self.left_motor = LargeMotor(left_motor) - self.right_motor = LargeMotor(right_motor) - - for x in (self.left_motor, self.right_motor): - if not x.connected: - log.error("%s is not connected" % x) - sys.exit(1) +class Tank(LargeMotorPair): + """ + This class is here for backwards compatibility for anyone who was using + this library before the days of LargeMotorPair. We wrote the Tank class + first, then LargeMotorPair. All future work will be in the MotorSet, + MotorPair, etc classes + """ - self.left_motor.reset() - self.right_motor.reset() - self.speed_sp = 400 - self.left_motor.speed_sp = self.speed_sp - self.right_motor.speed_sp = self.speed_sp + def __init__(self, left_motor_port, right_motor_port, polarity='normal', name='Tank'): + LargeMotorPair.__init__(self, left_motor_port, right_motor_port, name) self.set_polarity(polarity) - self.name = name - - def __str__(self): - return self.name - - def set_polarity(self, polarity): - valid_choices = ('normal', 'inversed') - assert polarity in valid_choices,\ - "%s is an invalid polarity choice, must be %s" % (polarity, ', '.join(valid_choices)) + self.speed_sp = 400 - self.left_motor.polarity = polarity - self.right_motor.polarity = polarity +class RemoteControlledTank(LargeMotorPair): -class RemoteControlledTank(Tank): + def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed=400): + LargeMotorPair.__init__(self, left_motor_port, right_motor_port) + self.set_polarity(polarity) - def __init__(self, left_motor, right_motor, polarity='inversed'): - Tank.__init__(self, left_motor, right_motor, polarity) + left_motor = self.motors[left_motor_port] + right_motor = self.motors[right_motor_port] + self.speed_sp = speed self.remote = RemoteControl(channel=1) - - if not self.remote.connected: - log.error("%s is not connected" % self.remote) - sys.exit(1) - - self.remote.on_red_up = self.make_move(self.left_motor, self.speed_sp) - self.remote.on_red_down = self.make_move(self.left_motor, self.speed_sp * -1) - self.remote.on_blue_up = self.make_move(self.right_motor, self.speed_sp) - self.remote.on_blue_down = self.make_move(self.right_motor, self.speed_sp * -1) + self.remote.on_red_up = self.make_move(left_motor, self.speed_sp) + self.remote.on_red_down = self.make_move(left_motor, self.speed_sp* -1) + self.remote.on_blue_up = self.make_move(right_motor, self.speed_sp) + self.remote.on_blue_down = self.make_move(right_motor, self.speed_sp * -1) def make_move(self, motor, dc_sp): def move(state): @@ -237,9 +462,7 @@ def main(self): # Exit cleanly so that all motors are stopped except (KeyboardInterrupt, Exception) as e: log.exception(e) - - for motor in list_motors(): - motor.stop() + self.stop() # ===================== diff --git a/ev3dev/webserver.py b/ev3dev/webserver.py index ec0c5a3..f421981 100644 --- a/ev3dev/webserver.py +++ b/ev3dev/webserver.py @@ -7,7 +7,7 @@ import sys import time import ev3dev.auto -from ev3dev.helper import Tank, list_motors +from ev3dev.helper import LargeMotorPair, list_motors from http.server import BaseHTTPRequestHandler, HTTPServer from time import sleep @@ -576,13 +576,13 @@ def run(self): motor.stop() -class WebControlledTank(Tank): +class WebControlledTank(LargeMotorPair): """ A tank that is controlled via a web browser """ def __init__(self, left_motor, right_motor, polarity='normal', port_number=8000): - Tank.__init__(self, left_motor, right_motor, polarity) + LargeMotorPair.__init__(self, left_motor, right_motor, polarity) self.www = RobotWebServer(self, TankWebHandler, port_number) def main(self): From b8d780e6f94a9cfc49729401ad5adf20c9a2dd83 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 6 Sep 2017 23:49:43 -0400 Subject: [PATCH 004/172] Exit cleanly if kociemba returns an ERROR (#350) --- demo/MINDCUB3R/rubiks.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/demo/MINDCUB3R/rubiks.py b/demo/MINDCUB3R/rubiks.py index 0e07627..c4088ad 100755 --- a/demo/MINDCUB3R/rubiks.py +++ b/demo/MINDCUB3R/rubiks.py @@ -517,7 +517,15 @@ def resolve(self): if rub.shutdown: return - output = check_output(['kociemba', ''.join(map(str, self.cube_kociemba))]).decode('ascii') + cmd = ['kociemba', ''.join(map(str, self.cube_kociemba))] + output = check_output(cmd).decode('ascii') + + if 'ERROR' in output: + msg = "'%s' returned the following error\n%s\n" % (' '.join(cmd), output) + log.error(msg) + print(msg) + sys.exit(1) + actions = output.strip().split() self.run_kociemba_actions(actions) self.cube_done() From 22d493e9e4fa0ef8addcb032d452704898857700 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 10 Sep 2017 05:35:03 -0700 Subject: [PATCH 005/172] Throw friendly errors (#342) * Untested implementation of friendly errors * Fix syntax * Use specific exception type * Fix and test error implementation --- ev3dev/core.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 8747923..92e1972 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -49,6 +49,7 @@ import shlex import stat import time +import errno from os.path import abspath from struct import pack, unpack from subprocess import Popen, check_output, PIPE @@ -204,12 +205,31 @@ def _set_attribute(self, attribute, name, value): attribute = self._attribute_file_open( name ) else: attribute.seek(0) - attribute.write(value.encode()) - attribute.flush() + + try: + attribute.write(value.encode()) + attribute.flush() + except Exception as ex: + self._raise_friendly_access_error(ex, name) return attribute else: raise Exception('Device is not connected') + def _raise_friendly_access_error(self, driver_error, attribute): + if not isinstance(driver_error, OSError): + raise driver_error + + if driver_error.errno == errno.EINVAL: + if attribute == "speed_sp": + try: + max_speed = self.max_speed + except (AttributeError, Exception): + raise ValueError("The given speed value was out of range") from driver_error + else: + raise ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)) from driver_error + raise ValueError("One or more arguments were out of range or invalid") from driver_error + raise driver_error + def get_attr_int(self, attribute, name): attribute, value = self._get_attribute(attribute, name) return attribute, int(value) From 06ae05faf27fc5d33dc5d26742a11ed2bc82e937 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 20 Sep 2017 19:17:59 -0400 Subject: [PATCH 006/172] Stretch fixes (#366) * list the device name in 'Device not connected' Exception * api_test.py must use python3 * Update README for tests * Update to latest ev3dev-lang to pick up stretch fixes * Update tests/README * Update tests/README --- ev3dev-lang | 2 +- ev3dev/core.py | 9 +++++++-- ev3dev/ev3.py | 20 ++++++++++---------- tests/README.md | 29 ++++++++++++++++++++++++----- tests/api_tests.py | 2 +- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/ev3dev-lang b/ev3dev-lang index 008804f..0169750 160000 --- a/ev3dev-lang +++ b/ev3dev-lang @@ -1 +1 @@ -Subproject commit 008804f5c28acd225f1b43057be2060d9965b2c1 +Subproject commit 0169750bf9aeefac423c38929287b4ad9ef7abd4 diff --git a/ev3dev/core.py b/ev3dev/core.py index 92e1972..b8b28eb 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -42,6 +42,7 @@ import fnmatch import numbers import array +import logging import mmap import ctypes import re @@ -54,6 +55,8 @@ from struct import pack, unpack from subprocess import Popen, check_output, PIPE +log = logging.getLogger(__name__) + try: # This is a linux-specific module. # It is required by the Button() class, but failure to import it may be @@ -196,7 +199,8 @@ def _get_attribute(self, attribute, name): attribute.seek(0) return attribute, attribute.read().strip().decode() else: - raise Exception('Device is not connected') + log.info("%s: path %s, attribute %s" % (self, self._path, name)) + raise Exception("%s is not connected" % self) def _set_attribute(self, attribute, name, value): """Device attribute setter""" @@ -213,7 +217,8 @@ def _set_attribute(self, attribute, name, value): self._raise_friendly_access_error(ex, name) return attribute else: - raise Exception('Device is not connected') + log.info("%s: path %s, attribute %s" % (self, self._path, name)) + raise Exception("%s is not connected" % self) def _raise_friendly_access_error(self, driver_error, attribute): if not isinstance(driver_error, OSError): diff --git a/ev3dev/ev3.py b/ev3dev/ev3.py index 725f713..c9ec23f 100644 --- a/ev3dev/ev3.py +++ b/ev3dev/ev3.py @@ -47,10 +47,10 @@ class Leds(object): # ~autogen led-colors platforms.ev3.led>currentClass - red_left = Led(name_pattern='ev3:left:red:ev3dev') - red_right = Led(name_pattern='ev3:right:red:ev3dev') - green_left = Led(name_pattern='ev3:left:green:ev3dev') - green_right = Led(name_pattern='ev3:right:green:ev3dev') + red_left = Led(name_pattern='led0:red:brick-status') + red_right = Led(name_pattern='led1:red:brick-status') + green_left = Led(name_pattern='led0:green:brick-status') + green_right = Led(name_pattern='led1:green:brick-status') LEFT = ( red_left, green_left, ) RIGHT = ( red_right, green_right, ) @@ -165,12 +165,12 @@ def on_backspace(state): _buttons = { - 'up': {'name': '/dev/input/by-path/platform-gpio-keys.0-event', 'value': 103}, - 'down': {'name': '/dev/input/by-path/platform-gpio-keys.0-event', 'value': 108}, - 'left': {'name': '/dev/input/by-path/platform-gpio-keys.0-event', 'value': 105}, - 'right': {'name': '/dev/input/by-path/platform-gpio-keys.0-event', 'value': 106}, - 'enter': {'name': '/dev/input/by-path/platform-gpio-keys.0-event', 'value': 28}, - 'backspace': {'name': '/dev/input/by-path/platform-gpio-keys.0-event', 'value': 14}, + 'up': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 103}, + 'down': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 108}, + 'left': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 105}, + 'right': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 106}, + 'enter': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 28}, + 'backspace': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 14}, } @property diff --git a/tests/README.md b/tests/README.md index b8f0017..0c7adf4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,8 +1,27 @@ +# fake-sys directory +The tests require the fake-sys directory which comes from +https://github.com/ddemidov/ev3dev-lang-fake-sys + +If you have already cloned the ev3dev-lang-python repo but do not have the +`fake-sys` directory use `git submodule init` to get it. If you have not +already cloned the ev3dev-lang-python repo you can use the `--recursive` option +when you git clone. Example: + +``` +$ git clone --recursive https://github.com/rhempel/ev3dev-lang-python.git +``` + +# Running Tests +To run the tests do: +``` +$ ./api_tests.py +``` + +# Misc Commands used to copy the /sys/class node: -```sh -node=lego-sensor/sensor0 -mkdir -p ./${node} -# Copy contents of special files, do not follow symlinks: -cp -P --copy-contents -r /sys/class/${node}/* ./${node}/ +``` +$ node=lego-sensor/sensor0 +$ mkdir -p ./${node} +$ cp -P --copy-contents -r /sys/class/${node}/* ./${node}/ ``` diff --git a/tests/api_tests.py b/tests/api_tests.py index 6edb48e..7476352 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import unittest, sys, os FAKE_SYS = os.path.join(os.path.dirname(__file__), 'fake-sys') From 7ca8d03d51ab0304c5baf427ea65276630560a11 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 21 Sep 2017 10:29:57 -0400 Subject: [PATCH 007/172] Remove ev3dev-lang and autogen (#368) * remove ev3dev-lang and autogen * Removed the templates directory --- .gitmodules | 3 - docs/sensors.rst | 4 +- ev3dev-lang | 1 - ev3dev/brickpi.py | 6 - ev3dev/core.py | 324 +++++--------------- ev3dev/ev3.py | 10 - spec_version.py | 2 - templates/autogen-header.liquid | 1 - templates/button-class.liquid | 9 - templates/button-property.liquid | 22 -- templates/doc-special-sensor-classes.liquid | 11 - templates/generic-class-slots.liquid | 4 - templates/generic-class.liquid | 52 ---- templates/generic-get-set.liquid | 38 --- templates/generic-helper-function.liquid | 16 - templates/generic-property-value.liquid | 10 - templates/led-colors.liquid | 60 ---- templates/motor_commands.liquid | 17 - templates/motor_states.liquid | 13 - templates/remote-control.liquid | 29 -- templates/spec_version.liquid | 6 - templates/special-sensors.liquid | 93 ------ 22 files changed, 73 insertions(+), 658 deletions(-) delete mode 160000 ev3dev-lang delete mode 100644 templates/autogen-header.liquid delete mode 100644 templates/button-class.liquid delete mode 100644 templates/button-property.liquid delete mode 100644 templates/doc-special-sensor-classes.liquid delete mode 100644 templates/generic-class-slots.liquid delete mode 100644 templates/generic-class.liquid delete mode 100644 templates/generic-get-set.liquid delete mode 100644 templates/generic-helper-function.liquid delete mode 100644 templates/generic-property-value.liquid delete mode 100644 templates/led-colors.liquid delete mode 100644 templates/motor_commands.liquid delete mode 100644 templates/motor_states.liquid delete mode 100644 templates/remote-control.liquid delete mode 100644 templates/spec_version.liquid delete mode 100644 templates/special-sensors.liquid diff --git a/.gitmodules b/.gitmodules index ba05cc2..87dee32 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "ev3dev-lang"] - path = ev3dev-lang - url = https://github.com/ev3dev/ev3dev-lang.git [submodule "tests/fake-sys"] path = tests/fake-sys url = https://github.com/rhempel/ev3dev-lang-fake-sys.git diff --git a/docs/sensors.rst b/docs/sensors.rst index a254b90..dbc7a17 100644 --- a/docs/sensors.rst +++ b/docs/sensors.rst @@ -18,7 +18,7 @@ The classes derive from :py:class:`Sensor` and provide helper functions specific to the corresponding sensor type. Each of the functions makes sure the sensor is in the required mode and then returns the specified value. -.. ~autogen doc-special-sensor-classes +.. Touch Sensor ######################## @@ -84,5 +84,5 @@ Light Sensor -.. ~autogen +.. diff --git a/ev3dev-lang b/ev3dev-lang deleted file mode 160000 index 0169750..0000000 --- a/ev3dev-lang +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0169750bf9aeefac423c38929287b4ad9ef7abd4 diff --git a/ev3dev/brickpi.py b/ev3dev/brickpi.py index 1df0483..a86c568 100644 --- a/ev3dev/brickpi.py +++ b/ev3dev/brickpi.py @@ -44,9 +44,6 @@ class Leds(object): """ The BrickPi LEDs. """ - -# ~autogen led-colors platforms.brickpi.led>currentClass - blue_led1 = Led(name_pattern='brickpi:led1:blue:ev3dev') blue_led2 = Led(name_pattern='brickpi:led2:blue:ev3dev') @@ -90,6 +87,3 @@ def all_off(): """ Leds.blue_led1.brightness = 0 Leds.blue_led2.brightness = 0 - - -# ~autogen diff --git a/ev3dev/core.py b/ev3dev/core.py index b8b28eb..c2a1205 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -23,20 +23,11 @@ # THE SOFTWARE. # ----------------------------------------------------------------------------- -# ~autogen autogen-header -# Sections of the following code were auto-generated based on spec v1.2.0 - -# ~autogen - -# ----------------------------------------------------------------------------- - import sys if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') -# ----------------------------------------------------------------------------- - import os import io import fnmatch @@ -289,7 +280,6 @@ def list_devices(class_name, name_pattern, **kwargs): return (Device(class_name, name, name_exact=True) for name in list_device_names(classpath, name_pattern, **kwargs)) -# ~autogen generic-class classes.motor>currentClass class Motor(Device): @@ -341,14 +331,9 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._stop_action = None self._stop_actions = None self._time_sp = None - -# ~autogen - self._poll = None __slots__ = [ -# ~autogen generic-class-slots classes.motor>currentClass - '_address', '_command', '_commands', @@ -376,13 +361,9 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam '_stop_action', '_stop_actions', '_time_sp', - -# ~autogen '_poll', ] -# ~autogen generic-get-set classes.motor>currentClass - @property def address(self): """ @@ -731,10 +712,6 @@ def time_sp(self): def time_sp(self, value): self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) - -# ~autogen -# ~autogen generic-property-value classes.motor>currentClass - #: Run the motor until another command is sent. COMMAND_RUN_FOREVER = 'run-forever' @@ -808,10 +785,6 @@ def time_sp(self, value): #: will `push back` to maintain its position. STOP_ACTION_HOLD = 'hold' - -# ~autogen -# ~autogen motor_commands classes.motor>currentClass - def run_forever(self, **kwargs): """Run the motor until another command is sent. """ @@ -870,10 +843,6 @@ def reset(self, **kwargs): setattr(self, key, kwargs[key]) self.command = self.COMMAND_RESET - -# ~autogen -# ~autogen motor_states classes.motor>currentClass - @property def is_running(self): """Power is being sent to the motor. @@ -904,9 +873,6 @@ def is_stalled(self): """ return self.STATE_STALLED in self.state - -# ~autogen - def wait(self, cond, timeout=None): """ Blocks until ``cond(self.state)`` is ``True``. The condition is @@ -999,8 +965,6 @@ def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): return (Motor(name_pattern=name, name_exact=True) for name in list_device_names(class_path, name_pattern, **kwargs)) -# ~autogen generic-class classes.largeMotor>currentClass - class LargeMotor(Motor): """ @@ -1009,21 +973,13 @@ class LargeMotor(Motor): SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(LargeMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-l-motor', 'lego-nxt-motor'], **kwargs) -# ~autogen - __slots__ = [ -# ~autogen generic-class-slots classes.largeMotor>currentClass - - -# ~autogen - ] -# ~autogen generic-class classes.mediumMotor>currentClass - class MediumMotor(Motor): """ @@ -1032,21 +988,13 @@ class MediumMotor(Motor): SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(MediumMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-m-motor'], **kwargs) -# ~autogen - __slots__ = [ -# ~autogen generic-class-slots classes.mediumMotor>currentClass - - -# ~autogen - ] -# ~autogen generic-class classes.actuonix50Motor>currentClass - class ActuonixL1250Motor(Motor): """ @@ -1055,21 +1003,13 @@ class ActuonixL1250Motor(Motor): SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = 'linear*' + __slots__ = [] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(ActuonixL1250Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-50'], **kwargs) -# ~autogen - __slots__ = [ -# ~autogen generic-class-slots classes.actuonix50Motor>currentClass - - -# ~autogen - ] -# ~autogen generic-class classes.actuonix100Motor>currentClass - class ActuonixL12100Motor(Motor): """ @@ -1078,21 +1018,13 @@ class ActuonixL12100Motor(Motor): SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = 'linear*' + __slots__ = [] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(ActuonixL12100Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-100'], **kwargs) -# ~autogen - __slots__ = [ -# ~autogen generic-class-slots classes.actuonix100Motor>currentClass - - -# ~autogen - ] -# ~autogen generic-class classes.dcMotor>currentClass - class DcMotor(Device): """ @@ -1103,6 +1035,21 @@ class DcMotor(Device): SYSTEM_CLASS_NAME = 'dc-motor' SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' + __slots__ = [ + '_address', + '_command', + '_commands', + '_driver_name', + '_duty_cycle', + '_duty_cycle_sp', + '_polarity', + '_ramp_down_sp', + '_ramp_up_sp', + '_state', + '_stop_action', + '_stop_actions', + '_time_sp', + ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -1124,30 +1071,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._stop_actions = None self._time_sp = None -# ~autogen - - __slots__ = [ -# ~autogen generic-class-slots classes.dcMotor>currentClass - - '_address', - '_command', - '_commands', - '_driver_name', - '_duty_cycle', - '_duty_cycle_sp', - '_polarity', - '_ramp_down_sp', - '_ramp_up_sp', - '_state', - '_stop_action', - '_stop_actions', - '_time_sp', - -# ~autogen - ] - -# ~autogen generic-get-set classes.dcMotor>currentClass - @property def address(self): """ @@ -1294,10 +1217,6 @@ def time_sp(self): def time_sp(self, value): self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) - -# ~autogen -# ~autogen generic-property-value classes.dcMotor>currentClass - #: Run the motor until another command is sent. COMMAND_RUN_FOREVER = 'run-forever' @@ -1331,10 +1250,6 @@ def time_sp(self, value): #: cause the motor to stop more quickly than coasting. STOP_ACTION_BRAKE = 'brake' - -# ~autogen -# ~autogen motor_commands classes.dcMotor>currentClass - def run_forever(self, **kwargs): """Run the motor until another command is sent. """ @@ -1368,9 +1283,6 @@ def stop(self, **kwargs): self.command = self.COMMAND_STOP -# ~autogen -# ~autogen generic-class classes.servoMotor>currentClass - class ServoMotor(Device): """ @@ -1380,6 +1292,18 @@ class ServoMotor(Device): SYSTEM_CLASS_NAME = 'servo-motor' SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' + __slots__ = [ + '_address', + '_command', + '_driver_name', + '_max_pulse_sp', + '_mid_pulse_sp', + '_min_pulse_sp', + '_polarity', + '_position_sp', + '_rate_sp', + '_state', + ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -1398,27 +1322,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._rate_sp = None self._state = None -# ~autogen - - __slots__ = [ -# ~autogen generic-class-slots classes.servoMotor>currentClass - - '_address', - '_command', - '_driver_name', - '_max_pulse_sp', - '_mid_pulse_sp', - '_min_pulse_sp', - '_polarity', - '_position_sp', - '_rate_sp', - '_state', - -# ~autogen - ] - -# ~autogen generic-get-set classes.servoMotor>currentClass - @property def address(self): """ @@ -1553,10 +1456,6 @@ def state(self): self._state, value = self.get_attr_set(self._state, 'state') return value - -# ~autogen -# ~autogen generic-property-value classes.servoMotor>currentClass - #: Drive servo to the position set in the `position_sp` attribute. COMMAND_RUN = 'run' @@ -1571,10 +1470,6 @@ def state(self): #: cause the motor to rotate counter-clockwise. POLARITY_INVERSED = 'inversed' - -# ~autogen -# ~autogen motor_commands classes.servoMotor>currentClass - def run(self, **kwargs): """Drive servo to the position set in the `position_sp` attribute. """ @@ -1590,9 +1485,6 @@ def float(self, **kwargs): self.command = self.COMMAND_FLOAT -# ~autogen -# ~autogen generic-class classes.sensor>currentClass - class Sensor(Device): """ @@ -1614,6 +1506,22 @@ class Sensor(Device): SYSTEM_CLASS_NAME = 'lego-sensor' SYSTEM_DEVICE_NAME_CONVENTION = 'sensor*' + __slots__ = [ + '_address', + '_command', + '_commands', + '_decimals', + '_driver_name', + '_mode', + '_modes', + '_num_values', + '_units', + '_value', + '_bin_data_format', + '_bin_data_size', + '_bin_data', + '_mode_scale' + ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -1630,9 +1538,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._modes = None self._num_values = None self._units = None - -# ~autogen - self._value = [None,None,None,None,None,None,None,None] self._bin_data_format = None @@ -1640,27 +1545,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._bin_data = None self._mode_scale = {} - __slots__ = [ -# ~autogen generic-class-slots classes.sensor>currentClass - - '_address', - '_command', - '_commands', - '_decimals', - '_driver_name', - '_mode', - '_modes', - '_num_values', - '_units', - -# ~autogen - '_value', - '_bin_data_format', - '_bin_data_size', - '_bin_data', - '_mode_scale' - ] - def _scale(self, mode): """ Returns value scaling coefficient for the given mode. @@ -1673,8 +1557,6 @@ def _scale(self, mode): return scale -# ~autogen generic-get-set classes.sensor>currentClass - @property def address(self): """ @@ -1761,9 +1643,6 @@ def units(self): self._units, value = self.get_attr_string(self._units, 'units') return value - -# ~autogen - def value(self, n=0): """ Returns the value or values measured by the sensor. Check num_values to @@ -1771,11 +1650,7 @@ def value(self, n=0): an error. The values are fixed point numbers, so check decimals to see if you need to divide to get the actual value. """ -# if isinstance(n, numbers.Integral): -# n = '{0:d}'.format(n) -# elif isinstance(n, numbers.Real): if isinstance(n, numbers.Real): -# n = '{0:.0f}'.format(n) n = int(n) elif isinstance(n, str): n = int(n) @@ -1857,8 +1732,6 @@ def list_sensors(name_pattern=Sensor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): for name in list_device_names(class_path, name_pattern, **kwargs)) -# ~autogen generic-class classes.i2cSensor>currentClass - class I2cSensor(Sensor): """ @@ -1875,9 +1748,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._fw_version = None self._poll_ms = None -# ~autogen -# ~autogen generic-get-set classes.i2cSensor>currentClass - @property def fw_version(self): """ @@ -1903,9 +1773,6 @@ def poll_ms(self, value): self._poll_ms = self.set_attr_int(self._poll_ms, 'poll_ms', value) -# ~autogen -# ~autogen special-sensors - class TouchSensor(Sensor): """ @@ -2414,10 +2281,6 @@ def ambient_light_intensity(self): return self.value(0) * self._scale('AMBIENT') -# ~autogen - -# ~autogen generic-class classes.led>currentClass - class Led(Device): """ @@ -2428,6 +2291,14 @@ class Led(Device): SYSTEM_CLASS_NAME = 'leds' SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_max_brightness', + '_brightness', + '_triggers', + '_trigger', + '_delay_on', + '_delay_off', + ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -2442,23 +2313,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._delay_on = None self._delay_off = None -# ~autogen - - __slots__ = [ -# ~autogen generic-class-slots classes.led>currentClass - - '_max_brightness', - '_brightness', - '_triggers', - '_trigger', - '_delay_on', - '_delay_off', - -# ~autogen - ] - -# ~autogen generic-get-set classes.led>currentClass - @property def max_brightness(self): """ @@ -2487,10 +2341,6 @@ def triggers(self): self._triggers, value = self.get_attr_set(self._triggers, 'trigger') return value - - -# ~autogen - @property def trigger(self): """ @@ -2540,7 +2390,6 @@ def trigger(self, value): else: raise Exception('"{}" attribute has wrong permissions'.format(attr)) - @property def delay_on(self): """ @@ -2736,7 +2585,6 @@ def buttons_pressed(self): return pressed -# ~autogen remote-control specialSensorTypes.infraredSensor.remoteControl>currentClass class RemoteControl(ButtonBase): """ EV3 Remote Controller @@ -2808,9 +2656,6 @@ def beacon(self): """ return 'beacon' in self.buttons_pressed - -# ~autogen - def __init__(self, sensor=None, channel=1): if sensor is None: self._sensor = InfraredSensor() @@ -2871,8 +2716,6 @@ def heading_and_distance(self): return self._sensor.value(self._channel * 2), self._sensor.value(self._channel * 2 + 1) -# ~autogen generic-class classes.powerSupply>currentClass - class PowerSupply(Device): """ @@ -2882,6 +2725,14 @@ class PowerSupply(Device): SYSTEM_CLASS_NAME = 'power_supply' SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_measured_current', + '_measured_voltage', + '_max_voltage', + '_min_voltage', + '_technology', + '_type', + ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -2896,23 +2747,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._technology = None self._type = None -# ~autogen - - __slots__ = [ -# ~autogen generic-class-slots classes.powerSupply>currentClass - - '_measured_current', - '_measured_voltage', - '_max_voltage', - '_min_voltage', - '_technology', - '_type', - -# ~autogen - ] - -# ~autogen generic-get-set classes.powerSupply>currentClass - @property def measured_current(self): """ @@ -2957,9 +2791,6 @@ def type(self): self._type, value = self.get_attr_string(self._type, 'type') return value - -# ~autogen - @property def measured_amps(self): """ @@ -2975,8 +2806,6 @@ def measured_volts(self): return self.measured_voltage / 1e6 -# ~autogen generic-class classes.legoPort>currentClass - class LegoPort(Device): """ @@ -3009,6 +2838,14 @@ class LegoPort(Device): SYSTEM_CLASS_NAME = 'lego-port' SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_address', + '_driver_name', + '_modes', + '_mode', + '_set_device', + '_status', + ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -3023,23 +2860,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._set_device = None self._status = None -# ~autogen - - __slots__ = [ -# ~autogen generic-class-slots classes.legoPort>currentClass - - '_address', - '_driver_name', - '_modes', - '_mode', - '_set_device', - '_status', - -# ~autogen - ] - -# ~autogen generic-get-set classes.legoPort>currentClass - @property def address(self): """ @@ -3108,8 +2928,6 @@ def status(self): return value -# ~autogen - class FbMem(object): """The framebuffer memory object. diff --git a/ev3dev/ev3.py b/ev3dev/ev3.py index c9ec23f..5919b73 100644 --- a/ev3dev/ev3.py +++ b/ev3dev/ev3.py @@ -44,9 +44,6 @@ class Leds(object): """ The EV3 LEDs. """ - -# ~autogen led-colors platforms.ev3.led>currentClass - red_left = Led(name_pattern='led0:red:brick-status') red_right = Led(name_pattern='led1:red:brick-status') green_left = Led(name_pattern='led0:green:brick-status') @@ -100,15 +97,11 @@ def all_off(): Leds.green_right.brightness = 0 -# ~autogen - class Button(ButtonEVIO): """ EV3 Buttons """ -# ~autogen button-property platforms.ev3.button>currentClass - @staticmethod def on_up(state): """ @@ -214,6 +207,3 @@ def backspace(self): Check if 'backspace' button is pressed. """ return 'backspace' in self.buttons_pressed - - -# ~autogen diff --git a/spec_version.py b/spec_version.py index 0daacae..b74d5f6 100644 --- a/spec_version.py +++ b/spec_version.py @@ -1,8 +1,6 @@ -# ~autogen spec_version spec_version = "1.2.0" kernel_versions = { "11-ev3dev" "11-rc1-ev3dev" } -# ~autogen diff --git a/templates/autogen-header.liquid b/templates/autogen-header.liquid deleted file mode 100644 index 0315e85..0000000 --- a/templates/autogen-header.liquid +++ /dev/null @@ -1 +0,0 @@ -# Sections of the following code were auto-generated based on spec v{{ meta.version }}{% if meta.specRevision %}, rev {{meta.specRevision}}{% endif %} diff --git a/templates/button-class.liquid b/templates/button-class.liquid deleted file mode 100644 index b54c9ea..0000000 --- a/templates/button-class.liquid +++ /dev/null @@ -1,9 +0,0 @@ -class Button(ButtonBase): - - """{% for line in currentClass.description %} - {{ line }}{% endfor %} - - This implementation depends on the availability of the EVIOCGKEY ioctl - to be able to read the button state buffer. See Linux kernel source - in /include/uapi/linux/input.h for details. - """ diff --git a/templates/button-property.liquid b/templates/button-property.liquid deleted file mode 100644 index 01b5651..0000000 --- a/templates/button-property.liquid +++ /dev/null @@ -1,22 +0,0 @@ -{% for instance in currentClass.instances %} - @staticmethod - def on_{{ instance.name }}(state): - """ - This handler is called by `process()` whenever state of '{{ instance.name }}' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass -{% endfor %} - - _buttons = { -{% for instance in currentClass.instances %} '{{ instance.name }}': {'name': '{{ currentClass.systemPath }}/{{ instance.systemName }}', 'value': {{ instance.systemValue }}}, -{% endfor %} } -{% for instance in currentClass.instances %} - @property - def {{ instance.name }}(self): - """ - Check if '{{ instance.name }}' button is pressed. - """ - return '{{ instance.name }}' in self.buttons_pressed -{% endfor %} diff --git a/templates/doc-special-sensor-classes.liquid b/templates/doc-special-sensor-classes.liquid deleted file mode 100644 index 71fabd2..0000000 --- a/templates/doc-special-sensor-classes.liquid +++ /dev/null @@ -1,11 +0,0 @@ -{% for classPair in specialSensorTypes %}{% -assign class = classPair[1] %} -{{ class.friendlyName }} -######################## - -.. autoclass:: {{ class.friendlyName | camel_case | capitalize }} - :members: - :show-inheritance: - - -{% endfor %} diff --git a/templates/generic-class-slots.liquid b/templates/generic-class-slots.liquid deleted file mode 100644 index 068cc13..0000000 --- a/templates/generic-class-slots.liquid +++ /dev/null @@ -1,4 +0,0 @@ -{% for prop in currentClass.systemProperties %}{% - assign prop_name = prop.name | downcase | underscore_spaces %} - '_{{ prop_name }}',{% -endfor %} diff --git a/templates/generic-class.liquid b/templates/generic-class.liquid deleted file mode 100644 index 67b7272..0000000 --- a/templates/generic-class.liquid +++ /dev/null @@ -1,52 +0,0 @@ -{% -assign class_name = currentClass.friendlyName | camel_case | capitalize %}{% -if currentClass.systemDeviceNameConvention %}{% - assign device_name_convention = currentClass.systemDeviceNameConvention | replace: '\{\d\}', '*' %}{% -else %}{% - assign device_name_convention = '*' %}{% -endif %}{% -if currentClass.inheritance %}{% - assign base_class = currentClass.inheritance | camel_case | capitalize %}{% -else %}{% - assign base_class = 'Device' %}{% -endif%}{% -assign driver_name = "" %}{% -if currentClass.driverName %}{% - for name in currentClass.driverName %}{% - capture driver_name %}{{ driver_name }}, '{{name}}'{% endcapture %}{% - endfor %}{% - capture driver_name %} driver_name=[{{ driver_name | remove_first:', ' }}],{% endcapture %}{% -endif %} -class {{ class_name }}({{ base_class }}): - - """{% -for line in currentClass.description %}{% - if line %} - {{ line }}{% - else %} -{% endif %}{% -endfor %} - """ -{% if currentClass.inheritance %} - SYSTEM_CLASS_NAME = {{ base_class }}.SYSTEM_CLASS_NAME{% - if currentClass.systemDeviceNameConvention %} - SYSTEM_DEVICE_NAME_CONVENTION = '{{ device_name_convention }}'{% - else %} - SYSTEM_DEVICE_NAME_CONVENTION = {{ base_class }}.SYSTEM_DEVICE_NAME_CONVENTION{% - endif %} -{% else %} - SYSTEM_CLASS_NAME = '{{ currentClass.systemClassName }}' - SYSTEM_DEVICE_NAME_CONVENTION = '{{ device_name_convention }}' -{% endif %} - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): -{% if currentClass.inheritance %} - super({{ class_name }}, self).__init__(address, name_pattern, name_exact,{{ driver_name }} **kwargs) -{% else %} - if address is not None: - kwargs['address'] = address - super({{ class_name }}, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact,{{ driver_name }} **kwargs) -{% endif %}{% -for prop in currentClass.systemProperties %}{% - assign prop_name = prop.name | downcase | underscore_spaces %} - self._{{ prop_name }} = None{% -endfor %} diff --git a/templates/generic-get-set.liquid b/templates/generic-get-set.liquid deleted file mode 100644 index 0a1bb92..0000000 --- a/templates/generic-get-set.liquid +++ /dev/null @@ -1,38 +0,0 @@ -{% assign class_name = currentClass.friendlyName | downcase | underscore_spaces %}{% -for prop in currentClass.systemProperties %}{% - assign prop_name = prop.name | downcase | underscore_spaces %}{% - if class_name != 'led' or prop_name != 'trigger' and prop_name != 'delay_on' and prop_name != 'delay_off' %}{% - assign getter = prop.type %}{% - assign setter = prop.type %}{% - if prop.type == 'string array' %}{% - assign getter = 'set' %}{% - elsif prop.type == 'string selector' %}{% - assign getter = 'from_set' %}{% - assign setter = 'string' %}{% - endif %} - @property - def {{ prop_name }}(self): - """{% - for line in prop.description %}{% - if line %} - {{ line }}{% - else %} -{% endif %}{% - endfor %} - """{% - if prop.readAccess %} - self._{{ prop_name }}, value = self.get_attr_{{ getter }}(self._{{ prop_name }}, '{{prop.systemName}}') - return value{% - else %} - raise Exception("{{prop_name}} is a write-only property!"){% - endif %}{% - if prop.writeAccess %} - - @{{prop_name}}.setter - def {{ prop_name }}(self, value): - self._{{ prop_name }} = self.set_attr_{{ setter }}(self._{{ prop_name }}, '{{prop.systemName}}', value){% -endif%}{% unless forloop.last %} -{% endunless %}{% -endif %}{% -endfor %} - diff --git a/templates/generic-helper-function.liquid b/templates/generic-helper-function.liquid deleted file mode 100644 index 84197c4..0000000 --- a/templates/generic-helper-function.liquid +++ /dev/null @@ -1,16 +0,0 @@ -{% assign class_name = currentClass.friendlyName | camel_case | capitalize %}{% -for propval in currentClass.propertyValues %}{% - if propval.propertyName == "Command" %}{% - for value in propval.values %}{% - assign cmd = value.name | replace:'-','_' %} - def {{ cmd }}( self, **kwargs ): - """{% - for line in value.description %}{{line}} - {% endfor %}""" - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = '{{value.name}}' -{% - endfor %}{% - endif %}{% -endfor %} diff --git a/templates/generic-property-value.liquid b/templates/generic-property-value.liquid deleted file mode 100644 index 0247a28..0000000 --- a/templates/generic-property-value.liquid +++ /dev/null @@ -1,10 +0,0 @@ -{% for prop in currentClass.propertyValues %}{% - assign className = currentClass.friendlyName | downcase | underscore_spaces %}{% - assign propName = prop.propertyName | upcase | underscore_spaces %}{% - for value in prop.values %}{% - for line in value.description %} - #: {{ line }}{% - endfor %} - {{ propName }}_{{ value.name | upcase | underscore_non_wc }} = '{{ value.name }}' -{% endfor %}{% -endfor %} diff --git a/templates/led-colors.liquid b/templates/led-colors.liquid deleted file mode 100644 index fec2e28..0000000 --- a/templates/led-colors.liquid +++ /dev/null @@ -1,60 +0,0 @@ -{% for instance in currentClass.instances %}{% - assign instanceName = instance.name | downcase | underscore_spaces %} - {{instanceName}} = Led(name_pattern='{{instance.systemName}}'){% -endfor %} -{% for group in currentClass.groups %}{% - assign groupName = group.name | upcase | underscore_spaces %}{% - assign ledNames = '' %}{% - for name in group.entries %}{% - capture ledNames %}{{ ledNames }}{{ name | downcase | underscore_spaces }}, {% - endcapture %}{% - endfor %} - {{ groupName }} = ( {{ ledNames }}){% -endfor %} -{% for color in currentClass.colors %}{% - assign colorName = color.name | upcase | underscore_spaces %}{% - assign mixValues = '' %}{% - for value in color.value %}{% - capture mixValues %}{{ mixValues }}{{ value }}, {% - endcapture %}{% - endfor %} - {{ colorName }} = ( {{ mixValues }}){% -endfor %} - - @staticmethod - def set_color(group, color, pct=1): - """ - Sets brigthness of leds in the given group to the values specified in - color tuple. When percentage is specified, brightness of each led is - reduced proportionally. - - Example:: - - Leds.set_color(LEFT, AMBER) - """ - for l, v in zip(group, color): - l.brightness_pct = v * pct - - @staticmethod - def set(group, **kwargs): - """ - Set attributes for each led in group. - - Example:: - - Leds.set(LEFT, brightness_pct=0.5, trigger='timer') - """ - for led in group: - for k in kwargs: - setattr(led, k, kwargs[k]) - - @staticmethod - def all_off(): - """ - Turn all leds off - """{% -for instance in currentClass.instances %}{% - assign instanceName = instance.name | downcase | underscore_spaces %} - Leds.{{instanceName}}.brightness = 0{% -endfor %} - diff --git a/templates/motor_commands.liquid b/templates/motor_commands.liquid deleted file mode 100644 index 5ffd6c2..0000000 --- a/templates/motor_commands.liquid +++ /dev/null @@ -1,17 +0,0 @@ -{% assign class_name = currentClass.friendlyName | camel_case | capitalize %}{% -for prop in currentClass.propertyValues %}{% - assign propName = prop.propertyName | upcase | underscore_spaces %}{% - if prop.propertyName == "Command" %}{% - for value in prop.values %}{% - assign cmd = value.name | replace:'-','_' %} - def {{ cmd }}(self, **kwargs): - """{% - for line in value.description %}{{line}} - {% endfor %}""" - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.{{ propName }}_{{ value.name | upcase | underscore_non_wc }} -{% - endfor %}{% - endif %}{% -endfor %} diff --git a/templates/motor_states.liquid b/templates/motor_states.liquid deleted file mode 100644 index ca9fc18..0000000 --- a/templates/motor_states.liquid +++ /dev/null @@ -1,13 +0,0 @@ -{% assign class_name = currentClass.friendlyName | camel_case | capitalize %}{% -for prop in currentClass.propertyValues %}{% - if prop.propertyName == "State" %}{% - for state in prop.values %} - @property - def is_{{ state.name }}(self): - """{% - for line in state.description %}{{line}} - {% endfor %}""" - return self.STATE_{{ state.name | upcase }} in self.state -{% endfor %}{% -endif %}{% -endfor %} diff --git a/templates/remote-control.liquid b/templates/remote-control.liquid deleted file mode 100644 index bb715c4..0000000 --- a/templates/remote-control.liquid +++ /dev/null @@ -1,29 +0,0 @@ -class RemoteControl(ButtonBase): - """{% for line in currentClass.description %} - {{ line }}{% endfor %} - """ - - _BUTTON_VALUES = { -{% for v in currentClass.values -%} {{ v.value }}: [{% - for s in v.state - %}'{{ s | downcase | underscore_spaces }}'{% - unless forloop.last %}, {% - endunless %}{% - endfor %}]{% unless forloop.last %}, -{% endunless %}{% -endfor %} - } -{% for b in currentClass.buttons %} - #: Handles ``{{ b.name }}`` events. - on_{{ b.name | downcase | underscore_spaces }} = None -{% endfor %} -{% for b in currentClass.buttons %}{% - assign name = b.name | downcase | underscore_spaces %} - @property - def {{ name }}(self): - """ - Checks if `{{ name }}` button is pressed. - """ - return '{{ name }}' in self.buttons_pressed -{% endfor %} diff --git a/templates/spec_version.liquid b/templates/spec_version.liquid deleted file mode 100644 index 4e2d028..0000000 --- a/templates/spec_version.liquid +++ /dev/null @@ -1,6 +0,0 @@ -spec_version = "{{ meta.version }}{% if meta.specRevision %}-r{{ meta.specRevision }}{% endif %}" -kernel_versions = { {% - for kernel in meta.supportedKernel.kernels %} - "{{ kernel }}"{% - endfor %} - } diff --git a/templates/special-sensors.liquid b/templates/special-sensors.liquid deleted file mode 100644 index 8049458..0000000 --- a/templates/special-sensors.liquid +++ /dev/null @@ -1,93 +0,0 @@ -{% for currentClassMetadata in specialSensorTypes %}{% - assign currentClass = currentClassMetadata[1] %}{% -assign class_name = currentClass.friendlyName | camel_case | capitalize %}{% -if currentClass.systemDeviceNameConvention %}{% - assign device_name_convention = currentClass.systemDeviceNameConvention | replace: '\{\d\}', '*' %}{% -else %}{% - assign device_name_convention = '*' %}{% -endif %}{% -if currentClass.inheritance %}{% - assign base_class = currentClass.inheritance | camel_case | capitalize %}{% -else %}{% - assign base_class = 'Device' %}{% -endif%}{% -assign driver_name = "" %}{% -if currentClass.driverName %}{% - for name in currentClass.driverName %}{% - capture driver_name %}{{ driver_name }}, '{{name}}'{% endcapture %}{% - endfor %}{% - capture driver_name %} driver_name=[{{ driver_name | remove_first:', ' }}],{% endcapture %}{% -endif %} -class {{ class_name }}({{ base_class }}): - - """{% -for line in currentClass.description %}{% - if line %} - {{ line }}{% - else %} -{% endif %}{% -endfor %} - """ - - __slots__ = ['auto_mode'] -{% if currentClass.inheritance %} - SYSTEM_CLASS_NAME = {{ base_class }}.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = {{ base_class }}.SYSTEM_DEVICE_NAME_CONVENTION -{% else %} - SYSTEM_CLASS_NAME = '{{ currentClass.systemClassName }}' - SYSTEM_DEVICE_NAME_CONVENTION = '{{ device_name_convention }}' -{% endif %} - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super({{ class_name }}, self).__init__(address, name_pattern, name_exact,{{ driver_name }} **kwargs) - self.auto_mode = True - -{% for prop in currentClass.propertyValues %}{% - assign className = currentClass.friendlyName | downcase | underscore_spaces %}{% - assign propName = prop.propertyName | upcase | underscore_spaces %}{% - for value in prop.values %}{% - for line in value.description %} - #: {{ line }}{% - endfor %}{% - if value.value %}{% - if value.type == 'int' %}{% - capture val %}{{ value.value }}{% endcapture %}{% - else %}{% - capture val %}'{{ value.value }}'{% endcapture %}{% - endif %}{% - else %}{% - capture val %}'{{ value.name }}'{% endcapture %}{% - endif %} - {{ propName }}_{{ value.name | upcase | underscore_non_wc }} = {{ val }} -{% endfor %}{% -endfor %} -{% for prop in currentClass.propertyValues %}{% -assign propName = prop.propertyName | upcase | underscore_spaces%} - {{ propName }}S = ({% - for value in prop.values %} - '{{ value.name }}',{% - endfor %} - ) -{%endfor %} -{% for mapping in currentClass.sensorValueMappings %}{% - assign name = mapping.name | downcase | underscore_spaces %}{% - assign mode = mapping.requiredMode | upcase | underscore_non_wc %} - @property - def {{ name }}(self): - """{% - for line in mapping.description %}{% - if line %} - {{ line }}{% - else %} -{% endif %}{% - endfor %} - """ - - if self.auto_mode: - self.mode = self.MODE_{{ mode }} - - return {% - for value_index in mapping.sourceValue - %}self.value({{ value_index }}){% if mapping.type contains 'float' %} * self._scale('{{ mode }}'){% endif %}{% unless forloop.last %}, {% endunless %}{% - endfor %} -{% endfor %}{% -endfor %} From 1ca3e288640abfb7a97de80ce8a15f19e4d59ba7 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 21 Sep 2017 10:30:26 -0400 Subject: [PATCH 008/172] Remove demo directory (#369) --- README.rst | 12 +- demo/BALANC3R | 29 - demo/EV3D4/EV3D4RemoteControl.py | 28 - demo/EV3D4/EV3D4WebControl.py | 29 - demo/EV3D4/README.md | 46 -- demo/EV3D4/desktop.html | 47 -- demo/EV3D4/favicon.ico | Bin 1086 -> 0 bytes demo/EV3D4/include/ArrowClockwise.png | Bin 2387 -> 0 bytes demo/EV3D4/include/ArrowDown.png | Bin 7219 -> 0 bytes demo/EV3D4/include/ArrowLeft.png | Bin 7183 -> 0 bytes demo/EV3D4/include/ArrowRight.png | Bin 7369 -> 0 bytes demo/EV3D4/include/ArrowUp.png | Bin 6803 -> 0 bytes demo/EV3D4/include/desktop.png | Bin 3462 -> 0 bytes demo/EV3D4/include/gear.png | Bin 2156 -> 0 bytes .../include/jquery.ui.touch-punch.min.js | 11 - demo/EV3D4/include/mobile.png | Bin 3771 -> 0 bytes demo/EV3D4/include/tank-desktop.js | 165 ----- demo/EV3D4/include/tank-mobile.js | 195 ------ demo/EV3D4/include/tank.css | 129 ---- demo/EV3D4/include/tank.png | Bin 4436 -> 0 bytes demo/EV3D4/index.html | 27 - demo/EV3D4/mobile.html | 33 - demo/EV3RSTORM/README.md | 14 - demo/EV3RSTORM/ev3rstorm.py | 157 ----- demo/EXPLOR3R/README.rst | 3 - demo/EXPLOR3R/auto-drive.py | 139 ---- demo/EXPLOR3R/remote-control.py | 105 ---- demo/MINDCUB3R/.gitignore | 1 - demo/MINDCUB3R/README.md | 61 -- demo/MINDCUB3R/__init__.py | 0 demo/MINDCUB3R/rubiks.py | 595 ------------------ demo/R3PTAR/README.md | 16 - demo/R3PTAR/r3ptar.py | 73 --- demo/R3PTAR/rattle-snake.wav | Bin 241694 -> 0 bytes demo/R3PTAR/snake-hiss.wav | Bin 77868 -> 0 bytes demo/README.md | 73 --- demo/TRACK3R/TRACK3R.py | 66 -- demo/TRACK3R/TRACK3RWithBallShooter | 13 - demo/TRACK3R/TRACK3RWithClaw | 13 - demo/TRACK3R/TRACK3RWithSpinner | 13 - demo/misc/leds.py | 82 --- demo/misc/snd/r2d2.wav | Bin 405548 -> 0 bytes demo/misc/sound.py | 66 -- 43 files changed, 3 insertions(+), 2238 deletions(-) delete mode 100755 demo/BALANC3R delete mode 100755 demo/EV3D4/EV3D4RemoteControl.py delete mode 100755 demo/EV3D4/EV3D4WebControl.py delete mode 100644 demo/EV3D4/README.md delete mode 100644 demo/EV3D4/desktop.html delete mode 100644 demo/EV3D4/favicon.ico delete mode 100644 demo/EV3D4/include/ArrowClockwise.png delete mode 100644 demo/EV3D4/include/ArrowDown.png delete mode 100644 demo/EV3D4/include/ArrowLeft.png delete mode 100644 demo/EV3D4/include/ArrowRight.png delete mode 100644 demo/EV3D4/include/ArrowUp.png delete mode 100644 demo/EV3D4/include/desktop.png delete mode 100644 demo/EV3D4/include/gear.png delete mode 100644 demo/EV3D4/include/jquery.ui.touch-punch.min.js delete mode 100644 demo/EV3D4/include/mobile.png delete mode 100644 demo/EV3D4/include/tank-desktop.js delete mode 100644 demo/EV3D4/include/tank-mobile.js delete mode 100644 demo/EV3D4/include/tank.css delete mode 100644 demo/EV3D4/include/tank.png delete mode 100644 demo/EV3D4/index.html delete mode 100644 demo/EV3D4/mobile.html delete mode 100644 demo/EV3RSTORM/README.md delete mode 100644 demo/EV3RSTORM/ev3rstorm.py delete mode 100644 demo/EXPLOR3R/README.rst delete mode 100755 demo/EXPLOR3R/auto-drive.py delete mode 100755 demo/EXPLOR3R/remote-control.py delete mode 100644 demo/MINDCUB3R/.gitignore delete mode 100644 demo/MINDCUB3R/README.md delete mode 100644 demo/MINDCUB3R/__init__.py delete mode 100755 demo/MINDCUB3R/rubiks.py delete mode 100644 demo/R3PTAR/README.md delete mode 100755 demo/R3PTAR/r3ptar.py delete mode 100644 demo/R3PTAR/rattle-snake.wav delete mode 100644 demo/R3PTAR/snake-hiss.wav delete mode 100644 demo/README.md delete mode 100644 demo/TRACK3R/TRACK3R.py delete mode 100755 demo/TRACK3R/TRACK3RWithBallShooter delete mode 100755 demo/TRACK3R/TRACK3RWithClaw delete mode 100755 demo/TRACK3R/TRACK3RWithSpinner delete mode 100755 demo/misc/leds.py delete mode 100644 demo/misc/snd/r2d2.wav delete mode 100755 demo/misc/sound.py diff --git a/README.rst b/README.rst index f5735ad..ac3dc38 100644 --- a/README.rst +++ b/README.rst @@ -168,16 +168,10 @@ Support what you are trying to do and what you have tried. The issue template is in place to guide you through this process. -Demo Robot - Laurens Valk of robot-square_ has been kind enough to allow us to - reference his excellent `EXPLOR3R`_ robot. Consider building the - `EXPLOR3R`_ and running the demo programs referenced below to get - familiar with what Python programs using this binding look like. - Demo Code - There are `demo programs`_ that you can run to get acquainted with - this language binding. The programs are designed to work with the - `EXPLOR3R`_ robot. + There are several demo programs that you can run to get acquainted with + this language binding. The programs are available at + https://github.com/ev3dev/ev3dev-lang-python-demo Upgrading this Library ---------------------- diff --git a/demo/BALANC3R b/demo/BALANC3R deleted file mode 100755 index 1cc572f..0000000 --- a/demo/BALANC3R +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 - -import logging -from ev3dev.GyroBalancer import GyroBalancer - - -class BALANC3R(GyroBalancer): - """ - Laurens Valk's BALANC3R - http://robotsquare.com/2014/06/23/tutorial-building-balanc3r/ - """ - def __init__(self): - GyroBalancer.__init__(self, - gainGyroAngle=1156, - gainGyroRate=146, - gainMotorAngle=7, - gainMotorAngularSpeed=9, - gainMotorAngleErrorAccumulated=3) - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG, - format='%(asctime)s %(levelname)5s: %(message)s') - log = logging.getLogger(__name__) - - log.info("Starting BALANC3R") - robot = BALANC3R() - robot.main() - log.info("Exiting BALANC3R") diff --git a/demo/EV3D4/EV3D4RemoteControl.py b/demo/EV3D4/EV3D4RemoteControl.py deleted file mode 100755 index 6d2df5d..0000000 --- a/demo/EV3D4/EV3D4RemoteControl.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 - -import logging -import sys -from ev3dev.auto import OUTPUT_A, OUTPUT_B, OUTPUT_C -from ev3dev.helper import RemoteControlledTank, MediumMotor - -class EV3D4RemoteControlled(RemoteControlledTank): - - def __init__(self, medium_motor=OUTPUT_A, left_motor=OUTPUT_C, right_motor=OUTPUT_B): - RemoteControlledTank.__init__(self, left_motor, right_motor) - self.medium_motor = MediumMotor(medium_motor) - - if not self.medium_motor.connected: - log.error("%s is not connected" % self.medium_motor) - sys.exit(1) - - self.medium_motor.reset() - - -logging.basicConfig(level=logging.INFO, - format='%(asctime)s %(levelname)5s: %(message)s') -log = logging.getLogger(__name__) - -log.info("Starting EV3D4") -ev3d4 = EV3D4RemoteControlled() -ev3d4.main() -log.info("Exiting EV3D4") diff --git a/demo/EV3D4/EV3D4WebControl.py b/demo/EV3D4/EV3D4WebControl.py deleted file mode 100755 index da70e7e..0000000 --- a/demo/EV3D4/EV3D4WebControl.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 - -import logging -import sys -from ev3dev.auto import OUTPUT_A, OUTPUT_B, OUTPUT_C -from ev3dev.helper import MediumMotor -from ev3dev.webserver import WebControlledTank - -class EV3D4WebControlled(WebControlledTank): - - def __init__(self, medium_motor=OUTPUT_A, left_motor=OUTPUT_C, right_motor=OUTPUT_B): - WebControlledTank.__init__(self, left_motor, right_motor) - self.medium_motor = MediumMotor(medium_motor) - - if not self.medium_motor.connected: - log.error("%s is not connected" % self.medium_motor) - sys.exit(1) - - self.medium_motor.reset() - - -logging.basicConfig(level=logging.INFO, - format='%(asctime)s %(levelname)5s: %(message)s') -log = logging.getLogger(__name__) - -log.info("Starting EV3D4") -ev3d4 = EV3D4WebControlled() -ev3d4.main() # start the web server -log.info("Exiting EV3D4") diff --git a/demo/EV3D4/README.md b/demo/EV3D4/README.md deleted file mode 100644 index fd44827..0000000 --- a/demo/EV3D4/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# EV3D4 -EV3D4 is designed to look like R2-D2 from Star Wars. There are two options for -controlling EV3D4. The first is to use the IR remote to send commands to the IR -sensor, run EV3D4RemoteControl.py to use this method. The second means of -controlling EV3D4 is via a web browser, run EV3D4WebControl.py to use this method. -You can run both of these from the Brickman interface or if logged in via ssh -you can run them via ./EV3D4RemoteControl.py or ./EV3D4WebControl.py. - -**Building instructions**: https://www.lego.com/en-us/mindstorms/build-a-robot/ev3d4 - -### EV3D4RemoteControl -EV3D4RemoteControl.py creates a child class of ev3dev/helper.py's -RemoteControlledTank. - - -### EV3D4WebControl -EV3D4WebControl creates a child class of ev3dev/webserver.py's WebControlledTank. -The WebControlledTank class runs a web server that serves the web pages, -images, etc but it also services the AJAX calls made via the client. The user -loads the initial web page at which point they choose the "Desktop interface" -or the "Mobile Interface". - -Desktop Interface - The user is presented with four arrows for moving forwards, -backwards, spinning clockwise or spinning counter-clockwise. Two additional -buttons are provided for controlling the medium motor. There are two sliders, -one to control the speed of the tank and the other to control the speed of the -medium motor. - -Mobile Interface - The user is presented with a virtual joystick that is used -to control the movements of the robot. Slide your thumb forward and the robot -moves forward, slide it to the right and the robot spins clockwise, etc. The -further you move the joystick towards the edge of the circle the faster the -robot moves. Buttons and a speed slider for the medium motor are also provided. - -Both interfaces have touch support so you can use either Desktop or Mobile from -your smartphone. When the user clicks/touches a button some jQuery code will -fire off an AJAX call to let the EV3D4 web server know what the user clicked or -where the joystick is if using the Mobile Interface. The web server in -WebControlledTank services this AJAX call and adjust motor speed/power -accordingly. - -You can see a demo of the web interface below. Note that the demo is on a -simple Tank robot, not EV3D4, but that doesn't really matter as EV3D4 is also -just a Tank robot. - -**Demo Video**: https://www.youtube.com/watch?v=x5VauXr7W4A diff --git a/demo/EV3D4/desktop.html b/demo/EV3D4/desktop.html deleted file mode 100644 index f031785..0000000 --- a/demo/EV3D4/desktop.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - -Lego Tank - - -
- - -
- -
- - - - -
- - -
-
- -
- - -
-
- -
-
- -
- -
- -
- - diff --git a/demo/EV3D4/favicon.ico b/demo/EV3D4/favicon.ico deleted file mode 100644 index f562293b5703e8fb43fe07773f48e60be3783f5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1086 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 diff --git a/demo/EV3D4/include/ArrowLeft.png b/demo/EV3D4/include/ArrowLeft.png deleted file mode 100644 index 54ba5a42b43f32b074ccfeba9972d72286b4c002..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7183 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 diff --git a/demo/EV3D4/include/ArrowRight.png b/demo/EV3D4/include/ArrowRight.png deleted file mode 100644 index 7014f9fc6b6cb6a9ce768127bb46829f043276dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7369 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 diff --git a/demo/EV3D4/include/ArrowUp.png b/demo/EV3D4/include/ArrowUp.png deleted file mode 100644 index e9068ae153f9745fc0908032726fb14d367a90f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6803 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 diff --git a/demo/EV3D4/include/gear.png b/demo/EV3D4/include/gear.png deleted file mode 100644 index 8898a16748d13c0f80102f6836313ef551fdb7e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2156 zcmZ{lc{~#iAIGzYS{}dWcs+kTe|*00@Avz9eg1yOTUi+M^GNal004d*7Hxe@^55YCA5U-X z3e+(`L{nol;3)R-V8`*r?T>W|I41UA!MwSt4*+o1;LwJ)A(QKQ-ad9l;*X?XPB)A) zsqpb9NS)b;FWN3^OuaO*J9XQ*pTwv{#$^4_>ZjxdaQu- zzn8v3x*)6_n$laR|KNi~N?F$#)Y-hR4u^>l0CPmV+J(xDQaQjiGGnB?W5BO9`gb@U3m z`0pbT0*d&KH)S5DbeVDXvB)Jz88tZ@kEGOp294P=X($;W#Z&XpyCl;FLg4?3A2pp9 zE^<24b5a!6gUBM1ZWP5xCbUvRWd#L{+jBf+fPDHM(cG+3XyVctft3sbXwK1Ue2D%2%3`BK$}2;J=}*4l z#a(_r$xv(-SZv2vL`W}Zf)&$?NI%_u{9bYDWo+bIJ4tx(jxp4QJa7!Z*zzr11ns>J zhP&-YmRZfbE=JF%BXd)s6cPmzRZT=?yp(yrrGCO2F4U5J{~;Sb`mS-u{b8nIwtub6 z`+JKaj=y34$+yE*%oXkBdWd$rpF*zdU2D7(7q9M~>!!?W>%Gnu>RgK<-&Sm{N>+V) z&~JqypLpruLd9M=hhp;zL_9~qd=InPf%XR$7ORfYVMae1Rm^n`BT8k+G%C)V46Y_QGRRMv*CsaP5WrQYf+$8WSmE6}vfH@{4;Y|5Xe}QV-@`kuyEXY%O_;lL5UmrMDatIw;D#5xE9F`8^*Gu-*Hwir+M)Zb=+5B2%4W!-?p1}R$t4#P zZ1CJc-ivtO#V>r?p=fFKgDd@y;*4@zIoDo&lD$REb*~)%nga-0%eV)RJh)9fl&)y(!O5l{z{i(;OM zVGq#ir-mli{naY22Nt|m%1vAPEk1%QGO%UJSQAs5W_W4v`l>c(V&0yxxOtg+);Lm& zyR|1dxB!{tp8*pIYkLsq2KFvVw~&-DBeKW(K44eLrFn?dHj1Gjy>j3{M#U!*48(_5GELI;7#Gtbg&i2~#RKR!Bl&VBzu$~iE_-di`YffURhm1YxLm%oL8kc; zth&CefJ1t^6sl`{Y=ZVMS_U`eZ`$KqUtf@rsP&JGD>h^UsY^#v0L6j6`o5{vi|=el z$VgQ-|Nhn^9EKbiaRUziAcgPDX zFwj&>Nmw+_{RRCL=ZfR(xyk_eYOzn6=JgWm8@N@aou6YfDrOz=ukX*km9=-m3c6Pe z(Whh9wA5AxB6firG&?J@fccK$pd8l@d82Vntf6tZ&ViT;NmW`hNN{X%N(-S`QW}yE zgGM;T>G%4*&zi-t{%kNy#?j2MqX@y~UE2e}u{ke-@Ni zRVK-8scCaH*$+EMJpc6}fFUmyrT7PPEq<>*rPJ#ng53SogM~Dl;mGdxpX))16w=|Y z6};7oDkio{oxq}dnNsq20dZgTfV!%h7EDzehETOt)6`Yh(pA@nscPz~s?x&T|Mvg? d3ch~s-kzcVzc2!wW;-qba7GsBnoER-{{(l+1)){a.preventDefault();var c=a.originalEvent.changedTouches[0],d=document.createEvent("MouseEvents");d.initMouseEvent(b,!0,!0,window,1,c.screenX,c.screenY,c.clientX,c.clientY,!1,!1,!1,!1,0,null),a.target.dispatchEvent(d)}}if(a.support.touch="ontouchend"in document,a.support.touch){var e,b=a.ui.mouse.prototype,c=b._mouseInit,d=b._mouseDestroy;b._touchStart=function(a){var b=this;!e&&b._mouseCapture(a.originalEvent.changedTouches[0])&&(e=!0,b._touchMoved=!1,f(a,"mouseover"),f(a,"mousemove"),f(a,"mousedown"))},b._touchMove=function(a){e&&(this._touchMoved=!0,f(a,"mousemove"))},b._touchEnd=function(a){e&&(f(a,"mouseup"),f(a,"mouseout"),this._touchMoved||f(a,"click"),e=!1)},b._mouseInit=function(){var b=this;b.element.bind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),c.call(b)},b._mouseDestroy=function(){var b=this;b.element.unbind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),d.call(b)}}}(jQuery); diff --git a/demo/EV3D4/include/mobile.png b/demo/EV3D4/include/mobile.png deleted file mode 100644 index 24f761f877ea8819aba4ee8407615f4cd28392dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3771 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 G@P7eZ;CPw< diff --git a/demo/EV3D4/include/tank-desktop.js b/demo/EV3D4/include/tank-desktop.js deleted file mode 100644 index 595c934..0000000 --- a/demo/EV3D4/include/tank-desktop.js +++ /dev/null @@ -1,165 +0,0 @@ - -var moving = 0; -var ip = 0; -var seq = 0; - -function stop_motors() { - var ajax_url = "/" + seq + "/move-stop/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - - moving = 0 -} - -function stop_medium_motor() { - var ajax_url = "/" + seq + "/motor-stop/medium/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; -} - -// Prevent the page from scrolling on an iphone -// http://stackoverflow.com/questions/7768269/ipad-safari-disable-scrolling-and-bounce-effect -$(document).bind( - 'touchmove', - function(e) { - e.preventDefault(); - } -); - -$(document).ready(function() { - - $("#medium-motor-speed").slider({ - min: 0, - max: 100, - step: 5, - value: 50 - }); - - $("#tank-speed").slider({ - min: 0, - max: 100, - step: 5, - value: 25 - }); - - // Desktop Interface - $('#ArrowUp').bind('touchstart mousedown', function() { - console.log('ArrowUp down') - var power = $('#tank-speed').slider("value") - var ajax_url = "/" + seq + "/move-start/forward/" + power + "/" - seq++; - moving = 1 - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('#ArrowDown').bind('touchstart mousedown', function() { - console.log('ArrowDown down') - var power = $('#tank-speed').slider("value") - var ajax_url = "/" + seq + "/move-start/backward/" + power + "/" - seq++; - moving = 1 - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('#ArrowLeft').bind('touchstart mousedown', function() { - console.log('ArrowLeft down') - var power = $('#tank-speed').slider("value") - var ajax_url = "/" + seq + "/move-start/left/" + power + "/" - seq++; - moving = 1 - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('#ArrowRight').bind('touchstart mousedown', function() { - console.log('ArrowRight down') - var power = $('#tank-speed').slider("value") - var ajax_url = "/" + seq + "/move-start/right/" + power + "/" - seq++; - moving = 1 - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('#desktop-medium-motor-spin .CounterClockwise').bind('touchstart mousedown', function() { - console.log('CounterClockwise down') - var power = $('#medium-motor-speed').slider("value") - var ajax_url = "/" + seq + "/motor-start/medium/counter-clockwise/" + power + "/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('#desktop-medium-motor-spin .Clockwise').bind('touchstart mousedown', function() { - console.log('Clockwise down') - var power = $('#medium-motor-speed').slider("value") - var ajax_url = "/" + seq + "/motor-start/medium/clockwise/" + power + "/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('.medium').bind('touchend mouseup mouseout', function() { - stop_medium_motor() - return false; - }); - - $('.nav').bind('touchend mouseup mouseout', function() { - if (moving) { - console.log('Mouse no longer over button') - stop_motors() - return false; - } - }); -}); diff --git a/demo/EV3D4/include/tank-mobile.js b/demo/EV3D4/include/tank-mobile.js deleted file mode 100644 index 62ac8a1..0000000 --- a/demo/EV3D4/include/tank-mobile.js +++ /dev/null @@ -1,195 +0,0 @@ - -var start_x = 0; -var start_y = 0; -var moving = 0; -var seq = 0; -var prev_x = 0; -var prev_y = 0; - -function stop_motors() { - var ajax_url = "/" + seq + "/move-stop/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - async: true, - url: ajax_url - }); - moving = 0; -} - -function stop_medium_motor() { - var ajax_url = "/" + seq + "/motor-stop/medium/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - async: true, - url: ajax_url - }); - return false; -} - -function ajax_move_xy(x, y) { - // console.log("FIRE ajax call with x,y " + x + "," + y) - var ajax_url = "/" + seq + "/move-xy/" + x + "/" + y + "/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - async: true, - url: ajax_url - }); - moving = 1; -} - -function ajax_log(msg) { - var ajax_url = "/" + seq + "/log/" + msg + "/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - async: true, - url: ajax_url - }); -} - - -// Prevent the page from scrolling on an iphone -// http://stackoverflow.com/questions/7768269/ipad-safari-disable-scrolling-and-bounce-effect -$(document).bind( - 'touchmove', - function(e) { - e.preventDefault(); - } -); - -$(document).ready(function() { - - // Used the 'Restrict the inside circle to the outside circle' code - var r = $('#joystick-wrapper').width()/2; - var small_r = $('#joystick').width()/2; - var origin_x = r - small_r; - var origin_y = r - small_r; - - $("#medium-motor-speed").slider({ - min: 0, - max: 100, - step: 5, - value: 50 - }); - - $('#medium-motor-spin .CounterClockwise').bind('touchstart mousedown', function() { - var power = $('#medium-motor-speed').slider("value") - var ajax_url = "/" + seq + "/motor-start/medium/counter-clockwise/" + power + "/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('#medium-motor-spin .Clockwise').bind('touchstart mousedown', function() { - var power = $('#medium-motor-speed').slider("value") - var ajax_url = "/" + seq + "/motor-start/medium/clockwise/" + power + "/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - url: ajax_url - }); - return false; - }); - - $('.medium').bind('touchend mouseup', function() { - stop_medium_motor() - return false; - }); - - $("div#joystick").draggable({ - revert: true, - containment: "parent", - create: function() { - start_x = parseInt($(this).css("left")); - start_y = parseInt($(this).css("top")); - prev_x = start_x; - prev_y = start_y; - }, - drag: function(event, ui) { - - // Restrict the inside circle to the outside circle - // http://stackoverflow.com/questions/26787996/containing-draggable-circle-to-a-larger-circle - var x = ui.position.left - origin_x, y = ui.position.top - origin_y; - var l = Math.sqrt(x*x + y*y); - var l_in = Math.min(r - small_r, l); - ui.position = {'left': Math.round(x/l*l_in) + origin_x, 'top': Math.round(y/l*l_in) + origin_y}; - - // Get coordinates - var x = ui.position.left - start_x - var y = (ui.position.top - start_y) * -1 - var distance = 0; - - // If this is the initial touch then set the distance high so we'll move - if (prev_x == start_x && prev_y == start_y) { - distance = 99; - } else { - distance = Math.round(Math.sqrt(((x - prev_x) * (x - prev_x)) + ((y - prev_y) * (y - prev_y)))); - } - - // When you drag the joystick it can fire off a LOT of drag - // events (one about every 8 ms), it ends up overwhelming the - // web server on the EV3. It takes the EV3 ~55ms to process - // one of these request so don't send one if the x,y coordinates - // have only changed a tiny bit - if (distance >= 10) { - ajax_move_xy(x, y); - prev_x = x; - prev_y = y; - } - }, - stop: function() { - if (moving) { - stop_motors(); - } - prev_x = start_x; - prev_y = start_y; - } - }); - - // This reacts much faster than the draggable stop event - $('#joystick-wrapper').bind('touchend mouseup', function() { - if (moving) { - stop_motors() - } - prev_x = start_x; - prev_y = start_y; - return true; - }); - - $('#joystick-wrapper').bind('touchstart mousedown', function() { - var ajax_url = "/" + seq + "/joystick-engaged/" - seq++; - - $.ajax({ - type: "GET", - cache: false, - dataType: 'text', - async: true, - url: ajax_url - }); - }); -}); diff --git a/demo/EV3D4/include/tank.css b/demo/EV3D4/include/tank.css deleted file mode 100644 index 83d746f..0000000 --- a/demo/EV3D4/include/tank.css +++ /dev/null @@ -1,129 +0,0 @@ -/* Entire Page - layout */ - -body { - margin: 0px; - padding: 0; - background: #FFF; - font-family: 'Armata', sans-serif; - font-size: 12px; - color: #222; -} - -.alignCenter{ - width: 960px; - margin: 0px auto; -} - -div#header { - padding-bottom: 70px; -} - -div#controls { - float: left; - width: 450px; -} - -img.button { - cursor: pointer; - border: 4px solid white; - - /* rounded corners */ - -moz-border-radius: 20px; - -webkit-border-radius: 20px; - -khtml-border-radius: 20px; - border-radius: 20px; - - -webkit-touch-callout: none; - -webkit-user-select: none; -} - -img.button:hover { - border: 4px solid black; -} - -img.button:active { - border: 4px solid red; -} - -img#ArrowUp, -img#ArrowDown { - margin-left: 137px; -} - -img#ArrowLeft { -} - -img.Clockwise { - width: 75px; - height: 75px; -} - -img.CounterClockwise { - -moz-transform: scaleX(-1); - -o-transform: scaleX(-1); - -webkit-transform: scaleX(-1); - transform: scaleX(-1); - filter: FlipH; - -ms-filter: "FlipH"; - width: 75px; - height: 75px; -} - -div#desktop-interface { - width: 520px; - float: left; - padding-top: 100px; - text-align: center; -} - -div#mobile-interface { - width: 300px; - padding-left: 600px; - padding-top: 100px; - text-align: center; -} - -div#medium-motor-spin { - width: 250px; - margin-left: 300px; - text-align: center; -} - -div#desktop-medium-motor-spin { - width: 250px; - margin-left: 450px; - text-align: center; -} - -div#tank-speed, -div#medium-motor-speed { - margin-top: 20px; - margin-bottom: 10px; -} - -div#joystick-wrapper { - float: left; - position: fixed; - top: 5px; - left: 10px; - width: 250px; - height: 250px; - -webkit-border-radius: 125px; - -moz-border-radius: 125px; - border-radius: 125px; - background: #848484; -} - -div#joystick { - width: 50px; - height: 50px; - -webkit-border-radius: 25px; - -moz-border-radius: 25px; - border-radius: 25px; - background: black; - cursor: pointer; - position: relative; - top: 100px; - left: 100px -} - diff --git a/demo/EV3D4/include/tank.png b/demo/EV3D4/include/tank.png deleted file mode 100644 index b83b552eff8ad5b1857cbb7533c95ee9bd93bb02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4436 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 auJk|8i{{W!Eza}+0000 - - - - - -Lego Tank - - -
- - -
-Desktop -

Desktop Interface

-
- -
-Mobile -

Mobile Interface

-
- -
-
- - diff --git a/demo/EV3D4/mobile.html b/demo/EV3D4/mobile.html deleted file mode 100644 index 7b5971f..0000000 --- a/demo/EV3D4/mobile.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - -Lego Tank - - - - -
-
-
-
- -
- - -
-
-
-
-
- - diff --git a/demo/EV3RSTORM/README.md b/demo/EV3RSTORM/README.md deleted file mode 100644 index 2608875..0000000 --- a/demo/EV3RSTORM/README.md +++ /dev/null @@ -1,14 +0,0 @@ -EV3RSTORM -========= - -EV3RSTORM is the most advanced of the LEGO(r) MINDSTORMS(r) Robots. -Equipped with a blasting bazooka and a spinning tri-blade, EV3RSTORM is -superior in both intelligence as well as in fighting power. - -Our version, being built with ev3dev, is also vastly more intelligent (one -could say, it has a [brain size of a planet](https://en.wikipedia.org/wiki/Marvin_(character))) -so it may be afflicted with severe depression and boredom at times. - -The build instructions may be found at the official LEGO MINDSTROMS site -[here](http://www.lego.com/en-us/mindstorms/build-a-robot/ev3rstorm). - diff --git a/demo/EV3RSTORM/ev3rstorm.py b/demo/EV3RSTORM/ev3rstorm.py deleted file mode 100644 index b7354bf..0000000 --- a/demo/EV3RSTORM/ev3rstorm.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 - -import time, random -import ev3dev.ev3 as ev3 - -random.seed( time.time() ) - -def quote(topic): - """ - Recite a random Marvin the Paranoid Android quote on the specified topic. - See https://en.wikipedia.org/wiki/Marvin_(character) - """ - - marvin_quotes = { - 'initiating' : ( - "Life? Don't talk to me about life!", - "Now I've got a headache.", - "This will all end in tears.", - ), - 'depressed' : ( - "I think you ought to know I'm feeling very depressed.", - "Incredible... it's even worse than I thought it would be.", - "I'd make a suggestion, but you wouldn't listen.", - ), - } - - ev3.Sound.speak(random.choice(marvin_quotes[topic])).wait() - -def check(condition, message): - """ - Check that condition is true, - loudly complain and throw an exception otherwise. - """ - if not condition: - ev3.Sound.speak(message).wait() - quote('depressed') - raise Exception(message) - -class ev3rstorm: - def __init__(self): - # Connect the required equipement - self.lm = ev3.LargeMotor('outB') - self.rm = ev3.LargeMotor('outC') - self.mm = ev3.MediumMotor() - - self.ir = ev3.InfraredSensor() - self.ts = ev3.TouchSensor() - self.cs = ev3.ColorSensor() - - self.screen = ev3.Screen() - - # Check if everything is attached - check(self.lm.connected, 'My left leg is missing!') - check(self.rm.connected, 'Right leg is not found!') - check(self.mm.connected, 'My left arm is not connected!') - - check(self.ir.connected, 'My eyes, I can not see!') - check(self.ts.connected, 'Touch sensor is not attached!') - check(self.cs.connected, 'Color sensor is not responding!') - - # Reset the motors - for m in (self.lm, self.rm, self.mm): - m.reset() - m.position = 0 - m.stop_action = 'brake' - - self.draw_face() - - quote('initiating') - - def draw_face(self): - w,h = self.screen.shape - y = h // 2 - - eye_xrad = 20 - eye_yrad = 30 - - pup_xrad = 10 - pup_yrad = 10 - - def draw_eye(x): - self.screen.draw.ellipse((x-eye_xrad, y-eye_yrad, x+eye_xrad, y+eye_yrad)) - self.screen.draw.ellipse((x-pup_xrad, y-pup_yrad, x+pup_xrad, y+pup_yrad), fill='black') - - draw_eye(w//3) - draw_eye(2*w//3) - - self.screen.update() - - def shoot(self, direction='up'): - """ - Shot a ball in the specified direction (valid choices are 'up' and 'down') - """ - self.mm.run_to_rel_pos(speed_sp=900, position_sp=(-1080 if direction == 'up' else 1080)) - while 'running' in self.mm.state: - time.sleep(0.1) - - def rc_loop(self): - """ - Enter the remote control loop. RC buttons on channel 1 control the - robot movement, channel 2 is for shooting things. - The loop ends when the touch sensor is pressed. - """ - - def roll(motor, led_group, speed): - """ - Generate remote control event handler. It rolls given motor into - given direction (1 for forward, -1 for backward). When motor rolls - forward, the given led group flashes green, when backward -- red. - When motor stops, the leds are turned off. - - The on_press function has signature required by RemoteControl - class. It takes boolean state parameter; True when button is - pressed, False otherwise. - """ - def on_press(state): - if state: - # Roll when button is pressed - motor.run_forever(speed_sp=speed) - ev3.Leds.set_color(led_group, - ev3.Leds.GREEN if speed > 0 else ev3.Leds.RED) - else: - # Stop otherwise - motor.stop() - ev3.Leds.set_color(led_group, ev3.Leds.BLACK) - - return on_press - - rc1 = ev3.RemoteControl(self.ir, 1) - rc1.on_red_up = roll(self.lm, ev3.Leds.LEFT, 900) - rc1.on_red_down = roll(self.lm, ev3.Leds.LEFT, -900) - rc1.on_blue_up = roll(self.rm, ev3.Leds.RIGHT, 900) - rc1.on_blue_down = roll(self.rm, ev3.Leds.RIGHT, -900) - - - def shoot(direction): - def on_press(state): - if state: self.shoot(direction) - return on_press - - rc2 = ev3.RemoteControl(self.ir, 2) - rc2.on_red_up = shoot('up') - rc2.on_blue_up = shoot('up') - rc2.on_red_down = shoot('down') - rc2.on_blue_down = shoot('down') - - # Now that the event handlers are assigned, - # lets enter the processing loop: - while not self.ts.is_pressed: - rc1.process() - rc2.process() - time.sleep(0.1) - - -if __name__ == '__main__': - Marvin = ev3rstorm() - Marvin.rc_loop() diff --git a/demo/EXPLOR3R/README.rst b/demo/EXPLOR3R/README.rst deleted file mode 100644 index 146d6a9..0000000 --- a/demo/EXPLOR3R/README.rst +++ /dev/null @@ -1,3 +0,0 @@ -The examples in this folder, unless stated otherwise, are based on Explor3r -robot by Laurens Valk. The assembling instructions for the robot may be found -here: http://robotsquare.com/2015/10/06/explor3r-building-instructions. diff --git a/demo/EXPLOR3R/auto-drive.py b/demo/EXPLOR3R/auto-drive.py deleted file mode 100755 index bae8fe3..0000000 --- a/demo/EXPLOR3R/auto-drive.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 - -# ----------------------------------------------------------------------------- -# Copyright (c) 2015 Denis Demidov -# -# 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. -# ----------------------------------------------------------------------------- - -# In this demo an Explor3r robot with touch sensor attachement drives -# autonomously. It drives forward until an obstacle is bumped (determined by -# the touch sensor), then turns in a random direction and continues. The robot -# slows down when it senses obstacle ahead (with the infrared sensor). -# -# The program may be stopped by pressing any button on the brick. -# -# This demonstrates usage of motors, sound, sensors, buttons, and leds. - -from time import sleep -from random import choice, randint - -from ev3dev.auto import * - -# Connect two large motors on output ports B and C: -motors = [LargeMotor(address) for address in (OUTPUT_B, OUTPUT_C)] - -# Every device in ev3dev has `connected` property. Use it to check that the -# device has actually been connected. -assert all([m.connected for m in motors]), \ - "Two large motors should be connected to ports B and C" - -# Connect infrared and touch sensors. -ir = InfraredSensor(); assert ir.connected -ts = TouchSensor(); assert ts.connected - -print('Robot Starting') - -# We will need to check EV3 buttons state. -btn = Button() - -def start(): - """ - Start both motors. `run-direct` command will allow to vary motor - performance on the fly by adjusting `duty_cycle_sp` attribute. - """ - for m in motors: - m.run_direct() - -def backup(): - """ - Back away from an obstacle. - """ - - # Sound backup alarm. - Sound.tone([(1000, 500, 500)] * 3) - - # Turn backup lights on: - for light in (Leds.LEFT, Leds.RIGHT): - Leds.set_color(light, Leds.RED) - - # Stop both motors and reverse for 1.5 seconds. - # `run-timed` command will return immediately, so we will have to wait - # until both motors are stopped before continuing. - for m in motors: - m.stop(stop_action='brake') - m.run_timed(speed_sp=-500, time_sp=1500) - - # When motor is stopped, its `state` attribute returns empty list. - # Wait until both motors are stopped: - while any(m.state for m in motors): - sleep(0.1) - - # Turn backup lights off: - for light in (Leds.LEFT, Leds.RIGHT): - Leds.set_color(light, Leds.GREEN) - -def turn(): - """ - Turn the robot in random direction. - """ - - # We want to turn the robot wheels in opposite directions from 1/4 to 3/4 - # of a second. Use `random.choice()` to decide which wheel will turn which - # way. - power = choice([(1, -1), (-1, 1)]) - t = randint(250, 750) - - for m, p in zip(motors, power): - m.run_timed(speed_sp = p * 750, time_sp = t) - - # Wait until both motors are stopped: - while any(m.state for m in motors): - sleep(0.1) - -# Run the robot until a button is pressed. -start() -while not btn.any(): - - if ts.is_pressed: - # We bumped an obstacle. - # Back away, turn and go in other direction. - backup() - turn() - start() - - # Infrared sensor in proximity mode will measure distance to the closest - # object in front of it. - distance = ir.proximity - - if distance > 60: - # Path is clear, run at full speed. - dc = 95 - else: - # Obstacle ahead, slow down. - dc = 30 - - for m in motors: - m.duty_cycle_sp = dc - - sleep(0.1) - -# Stop the motors before exiting. -for m in motors: - m.stop() diff --git a/demo/EXPLOR3R/remote-control.py b/demo/EXPLOR3R/remote-control.py deleted file mode 100755 index f591334..0000000 --- a/demo/EXPLOR3R/remote-control.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 - -# ----------------------------------------------------------------------------- -# Copyright (c) 2015 Denis Demidov -# -# 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. -# ----------------------------------------------------------------------------- - -# This demo shows how to remote control an Explor3r robot with touch sensor -# attachment. -# -# Red buttons control left motor, blue buttons control right motor. -# Leds are used to indicate movement direction. -# Whenever an obstacle is bumped, robot backs away and apologises. - -from time import sleep -from ev3dev.auto import * - -# Connect two large motors on output ports B and C -lmotor, rmotor = [LargeMotor(address) for address in (OUTPUT_B, OUTPUT_C)] - -# Check that the motors are actually connected -assert lmotor.connected -assert rmotor.connected - -# Connect touch sensor and remote control -ts = TouchSensor(); assert ts.connected -rc = RemoteControl(); assert rc.connected - -# Initialize button handler -button = Button() - -# Turn leds off -Leds.all_off() - -def roll(motor, led_group, direction): - """ - Generate remote control event handler. It rolls given motor into given - direction (1 for forward, -1 for backward). When motor rolls forward, the - given led group flashes green, when backward -- red. When motor stops, the - leds are turned off. - - The on_press function has signature required by RemoteControl class. - It takes boolean state parameter; True when button is pressed, False - otherwise. - """ - def on_press(state): - if state: - # Roll when button is pressed - motor.run_forever(speed_sp=600*direction) - Leds.set_color(led_group, direction > 0 and Leds.GREEN or Leds.RED) - else: - # Stop otherwise - motor.stop(stop_action='brake') - Leds.set(led_group, brightness_pct=0) - - return on_press - -# Assign event handler to each of the remote buttons -rc.on_red_up = roll(lmotor, Leds.LEFT, 1) -rc.on_red_down = roll(lmotor, Leds.LEFT, -1) -rc.on_blue_up = roll(rmotor, Leds.RIGHT, 1) -rc.on_blue_down = roll(rmotor, Leds.RIGHT, -1) - -# Enter event processing loop -while not button.any(): - rc.process() - - # Backup when bumped an obstacle - if ts.is_pressed: - Sound.speak('Oops, excuse me!') - - for motor in (lmotor, rmotor): - motor.stop(stop_action='brake') - - # Turn red lights on - for led in (Leds.LEFT, Leds.RIGHT): - Leds.set_color(led, Leds.RED) - - # Run both motors backwards for 0.5 seconds - for motor in (lmotor, rmotor): - motor.run_timed(speed_sp=-600, time_sp=500) - - # Wait 0.5 seconds while motors are rolling - sleep(0.5) - - Leds.all_off() - - sleep(0.01) diff --git a/demo/MINDCUB3R/.gitignore b/demo/MINDCUB3R/.gitignore deleted file mode 100644 index 06cf653..0000000 --- a/demo/MINDCUB3R/.gitignore +++ /dev/null @@ -1 +0,0 @@ -cache diff --git a/demo/MINDCUB3R/README.md b/demo/MINDCUB3R/README.md deleted file mode 100644 index 5f8a45e..0000000 --- a/demo/MINDCUB3R/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# MINDCUB3R - -## Installation -### Installing kociemba -The kociemba program produces a sequence of moves used to solve -a 3x3x3 rubiks cube. -``` -$ sudo apt-get install build-essential libffi-dev -$ cd ~/ -$ git clone https://github.com/dwalton76/kociemba.git -$ cd ~/kociemba/kociemba/ckociemba/ -$ make -$ sudo make install -``` - -### Installing rubiks-color-resolver -When the cube is scanned we get the RGB (red, green, blue) value for -all 54 squares of a 3x3x3 cube. rubiks-color-resolver analyzes those RGB -values to determine which of the six possible cube colors is the color for -each square. -``` -$ sudo apt-get install python3-pip -$ sudo pip3 install git+https://github.com/dwalton76/rubiks-color-resolver.git -``` - -### Installing the MINDCUB3R demo -We must git clone the ev3dev-lang-python repository. MINDCUB3R is included -in the demo directory. -``` -$ cd ~/ -$ git clone https://github.com/rhempel/ev3dev-lang-python.git -$ cd ~/ev3dev-lang-python/demo/MINDCUB3R/ -$ kociemba DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD -``` - -## Running MINDCUB3R -``` -$ cd ~/ev3dev-lang-python/demo/MINDCUB3R/ -$ ./rubiks.py -``` - -## About kociemba -You may have noticed that the -`kociemba DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD` -step of the install looks a little odd. The "DRLUU..." string is a -representation of the colors of each of the 54 squares of a 3x3x3 cube. So -the D at the beginning means that square `#1` is the same color as the middle -square of the Down side (the bottom), the R means that square `#2` is the same -color as the middle square of the Right side, etc. The kociemba program takes -that color data and returns a sequence of moves that can be used to solve the -cube. - -``` -$ kociemba DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD -D2 R' D' F2 B D R2 D2 R' F2 D' F2 U' B2 L2 U2 D R2 U -$ -``` - -Running the kociemba program is part of the install process because the first -time you run it, it takes about 30 seconds to build a series of tables that -it caches to the filesystem. After that first run it is nice and fast. diff --git a/demo/MINDCUB3R/__init__.py b/demo/MINDCUB3R/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/demo/MINDCUB3R/rubiks.py b/demo/MINDCUB3R/rubiks.py deleted file mode 100755 index c4088ad..0000000 --- a/demo/MINDCUB3R/rubiks.py +++ /dev/null @@ -1,595 +0,0 @@ -#!/usr/bin/env python3 - -from ev3dev.auto import OUTPUT_A, OUTPUT_B, OUTPUT_C, InfraredSensor -from ev3dev.helper import LargeMotor, MediumMotor, ColorSensor, MotorStall -from pprint import pformat -from rubikscolorresolver import RubiksColorSolverGeneric -from subprocess import check_output -from time import sleep -import json -import logging -import os -import signal -import sys -import time - -log = logging.getLogger(__name__) - - -class ScanError(Exception): - pass - - -class Rubiks(object): - scan_order = [ - 5, 9, 6, 3, 2, 1, 4, 7, 8, - 23, 27, 24, 21, 20, 19, 22, 25, 26, - 50, 54, 51, 48, 47, 46, 49, 52, 53, - 14, 10, 13, 16, 17, 18, 15, 12, 11, - 41, 43, 44, 45, 42, 39, 38, 37, 40, - 32, 34, 35, 36, 33, 30, 29, 28, 31] - - hold_cube_pos = 85 - rotate_speed = 400 - flip_speed = 300 - flip_speed_push = 400 - corner_to_edge_diff = 60 - - def __init__(self): - self.shutdown = False - self.flipper = LargeMotor(OUTPUT_A) - self.turntable = LargeMotor(OUTPUT_B) - self.colorarm = MediumMotor(OUTPUT_C) - self.color_sensor = ColorSensor() - self.color_sensor.mode = self.color_sensor.MODE_RGB_RAW - self.infrared_sensor = InfraredSensor() - self.cube = {} - self.init_motors() - self.state = ['U', 'D', 'F', 'L', 'B', 'R'] - self.rgb_solver = None - signal.signal(signal.SIGTERM, self.signal_term_handler) - signal.signal(signal.SIGINT, self.signal_int_handler) - - def init_motors(self): - - for x in (self.flipper, self.turntable, self.colorarm): - if not x.connected: - log.error("%s is not connected" % x) - sys.exit(1) - x.reset() - - log.info("Initialize flipper %s" % self.flipper) - self.flipper.run_forever(speed_sp=-50, stop_action='hold') - self.flipper.wait_for_stop() - self.flipper.stop() - self.flipper.reset() - self.flipper.stop(stop_action='hold') - - log.info("Initialize colorarm %s" % self.colorarm) - self.colorarm.run_forever(speed_sp=500, stop_action='hold') - self.colorarm.wait_for_stop() - self.colorarm.stop() - self.colorarm.reset() - self.colorarm.stop(stop_action='hold') - - log.info("Initialize turntable %s" % self.turntable) - self.turntable.reset() - self.turntable.stop(stop_action='hold') - - def shutdown_robot(self): - log.info('Shutting down') - self.shutdown = True - - if self.rgb_solver: - self.rgb_solver.shutdown = True - - for x in (self.flipper, self.turntable, self.colorarm): - x.shutdown = True - - for x in (self.flipper, self.turntable, self.colorarm): - x.stop(stop_action='brake') - - def signal_term_handler(self, signal, frame): - log.error('Caught SIGTERM') - self.shutdown_robot() - - def signal_int_handler(self, signal, frame): - log.error('Caught SIGINT') - self.shutdown_robot() - - def apply_transformation(self, transformation): - self.state = [self.state[t] for t in transformation] - - def rotate_cube(self, direction, nb): - current_pos = self.turntable.position - final_pos = 135 * round((self.turntable.position + (270 * direction * nb)) / 135.0) - log.info("rotate_cube() direction %s, nb %s, current_pos %d, final_pos %d" % (direction, nb, current_pos, final_pos)) - - if self.flipper.position > 35: - self.flipper_away() - - self.turntable.run_to_abs_pos(position_sp=final_pos, - speed_sp=Rubiks.rotate_speed, - stop_action='hold', - ramp_up_sp=0) - self.turntable.wait_for_running() - self.turntable.wait_for_position(final_pos) - self.turntable.wait_for_stop() - - if nb >= 1: - for i in range(nb): - if direction > 0: - transformation = [0, 1, 5, 2, 3, 4] - else: - transformation = [0, 1, 3, 4, 5, 2] - self.apply_transformation(transformation) - - def rotate_cube_1(self): - self.rotate_cube(1, 1) - - def rotate_cube_2(self): - self.rotate_cube(1, 2) - - def rotate_cube_3(self): - self.rotate_cube(-1, 1) - - def rotate_cube_blocked(self, direction, nb): - - # Move the arm down to hold the cube in place - self.flipper_hold_cube() - - # OVERROTATE depends on lot on Rubiks.rotate_speed - current_pos = self.turntable.position - OVERROTATE = 18 - final_pos = int(135 * round((current_pos + (270 * direction * nb)) / 135.0)) - temp_pos = int(final_pos + (OVERROTATE * direction)) - - log.info("rotate_cube_blocked() direction %s nb %s, current pos %s, temp pos %s, final pos %s" % - (direction, nb, current_pos, temp_pos, final_pos)) - - self.turntable.run_to_abs_pos(position_sp=temp_pos, - speed_sp=Rubiks.rotate_speed, - stop_action='hold', - ramp_up_sp=0) - self.turntable.wait_for_running() - self.turntable.wait_for_position(temp_pos) - self.turntable.wait_for_stop() - - self.turntable.run_to_abs_pos(position_sp=final_pos, - speed_sp=int(Rubiks.rotate_speed/4), - stop_action='hold', - ramp_up_sp=0) - self.turntable.wait_for_running() - self.turntable.wait_for_position(final_pos, stall_ok=True) - self.turntable.wait_for_stop() - - def rotate_cube_blocked_1(self): - self.rotate_cube_blocked(1, 1) - - def rotate_cube_blocked_2(self): - self.rotate_cube_blocked(1, 2) - - def rotate_cube_blocked_3(self): - self.rotate_cube_blocked(-1, 1) - - def flipper_hold_cube(self, speed=300): - current_position = self.flipper.position - - # Push it forward so the cube is always in the same position - # when we start the flip - if (current_position <= Rubiks.hold_cube_pos - 10 or - current_position >= Rubiks.hold_cube_pos + 10): - self.flipper.run_to_abs_pos(position_sp=Rubiks.hold_cube_pos, - ramp_down_sp=400, - speed_sp=speed) - self.flipper.wait_for_running() - self.flipper.wait_for_position(Rubiks.hold_cube_pos) - self.flipper.wait_for_stop() - sleep(0.05) - - def flipper_away(self, speed=300): - """ - Move the flipper arm out of the way - """ - log.info("flipper_away()") - self.flipper.run_to_abs_pos(position_sp=0, - ramp_down_sp=400, - speed_sp=speed) - self.flipper.wait_for_running() - - try: - self.flipper.wait_for_position(0) - self.flipper.wait_for_stop() - except MotorStall: - self.flipper.stop() - - def flip(self): - """ - Motors will sometimes stall if you call run_to_abs_pos() multiple - times back to back on the same motor. To avoid this we call a 50ms - sleep in flipper_hold_cube() and after each run_to_abs_pos() below. - - We have to sleep after the 2nd run_to_abs_pos() because sometimes - flip() is called back to back. - """ - log.info("flip()") - - if self.shutdown: - return - - # Move the arm down to hold the cube in place - self.flipper_hold_cube() - - # Grab the cube and pull back - self.flipper.run_to_abs_pos(position_sp=190, - ramp_up_sp=200, - ramp_down_sp=0, - speed_sp=self.flip_speed) - self.flipper.wait_for_running() - self.flipper.wait_for_position(190) - self.flipper.wait_for_stop() - sleep(0.05) - - # At this point the cube is at an angle, push it forward to - # drop it back down in the turntable - self.flipper.run_to_abs_pos(position_sp=Rubiks.hold_cube_pos, - ramp_up_sp=200, - ramp_down_sp=400, - speed_sp=self.flip_speed_push) - self.flipper.wait_for_running() - self.flipper.wait_for_position(Rubiks.hold_cube_pos) - self.flipper.wait_for_stop() - sleep(0.05) - - transformation = [2, 4, 1, 3, 0, 5] - self.apply_transformation(transformation) - - def colorarm_middle(self): - log.info("colorarm_middle()") - self.colorarm.run_to_abs_pos(position_sp=-750, - speed_sp=600, - stop_action='hold') - self.colorarm.wait_for_running() - self.colorarm.wait_for_position(-750) - self.colorarm.wait_for_stop() - - def colorarm_corner(self, square_index): - log.info("colorarm_corner(%d)" % square_index) - position_target = -580 - - if square_index == 1: - position_target += 20 - elif square_index == 3: - pass - elif square_index == 5: - position_target -= 20 - elif square_index == 7: - pass - else: - raise ScanError("colorarm_corner was given unsupported square_index %d" % square_index) - - self.colorarm.run_to_abs_pos(position_sp=position_target, - speed_sp=600, - stop_action='hold') - - def colorarm_edge(self, square_index): - log.info("colorarm_edge(%d)" % square_index) - position_target = -640 - - if square_index == 2: - pass - elif square_index == 4: - position_target -= 20 - elif square_index == 6: - position_target -= 20 - elif square_index == 8: - pass - else: - raise ScanError("colorarm_edge was given unsupported square_index %d" % square_index) - - self.colorarm.run_to_abs_pos(position_sp=position_target, - speed_sp=600, - stop_action='hold') - - def colorarm_remove(self): - log.info("colorarm_remove()") - self.colorarm.run_to_abs_pos(position_sp=0, - speed_sp=600) - self.colorarm.wait_for_running() - try: - self.colorarm.wait_for_position(0) - self.colorarm.wait_for_stop() - except MotorStall: - self.colorarm.stop() - - def colorarm_remove_halfway(self): - log.info("colorarm_remove_halfway()") - self.colorarm.run_to_abs_pos(position_sp=-400, - speed_sp=600) - self.colorarm.wait_for_running() - self.colorarm.wait_for_position(-400) - self.colorarm.wait_for_stop() - - def scan_middle(self, face_number): - log.info("scan_middle() %d/6" % face_number) - - if self.flipper.position > 35: - self.flipper_away() - - self.colorarm_middle() - log.info(self.color_sensor.rgb()) - self.colorarm_remove_halfway() - - def scan_middles(self): - """ - Used once to get the RGB values of the middle squares to - populate the crayola_colors in rubiks_rgb_solver.py - """ - log.info("scan_middle()") - self.colors = {} - self.k = 0 - self.scan_middle(1) - raw_input('Paused') - - self.flip() - self.scan_middle(2) - raw_input('Paused') - - self.flip() - self.scan_middle(3) - raw_input('Paused') - - self.rotate_cube(-1, 1) - self.flip() - self.scan_middle(4) - raw_input('Paused') - - self.rotate_cube(1, 1) - self.flip() - self.scan_middle(5) - raw_input('Paused') - - self.flip() - self.scan_middle(6) - raw_input('Paused') - - def scan_face(self, face_number): - log.info("scan_face() %d/6" % face_number) - - if self.shutdown: - return - - if self.flipper.position > 35: - self.flipper_away() - - self.colorarm_middle() - self.colors[int(Rubiks.scan_order[self.k])] = tuple(self.color_sensor.rgb()) - - self.k += 1 - i = 1 - self.colorarm_corner(i) - - # The gear ratio is 3:1 so 1080 is one full rotation - self.turntable.reset() - self.turntable.run_to_abs_pos(position_sp=1080, - speed_sp=Rubiks.rotate_speed, - stop_action='hold') - - while True: - current_position = self.turntable.position - - # 135 is 1/8 of full rotation - if current_position >= (i * 135): - current_color = tuple(self.color_sensor.rgb()) - self.colors[int(Rubiks.scan_order[self.k])] = current_color - # log.info("%s: i %d, current_position %d, current_color %s" % - # (self.turntable, i, current_position, current_color)) - - i += 1 - self.k += 1 - - if i == 9: - # Last face, move the color arm all the way out of the way - if face_number == 6: - self.colorarm_remove() - - # Move the color arm far enough away so that the flipper - # arm doesn't hit it - else: - self.colorarm_remove_halfway() - - break - - elif i % 2: - self.colorarm_corner(i) - else: - self.colorarm_edge(i) - - if self.shutdown: - return - - if i < 9: - raise ScanError('i is %d..should be 9' % i) - - self.turntable.wait_for_position(1080) - self.turntable.stop() - self.turntable.reset() - log.info("\n") - - def scan(self): - log.info("scan()") - self.colors = {} - self.k = 0 - self.scan_face(1) - - self.flip() - self.scan_face(2) - - self.flip() - self.scan_face(3) - - self.rotate_cube(-1, 1) - self.flip() - self.scan_face(4) - - self.rotate_cube(1, 1) - self.flip() - self.scan_face(5) - - self.flip() - self.scan_face(6) - - if self.shutdown: - return - - log.info("RGB json:\n%s\n" % json.dumps(self.colors)) - self.rgb_solver = RubiksColorSolverGeneric(3) - self.rgb_solver.enter_scan_data(self.colors) - self.rgb_solver.crunch_colors() - self.cube_kociemba = self.rgb_solver.cube_for_kociemba_strict() - log.info("Final Colors (kociemba): %s" % ''.join(self.cube_kociemba)) - - # This is only used if you want to rotate the cube so U is on top, F is - # in the front, etc. You would do this if you were troubleshooting color - # detection and you want to pause to compare the color pattern on the - # cube vs. what we think the color pattern is. - ''' - log.info("Position the cube so that U is on top, F is in the front, etc...to make debugging easier") - self.rotate_cube(-1, 1) - self.flip() - self.flipper_away() - self.rotate_cube(1, 1) - raw_input('Paused') - ''' - - def move(self, face_down): - log.info("move() face_down %s" % face_down) - - position = self.state.index(face_down) - actions = { - 0: ["flip", "flip"], - 1: [], - 2: ["rotate_cube_2", "flip"], - 3: ["rotate_cube_1", "flip"], - 4: ["flip"], - 5: ["rotate_cube_3", "flip"] - }.get(position, None) - - for a in actions: - - if self.shutdown: - break - - getattr(self, a)() - - def run_kociemba_actions(self, actions): - log.info('Action (kociemba): %s' % ' '.join(actions)) - total_actions = len(actions) - - for (i, a) in enumerate(actions): - - if self.shutdown: - break - - if a.endswith("'"): - face_down = list(a)[0] - rotation_dir = 1 - elif a.endswith("2"): - face_down = list(a)[0] - rotation_dir = 2 - else: - face_down = a - rotation_dir = 3 - - log.info("Move %d/%d: %s%s (a %s)" % (i, total_actions, face_down, rotation_dir, pformat(a))) - self.move(face_down) - - if rotation_dir == 1: - self.rotate_cube_blocked_1() - elif rotation_dir == 2: - self.rotate_cube_blocked_2() - elif rotation_dir == 3: - self.rotate_cube_blocked_3() - log.info("\n") - - def resolve(self): - - if rub.shutdown: - return - - cmd = ['kociemba', ''.join(map(str, self.cube_kociemba))] - output = check_output(cmd).decode('ascii') - - if 'ERROR' in output: - msg = "'%s' returned the following error\n%s\n" % (' '.join(cmd), output) - log.error(msg) - print(msg) - sys.exit(1) - - actions = output.strip().split() - self.run_kociemba_actions(actions) - self.cube_done() - - def cube_done(self): - self.flipper_away() - - def wait_for_cube_insert(self): - rubiks_present = 0 - rubiks_present_target = 10 - log.info('wait for cube...to be inserted') - - while True: - - if self.shutdown: - break - - dist = self.infrared_sensor.proximity - - # It is odd but sometimes when the cube is inserted - # the IR sensor returns a value of 100...most of the - # time it is just a value less than 50 - if dist < 50 or dist == 100: - rubiks_present += 1 - log.info("wait for cube...distance %d, present for %d/%d" % - (dist, rubiks_present, rubiks_present_target)) - else: - if rubiks_present: - log.info('wait for cube...cube removed (%d)' % dist) - rubiks_present = 0 - - if rubiks_present >= rubiks_present_target: - log.info('wait for cube...cube found and stable') - break - - time.sleep(0.1) - - -if __name__ == '__main__': - - # logging.basicConfig(filename='rubiks.log', - logging.basicConfig(level=logging.INFO, - format='%(asctime)s %(filename)12s %(levelname)8s: %(message)s') - log = logging.getLogger(__name__) - - # Color the errors and warnings in red - logging.addLevelName(logging.ERROR, "\033[91m %s\033[0m" % logging.getLevelName(logging.ERROR)) - logging.addLevelName(logging.WARNING, "\033[91m %s\033[0m" % logging.getLevelName(logging.WARNING)) - - rub = Rubiks() - - try: - rub.wait_for_cube_insert() - - # Push the cube to the right so that it is in the expected - # position when we begin scanning - rub.flipper_hold_cube(100) - rub.flipper_away(100) - - rub.scan() - rub.resolve() - rub.shutdown_robot() - - except Exception as e: - log.exception(e) - rub.shutdown_robot() - sys.exit(1) diff --git a/demo/R3PTAR/README.md b/demo/R3PTAR/README.md deleted file mode 100644 index 6466009..0000000 --- a/demo/R3PTAR/README.md +++ /dev/null @@ -1,16 +0,0 @@ -R3PTAR -====== - -One of the most loved robots, the standing 35 cm. / 13,8 inch tall R3PTAR robot -slithers across the floor like a real cobra, and strikes at lightning speed -with it’s pointed red fangs. - -Coincidentally, its also a nice example to demonstrate how to use threads in -Python. - -**Building instructions**: http://www.lego.com/en-us/mindstorms/build-a-robot/r3ptar - -**Resources**: - -* `rattle-snake.wav`: https://www.freesound.org/people/7h3_lark/sounds/268580/ -* `snake-hiss.wav`: https://www.freesound.org/people/Reitanna/sounds/343928/ diff --git a/demo/R3PTAR/r3ptar.py b/demo/R3PTAR/r3ptar.py deleted file mode 100755 index 8b9d658..0000000 --- a/demo/R3PTAR/r3ptar.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 - -import time -import threading -import signal -import ev3dev.ev3 as ev3 - -def tail_waggler(done): - """ - This is the first thread of execution that will be responsible for waggling - r3ptar's tail every couple of seconds. - """ - m = ev3.MediumMotor(); assert m.connected - - while not done.is_set(): - m.run_timed(speed_sp=90, time_sp=1000, stop_action='coast') - time.sleep(1) - ev3.Sound.play('rattle-snake.wav').wait() - m.run_timed(speed_sp=-90, time_sp=1000, stop_action='coast') - time.sleep(2) - -def hand_biter(done): - """ - This is the second thread of execution. It will constantly poll the - infrared sensor for proximity info and bite anything that gets too close. - """ - m = ev3.LargeMotor('outD'); assert m.connected - s = ev3.InfraredSensor(); assert s.connected - - m.run_timed(speed_sp=-200, time_sp=1000, stop_action='brake') - - while not done.is_set(): - # Wait until something (a hand?!) gets too close: - while s.proximity > 30: - if done.is_set(): return - time.sleep(0.1) - - # Bite it! Also, don't forget to hiss: - ev3.Sound.play('snake-hiss.wav') - m.run_timed(speed_sp=600, time_sp=500, stop_action='brake') - time.sleep(0.6) - m.run_timed(speed_sp=-200, time_sp=500, stop_action='brake') - time.sleep(1) - -# The 'done' event will be used to signal the threads to stop: -done = threading.Event() - -# We also need to catch SIGINT (keyboard interrup) and SIGTERM (termination -# signal from brickman) and exit gracefully: -def signal_handler(signal, frame): - done.set() - -signal.signal(signal.SIGINT, signal_handler) -signal.signal(signal.SIGTERM, signal_handler) - -# Now that we have the worker functions defined, lets run those in separate -# threads. -tail = threading.Thread(target=tail_waggler, args=(done,)) -head = threading.Thread(target=hand_biter, args=(done,)) - -tail.start() -head.start() - -# The main thread will wait for the 'back' button to be pressed. When that -# happens, it will signal the worker threads to stop and wait for their -# completion. -btn = ev3.Button() -while not btn.backspace and not done.is_set(): - time.sleep(0.1) - -done.set() -tail.join() -head.join() diff --git a/demo/R3PTAR/rattle-snake.wav b/demo/R3PTAR/rattle-snake.wav deleted file mode 100644 index 10ed3ff22c58a0febbcfbc5fe4c7f53a1994b068..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 241694 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ⅈTD#bf%}1 z8m}zprsg4 zHgPo&UfT_8?mH-4RZa-YvEiy=VhztWxQ8Z5+QrQS_YH@@UT!P49lj4tzh?NX;i|qT zuXTF>?Gc3^X=dpO*yW6IuLk=MT4h7y<#ob((GF8&`+x=cK|~|{pi{U4l>|!I1z1n`JiLy&3OT$lgj6CI z87gL@U$A$8sXj*}xZ&ar`W)RrKEuJ%O*vcHh~CEzLSvNE5H7A{3&lOeFsc>kpldlT zn+v1(cS(hWswPS?fKgV90G>@)51f%(yd}al{4nel-ry~XwPFzG7u!i|;I&jKtr2$m zFDs=;SGgnn1iLMblj>vFcM<%J5uy6~HoROpEi4HaU`fy1s78iM}E%;f`EGSLeO zfsV*oTuW&s`T?0OpG5XyOU1@a9CQhr1NzYO;9=ZoHWKYg6oH(15b|E^!!wJZ_%&l68W7_uDk-y#GXhQ z@&)7uwo8|kZqQzM5855ckcUb0fK|~7Jx40xI5%Xz-6EY z?S}bKXW)$J4m_330D-Y9_|+c#jgG)|&>2u1hXIbG2u{Q6py}W{k3+3My1@$k0au|c z=nU{o&Qmz>m~8}piA>PE*aYZ|2uMh{po@SBnF%HbSCll!0zZVeK`F{@d8e`*8V-!E zo1tRBbrj`!fWWsB83ApVJA*BLlrjNX0t^6&%6KJ8xhB1a0^mw&1bvbBOG~AL&_gr~ zX$sO7Pvnh)R@sg&2d~6f3&EN8!YUF3gmk5YN{=WhjgAl z%C{vg>VeQ`aVoS~*vW9nHMJY9CqIGW#FNZ31*PJ!%g`y*E=^|4$~s~>G*_&|hf}%y zQ|2Q~!-b-k5a|U{j=z_zhVP0;$IV9O zJE)ApW^)-#7(Pbz0Jci;$PJ$I!^mWn7H@#}hMNSwIcuVaG>5TX_EN@!j6i_Z5&M z&PmG*E%ioUhUX$&D1c0;F5N&fxLjM4|C>G*Gb z9Q>rpKJV=b4?q@^Ia(b3;=S!31PSsrq)eNQXZfe{dDwd)&o`4w z(M?k}3!ow$urO70q^^~GjGuyCg@V03_)8c})*%Ly%Y-<4{Xm(znRYUiEDeL-01x6I zxIWzno)7IrXNZ3S6ubqyCWb3KrKeW#7kx3{-&XWPf#q?kb7`PG+z3_SgnB+viyiJM zV!~vFjyK##&jot2dpMc)Ds4tOL-%D5YJwK{fnQ!IV_4c>*h;dIp-asYr9Y5H_e<(7nNVcW@P|=k}l$Ac`xDn z=q&MqTt+WYav2*no<8K;z}qzw$k~At;C^la==LNVbm||0$C8oSC5;xQQX}x~>`DBq zsvQ^NKTnR)1`FThRjS#_Z|(|q9VP=aslP(B!h1VJ^)SrvUbg3{-JxaBTj4wYBM|9W zq*@dj1m6fOpccz>Z3@~s{2prdQREAJhx0YJN#~}{fxG*O(v(VBN{vUzZUuwo;-G+% z;#1V4G^c#$xuNs`<&4taZ~@6K9Vea+3n$;W8zS4O+OB(wJ3I+JUN#W<8PbVY>`(DH z{5uF5hUsVWYpbqFHwj+Us{V^OtTfDtvE^td8A6RICZ97XCx`oCUV*L_QL~d6ZH@Je zM1q)nSQCFuQ?uCQ#5Hw<<&IIomZ;rq11HWcalZ{54b6${>zn1;K`dnhMJgpKDNFao zohcvpyv`25hZ;nhCKQd}MI^6m1fz$DYH)g^>4hdL^LjAvFS*u+^cZ(C+e`>`7uf(Vy%H&8xcQ zYY<#aHLdK6{0>fM_f~zx)@YssKh#=4q<`zdsn^taXAIlWTwC|qb{f4r`-ezka?JHwYc?|Sx`;zKUWvj1>kI_P!vptsU*gj0wLUd7nl3walH zfF6V0MRo>ydHcc}>0aDQ_M!2idTm9zRia-LL!|Sjo%GDII6H4N>m+Aw{m`D*l0K^w@A z*d7*IX3nf9%?xiy^&}knS%JBw^Mu;5cmp#~Ms9xXquk&HtZ1Uo}_1=v&t>g!f zE~F7XD03|HA!Z1hQc+Y(Oqlh}_G?qfeyu;?i0&1h{PSn-*tp*Mr6q63uQew6{`Y5J zPm&NwnX)rwC8Ab?|FQGGWJ_brvz)6SW8w*&YaOGSL|O_;?8hA=g-AVi>)j7^@jS!7WZdcBoeXpZMEft;nhobyq`VQNgtg#K)-Q5dNLy8C$ZpfKBJH=S$duT@0Mde*c6k=%-h!`& zo?_xTN2FfYqcF{rV*0=?^+t!zFc{?-d_zW4HlMkk(AqTC_b;7*9?Mx(d@C|P>WR33 zuwjkLeip;}9=c^hb3=h8%Aqd}A|^?f%7z3lY)nYUGZu2^@hzoM#Q&NWYj)@O0u8Yj zB`X5O$sdA_7Y-6!+TRshN*hGpitgy$Dy*iWE8>bz1SKV8**CGPEz?}xzmFs~)QN@F z)<~UIA^#l2pC|nAJk5O?J1ZpZTZZ#g?V946-wVS0iT`F+UKA|Ca8E z(NvWexD_)3wv?||rE8v*jPMAFb@Xg$gi@wGTl(I6G`7${lng>|hOw2&S+62uVjfrC z7p2HC?xne7Lw`pRd2_4qdJQQ4hnZ-dXt5U-tO^+wwAb^KI$nHQ zf4Wck>x3=P4yYK4udvY886{UjO2eYvq;$~G+wq{dNznZ;Ju}(=x224~mN@~dUgL~@ zN#%z?3G=)1e#q~JK0(PG@99g2xO!^*4HqI8f7MiGp_2-CD+4>64Ego7t8)O>z`9Zn zZcruD)t1;joX78EwLsNeK{V57RYV=`j_?4I&Rc*dpa;_uhQoSP56TAxfBPcrz zi8dY#Iv9G(G`FnXufKgOkg=wH38$kw+XsG}U3Q-+Hi8vt#6AD4oUC$_5Q1*h!?BjQ zf7z(F4?a|bhu7;_uVzFeSM%2+|Ma8gM;wp*6E%yN{yQOK2QyjIBy2|HUgID~a`sX8 z18gO95?dA%6SdwptpFqeT&b{BM_ccpJR|>2?uA@nHm<+7+gdE>L!-hK-esOKy17k0 zgdeHwR&l$+RI-K0t8*!GnQx;1tnX{yQG0lVF0P;9Hu2s&`rYB|W-&997pPjo_o3RE z30Z&Xn^l{q?yq^TIb7)ZJQe;Y;;^c*8q8z;&U}WyBf|cFEa|nzLSdO#9548G{ssEX znxVR{r9rla{uS;LYG*=`X^pG4vlBnWo)>VK--WA)WOk@`X<54WX-I=mH%P@l6MI_w zR%ulef}HpebP}cuJhNTrJ5U|yk@P{b1~nnbKAs>ee&= zVmQ4DjwV);wY~G45%OH^ReUPC2<^iR^Q=N^qOFBXs1zK9Mpaz*aKRT@Y*+8n$Q1`GIY(=McijL7e$ztRbQyNksZhj?iT)Nb|@1|Eev+h zG0 zT?-5#M_BF?#;SKtmuNyxs8aQBks$ZVKqt6Rz9+Y#i)cY=04V$&xP4q}s=2Ng(w=jP z=S3ZJUV5jxseS?t6)L#tendD=rfLGzdg+1R?tL!&MO~pX(8wc#_J=!4^<}VemXArNltx$*N-GQGw?dVW3wf}`fPyek{v!96hRPSA z>c}XhFEB|Q64!`%%0EbRBwAr)MQI=>OaCdukoIUt$RJMVlawR)2BeFKO6LKe@U!$) zxeT3`Lm(ggOj#{O%VPm=F%@fwrU)a1YQPbgDl}4dgCxf%9+BHabEOZ;61*W> z+)#p%g-SV~SZ)EH#<_rV04WYp3#@|4py#jyt^@6ms>|b`o=_f`t2Beof@#$cu<5@9 z`U_0~iE6BJ9Q=&^iW6i9E-M$6Tj0;qmFd7|`5mMm_9%r)CupRyR^FtfL-Ei+<&^SD zAwl{f7+51ND&^2}C<2%WGeKHmHpnFOQ4)a_V+F`5yi=}1iSSAJuDB4eB*(&Mq4Cf_ z`5*D9Tnb%*I>RdDxEvvrNlk!3AziKuzXv<>&cb#v4hliLpl9LcQcIzlyare`MdUNK zLW$?90jKi_BnidQG`Wa7DQ+HYYYTIg4e(!JcH#jX$F+3YZpmDxG{uaO>ss(4HTZGzGy($#i zOlE60LM49Tzkziv_xQowD@oH_=O4~twz{H3~WtZ^SaaFu*Y^&Pt zhvnL|mbvKuf-KN%A=Uwh!BbDGiq&NAhyeP-zY4CVthEDnq#+XhE`QOT<_F~^v1=l# z=_LLa)R8TV=L>efCf8GcxUK@E`HXgOxR43zQJ%%F-R8R?8IJ3o zwQ^P<8%+rn@n>ZXm|2=sXd@R57oZb?p|h2o=&549ss7dUlI(#4(EqT1c&j)RxgrFR zV8e9fMagG>Qg95dV%kv)q*{(uN-gafzOjF;sl8^Ab(Lp5o`&2I?$JBc3j@8uOGdBE zf>%R3=w9S2zw8pYjbt_TV$u%i$%Fmr@L-}ba9n&9I@vsYvVIq_Rhq*mL*L@A;|n-i9RW%E~I?v~oie9sWRt zSp5}Y;x#!%nrs{zlY==j#W|sBU8ij7-j^$n}eSwB2#zm~J zd{XfWsUW(sPZ%@xRlnLejNgz~WvJ z!M3uzSZ-c|s^&{9$vPpYtP~DPh@NbmDw{>WdtA=K%3U$J%>p5B^E`h}Rz$$w)s{k@k|qYc!w*N{eX5;shjvNoU43@J zppUqvObv%Tk8Tn%(V6z6ci9c$V@;%Ps{Rr<+M+P4ZSU!qqEFFtBE?KbNnvF-um)bm z^p5Hif5rb-!FF?&XAx{kXqoiL@uGN-ZG)#fkk;UB)Cy{<@3Y%k@!W3J)aV2=j*F^RJ3d?I+{Y~>C(k;kQ$o|lxxIaO$wI6@#%|}s-wr|WH{ao*! zq5?S)@iN;R9zWnq#ha`!*eh8gcec?NmE5NykE{(%NuR9WQo|#fa32YwmA>?5q8EBPz zp>B=E?ta*`bC0Q>_y5H?+QF}?E_vs-3Q0TXyHWPMA~L(JV|u&eefH8ze{1UFOb3f6 zYc_YQ(FpCJhsY=1Z&3akGb!X=`RqW5{r7v4i|f!@mu>zdSR~Qq&M6ojyi|W1{x9f= zLQ{q3J_i0lq~x%M6{%Lsx97JlgS0K-Hxcg}zcP*g&=#Mdm97YE zPPG$T-sgDJ8r}x;nC=Z{Qq@0_y=(L|-{sGYY$#t|^0xLm^adIw8X56fbI&-tECCvV zMS2ZlO>AG$)Y=F2&DU} z?`OM^18GgXbs0m2M%hL()9^=Yswpe|DnBI`nBtJzas9-5jpf!xa=MCY`~yae@6bE{`JdMyTKu@Yq}|@GE})P1dEM>TP|3sY z7y7KER9=8HW+%S?q`5R)8nn@-Pz?*%A7_{5celnI_a12UQ1RC_=O<^g6EQ;nfws0r z4{l$2Ff!QH;2o6L^h=YO=|5kV!d-Ri$Rma^9WDiSy>D-Stc-WGgsF~SB5<eDh^) z)fRk8$Eh*dVL&$5nrzu$UTvk}jg^lZOYarP%ve+YV0xk-@I^R=Nv!75id zJMH&z_wJ!_DVn^+wdumgbzn_t^Z0nK6vtmC>+1ZEz@UjkTC>XHf0SdyTQck116!N| zKjMSp5BW=;Z6h~!dIt8X(Z?;oe4p#QSfe3uTT+>@+<);opEvCK1Ml@mtLzrCrUEAa zN&G^jWPemwC#P4fDeo0CLjE#u40ye9w>o*j^u$@An{ry=Whu3R*sKM5d4s=d{;^c+ z4iIL`;}ESryb`kg(QSkenzgdX&@5|l6(^h=I-CwOZxSkaf?5Oc3H-y?GB8=34Y#58 zU~7?Q?gV;`=mBvVULqe%#5t6ZN_vMkcI`$sS_AegsuZ0XG#f9vRI2H^yZ*9T7T6rR zRNBtI%48tV=r&1x1V9pi95E6kv7Fy;wotbd9jtMd$4c^JS_qc&_TOZBpk+M#Bs3G4 zVSkFWl)3pcV=JCawX|-S6o?Oo-WPNsD&#M{1O0GZBi%6L%8C(0KGPS|G;XJ>`%_+; zAUY+VgfA2Zg&y?vv^fNu=++g3J#CwgPJZsB%BR-P^eWJ!q4BZZxmccvstK1yfHs~^C2*~kRuhW_n=o@}I?XG-%&hIausi84MTu*s#(-iA4C<)tQ zi}B8g>=gfiZBw0F?)Q(>O^=Z%3hk-33UU+!06~%xWg8)i?hN#+I&U_FL?&GEUv+QB z7Vz12jA)|C0eT`aVXauWc#3U~TrBMdGzfAF`dKrX1A;@IZZr`QEdT z8E=H?)D?oozUBU8dZO>Me}UpZbp&F?St7(W%(+<@sk(+bkUruh%ZkGF?4EFU&}i%` zTWqt{O5tzHG(-u#7ffUx*eiT*!6N7=c11W-uz+poPUVvT7;eF4hQ(=`+p??1de3ox zBg=znLrZF5-9lsEz)r~=EvkIL9QB&qU){x2M+vCtCn|u)QLpTB2Tm%b;d)XYM|Y{4 zP`?`4qTMJO&w1csbgna<*@r!+X_iyeiod$Yl9!3eZVc)Y{wv60Z*T9#Ui7tQ$LX8~ zhS*m(!k0qvC4S8*)eZcuV>wepE%o0+ABVhJ2zuUrS_S2y#e{%J1P3_Cb`8&tIs}Me0GQ-FX?m z$es1AWWlgLx{3B&(^uZ1*Ng#Ff2mT45Kj@Xm-yR1O+HTD4qWSfOxp0Xbe%#fa}x@p z6|aL2$4lrH2k)duLoZ1Z6dARoy0e5>H2FzzoRPoW)PfgnXN(tpgp9bT&*%%@|I z(8>T$u2yUpuXQi;b`bX#UgW1Cz2FS?BRL1W2>)U`!O2(@`^cNbofMwqRQ&g~2x$~> z1|(Q>u!3GJDid!b8atFsC*e3@hR_I9`L6j8@Srdaf`D`ABuGUZCHRgwq=$UiZPZ9^ z@K-bQKnrXUtVUMTYpGy5p1lj?VFAHFZVD-+X8~6^^OK6!+#mKM*ArR_J>b^yC-zA^Uoiok4#fbYxobQzVI

GuMU_03z@$ zAOO1q{dtzcAfO94fp;k=!5+N7@(TaoEZ{zJUwHq;3HAg(zxd5h9Zv8$jqmJqZXXA7 ze}RkO9N;ngkzL2TDqizB$t~b5_5?czkn``S4U`GB;)2=E+&*9s@D+RkcIWo8JjZ7cf_?@qL@Du<8;=UmT+qY8bSEM||eH)MYV1zy5Y2aAMzfpL6yAeFij zc*^&J4wEf0K=~H=!85mZlIidyMUmnl(aHS-xB8H8lSrX`^Ataf+2QnG3+QvXhp+kgyteJrbu^BI76ZpF}%l;NM6}__Tw)K#| zR^&4^Y>0RVbIJW0DgulI56jd+SZC`de-!kP{Ue;CI1cu5&*Qd(OHNjGgdGMS8iGc?OP=_rZ7CbLnT2B%V2XUD^pj0>}NkXpn&+ zhkUMLBiAM1b_;AX*?dEcwu~LXB(NE^V(XjW!q7h43Va}3;JxNlf(FzE$@Cu0Fz2)4 z)h<%JMRG~oJ?#*yAtl+}LyQGV8kM|PLi9TdwfG)Aim4j@}YG!%6(|aUmV`j)h zidUHKDNZB39dfDDFxbANc3{wmpx(9J9AxlW*);DKJ{6H>&mr>_x42^0B!OCU+4r-S z65Wz)bU#NTR1c}0)?GYDW}tnfcuHh3xVA3L`yKfXOhbk#4BR94b9xN@l3If;R~f+G zmTGFUf}|h%KA|p=n3_i{!}7^Af{;!U*}MUFC&3m`JK#AyfSnV#N&kd}|<-t0`L zR_Ky0@a0$;U9?%CCiU)f0YVTl~(jneuawu*9Y5ld%79P}0 zo~FK`QuzBhuaY|d$y&26rG1+CQ0YIlSq_!yuZWzkpCo;L53(r5$-W976~0z?#($BU zDk-S{T|7e6GxD-%xNMfLxu;ROrlyUdRl~z^;}m#hcKT71){v>65&G6}-8ZzfuI?iF zk+kY&x2aYCQ{>3cbT5P5@{fs$iu(FzID>7j4J`~0@6+j?NM1kG!+3SZ(=eTIRbJxz z!kcUG(!o^+4e`x*!w$WTK3e;&=A5WU_y*}($$V_AvPwAIU8vr{q6>xys!+a#BQEgH<9Tx3GAV}%Hr2g){XA8p$8{4Eb<`%RpQ5x&i0E5-Vg~xUJ>@9lLrM`X8MOpXY zI^`SBpz?XN9q_rk$wOoBs13E}O4>L&L8-yV!jA)Vg}^5>JRoLObU=^BJdsXz_~|kB zKy4pYyM}YgnUyBtM|EWV5|>_)$&H3HG|NSI3PLO1g>;R~Apqwatc02EHS+ZAUX-6F zuJv(kFM0{*%aZ8(a9ia%a-4Ic=nA{HeuktdY$raYUWdGq&c!Ekr@>P;0y@#~wIHG> z#r{FPRxs8llBZ$kEmK%Zb%uFUJrongeWoruhH}%WI{q${6TQs&(ey&y5sRu*;?*JJ z#D!j5;tAYygkT2MDz<>14VIvBuFWV3dOWR}W?@g{ZR-|$rw1QV^tO8IUMeAJ9Hv)m zSWW3OYeviwb*l3hh{|fYL$1BN`MEdvUY;XLr;n2{z(=Ioxxt>HGB)fc>|ENDs1%Gc z>FA$HB~dTyusu{TUztoHjZ^806}J<%5}UxhN3iIQpZAN3Y!Qqpzy$Y;XV zqfujZ4Yge~8^2Vr%qNYTtjqT>04aJ?W39g&{5-hQd5Keo_P0y&&FYkf2khTn8x_Bq z3i~l>iWql4LzV{h^B8OTC`V|*{6*YFvBS2Y{;;}9#BIkv^<+q-zZzqu)@cv z--t@`)iv9rwgk_x?Lp?NPE>!dSrF3N09Nm((i)WeN9BJ4K1D_Z>g=c0W5pKBQ2d}^ zl4pedQq*)PFejY$2}?OIGa!!5W~v7-WYM6 zZevNwZ+`=Q)6$w$PjLulb!HunnAjxM__%I;xDEfBCsVaZcwjtV+c|cZq)+i!rft{{ z#~piW$6@o=UqN|+Qhj!E^$h(}=kEF;_!ReSjvI*QrK_?+w6p+ODAgWU-C8UE()Lq1S! zmJPPY69qERU*X}W-^y_FEnlf-8yIWLk|zcosrQ(r!53r$-80Y!L4(cT>t_V7mgibp zql2TnIfms7m;8=P#GrAh57F680y6|{ zz!?~;NTKo_YH}zslz6C3Xn28fWbe0^Nm~l?kjt6@vhn6m^%Z>IB1gPGLMHvI?z?fR zU=jF*SR%e5k0WUMZ&4pt@2Wgu$H;TYIG+r=Pjsw26(9{++L_=$7I(L=xZ}?WZ((Rm zuJz9&dRYfie@EISE304m?;57DYineXL%oq`NG+Cc#|3q3kdC2^$YnM_RtjG+Qp{(q zi(YO!p?oLrTXocXD!2zY*AXI-NkWX3#7TqG|G|W4617luyyaTBNRn&X3r5J-IAHJ3 z;Elp{^|QIo+PC;F+fJcg;Pjf&i>fv5x%RHoiP%@$G;F@%iqCBvC^|1pwp-v`;YMmk zb$58OV!m%mV7>yzzT@Ks4Y;PYn~3trjjGnJUF2BkzDXQ77Fnu{Gp~pcPVJ(9Lf_ai#6&O+A%PX} zK@`OgxMw3(;yC_fYD5pQZ@n1zQFxVeGa6((aGYEN(y$kwOWhQ$!)PL(?to-bFYsB| z62UlXF;^=TQDYrVp?NY8?8|h-S}+ftI7f=}*;DLRkr$Zd+C}{(^znwpQo#`j^mnHh zAR5}j?2&v$TlnJ0E9hmWHQ8I(Q79qwWFgXvU51CEVqpfej7frKQ40dW0<-uXc@IB^ zbbxA!67C6li}{N_hQ0xZ5t&SLv>{MRz2{HFiL`-{z_q|Vx*Iqa4q;S$7P=S=Vqo4Q z(Vbh%?trF&_bC&T3oij@@qG;hI?mF}5by*j~O=MT?rj0V~Q7x}*GoEXK90EHsZ&)Re$+X}khzi!Q>sd3K z#`_=&p>ePVEaCnzE$Dt+UuYa41{Wi50UMq}_5)|Z{oqWbJy44mk{I9wyTcV|CRmB@ zC%^L?whJtd<_R`a_x;U)tzaJIgXQ7{+=Re>b~N~&>IWKy`@kOLF3t&fkz%tz>@zy*5f=D=*WL*OgZSrCP4fMWPC=ykX7 zY(dA$+oB5IcyY{~U>?IY2|gt8vqhZWUvE7|Ln=O@4%mfV*jvVS-XW4*qGF_6a*|tH z*TLIF4q}g~G)Y?t?EKGOg{8y)PELVr)p8~(IF~l;*T+wXRI^rolK(w2^WSh(N zR~n#jx+l7vIqf+DJQ1uWK2crN@30XTZD7Ax$ZR6&mAz!0?Ta0L>;|%c5eU|c+XwDD z20%K=Mt@_oT8v84#WvN-Fb4R<^KZ1k8{Y_N^N^ADqT)-4LGR$3Hfyx&sA*-Bh;-F%APKz` za)}&Mbe5i@9Ubub-$gu>N-G}_S*mQyM`L{i65YgK?7Bsdwq|<&3%RHJ;u}cbfhSsz zS&nFTh1#hB_!+Q+=b-bc_^NClJ4A3!g4na_8>2LGgXph$5xv9JAon>D5d`owsDP5{U(2r|PJ}cSIXy7jb`X1R5h6LKuzJ_}@xh z&^UYpL2^U*>2QWTR_b-V#`h}9!LG)uoG*Ab^v#jX(`RG-Lt!ikW*=J~V&m19z5m)K z2=<6gP;cQ6dZ#IrTCQFp5EI)01Suoi@h<8{WY`sPHnVoo@xm_n?u4!1wT=R@CQQhi%>all$wRW)CXM1E1W zW)5%`qGe5yqf>BHNLO}-e=CdzwwX6_JEFSF#SXit3zAJV@*hVhi@d_d0tGp-PH)>J z1of3cKcVBUV|5eV2V}1l+29*yu;@9pmv@yLB-^CdB2CyX@`^Z?G5>cez=H>@anEn*54h0!mi?klm4EZ+SQh#0+#Ul)6zs9{oH#6Mj&&Hd`URht{ED;M*d#T&gEy&en z_w)Zq{%ct*d+SOE1J3;wekM5nr?`^`l+T1a72R?-4p}E}>n{j$tJS4>m8sGy z$PW3@-2QnT3{S$lvP;o9k{GAN!70+AAy$ucO5|3y#IcU-Mocy}RUS&dAjzyRbrXQk zWI?K$m5aw!?(+{6f44312ciZk5*<&WRN+BuxZ|0gRAk%R2}Y~87$tR0BhKl6l+1Uf z#{Y-Bs-Pu=HlwO({lVDxAziB`aXG4FFX$ft3cuKnH%a-~CCVo_|3PbZhT*9UE< z>58*JjdC}!R`53Tfn!;DjC!$VR;k4Otl19v)zV|WN%Dr?gziarjK)e(|j+ve)`^RQE+7{myT-9WIR+2jP6EF_ zIbbm^^E3>iRhfze%@=>nul2^|>SbXX?Nh9q`FN#ChRd@mH;~zFOv)AC!(4a5uGr=k z<;68>GT#DOYo*4@$$9w=*R+3ae_lR7ekyQ1+lkgD?ZU2tT@*mYf%kVsf7+yo&jeme z%UxZ+3v3M|E^A(?p$LQJ`nzRyqoU$;EZ8P&G1B+<$FkMpWYI$Qy?$oIaL@bvDW37* zK&q8Y6*(M{*w469YG0JL*Wc=vuZ1fYlr^vGXm4teBny~4^9vyXTr6l&KR*;~_yjqD z&mc-nyuuYiwC}`AkqJ`0r)lwU#}iGKdZHjsT1gEq8eAK#i4E(=9P#7|nu?v?5Gzj1 zm7i5^=U%y52KFfn#8Ya%*%~Q#1#M;i!fpzF)GsLCA?h89D;5a%SZ4ka62ipDpkbcv zTn2WfYP)}ZP47N-^}>l*Dv}m7F{^Xw=8-B`rCd~ks>b7U**0SiOc4Cjw>4~ ze&l8H1poK)uht5*gZ!*{ATAHT!@Me+FOQ4b;+Eq1s!(f2p~28A{+o9U)kXWq&g3ED z!pIb6I&w0!m3`p%f7y!-$^_%dAt6r{|CShQ-0(JjHx%1Y9&@?s;4c^HN6V2m(e7A_ z_i|p0?U1V%n4Ul-J|=?8T3fo<)=|qEu4_}PUuNu_9KDB`gWGhkxQG5XHQsz$x{BBvg=kRL(CE9ZQ*d@ zVaXm6if>W`5g=Az1~%clxRK~+g#kTqBpE+C=N6-&XHY$>T9J+B5}5TVBsNyFw#(VuvQv5OnW&nUD1i_ zP*CK|i2R^;`$rbaJWn*!8xzHc|K;aj@CV7T`pr>_|y8Qf`jXktlJ-4bv6 z;#~YfUrmegl?esYt*+{`ff?;?rf#u&sy_SXR^6%f$GmIWiJ9-+g$%6A%&Qc78%C<5 zpaeFnCZo_Pd=XLt@a(3L9>zyM?8L+1PsmtVPI$5HOimWwBuKzh)@BZLiFZyurP&!DwN-`3?4J^dJJNgpKq^E>w=mxkI(bhiP{X@DyaT4hS zr{YcADDf|RO1wd2fVTN*Up;#nFd$!r+3-T@1#y;7hreYN&>hiWjG|NU`>xmIeTh(; zjvn$T{W5%y{}WoJ309m5)R{U{Cb+FI8O~w^@L~RGou6C;elr4w6!lkb28RXW{T{+j z%?EGlPOBH=&N{;X1Iy)FibrT>24ZUFu_?i2+!%L~FiDo{Yl(M}jpvDoOQetF+r3si zNHvQ2yM7!pGk6;o@YxB9y6wLp&?|of2Ypti8p&jDkc+U+@FH?Fa1tBHQ(#iKSK=w$ zHX=^+TRhc?IF5<6s!%Y7Z(_gjEu~6@htRj|RKam1#Ut{a5vb97tY6$*JlYztwou&+ zdhS|Eg$3PXO_oedDm~}k&lamgX}9BuxJ>S~4#96|hQV&xa>>5m$~ZZ}2hJVBwBS>&~l-hNA2unZ4>UaCJ(Dg+U5)q%@^Kun}GzS22VFX zlo-^FeH;DXq**NHdWQCt**uNN@9Kl#8Pha&pz;|tjM$^HfsO0tlTFlK;3YL$u4i`E zTUoI-n34JKs2*VVt2W?aItKjL3ySxNFIt6x&T=Djz&lKt6#CH!{{nF*dPjIqJ3TvxmyJ1SL^*R11s__}!!69BerI1PzsF@ClB6 z+yJx*GaOaRO|0B~g7^n$Nq5BFsp7zX&MWu?q%%E)_R3m`U*Y-wv+Q_!INePWDY;J1 z4D_b5$rD_zloEa>?vNdTFfxdoD1;=_nW;SS(@Ei^3ppxua8H<{U>~Xqr@(fC_0V

!IDT@$#~L!!b`$(f*c!+J_n7pAGbsp-|IVcS9F{7>4pu4 zS%$^N-)6ft4LNA9h2~q1pu94Skz_x1rax*}B>G4;Z_rVoaFZ*hu^xf{f_laEf}%@sT4!9YeWJt|I*; zF2>KoUc+F}*X<{)PtA!&jrNfG>OhsMz2DL=7?`C#s&UtiFvv{XfRptO(MbJEw=#;E zN14m$snkT$SWJ>_zTuGuH{jOauhgle+DYaR=o(=zC7TlMaDns^cL^!eSyXU;ecu+< z5K|xeCW%5h=`euHwvNl(O$Z&J`3_meiz`Qos+Kr#&*g}1ddu67p zpQ#m=Ir!t0L!@#$O*^}Pg^mGh$?aqlbYH9Jm8%vaS>!~=LR_u!Vt>Ivj%_Px5Opzb zqp4=#pYA&B={SfHL)>P*r<$y$Ab9dq$0mZeMW=kFh|&B(5ghudse~(*`~8}pVXE!6 zB@Q)C>z($I)2-K)#?G%j3T*&|D{n=i>PQMOA=Xk{?Rf2Ig98dWH9)QNU+uUxtzG2lkCK6wu1cu?tS)B>(j&1z* z?Q&7qY_kPPP(`~KCmCP!;)m&+1x+ezD3+&@$nu(3| zg?GzEJrgN>acH;`Z@C&gMU@lXumu_;c8}3=7plKk4lmDC?-qo`)%ti@KUWKiNA>F6 z#8IiflWnN##U-zlcX{p6BSjozeEIOwuLE~IHE}wFKQ`Wu)< zgy!D_Nzl$H*u6<7t&|s}w4TNhM5SSad?^lHo%4&5a`4q|>i4WsfwYjDoHB&LZJV-#F1O&r!^`2%_+AUDy~dA+*c8Cux^som}|*+5B4GdbjPaYnW={ zPm2KrmDO#*tx)r#=Buqf?QI=}y$f{N(0W?Go115h*E`Si9w)gpj?x8TRnnMb75v?3 zRcboZ+Q+n3wM=PK$+6wj6`8#!`y%_J)Q!3yrWv;D_LnFLyoK!M?qs*JDV*u- zaF;A*0qqx!$(T!PB=unqqn=t98t-aI{rh{r_ulRu(*0YO*0xDXm*z`{wJ(r|_ADM4 zZAi9UMhy_gGaI>$p7*^Bf-s+{LLc`ft|iQLiWL3A5TMxI_Eoa9VNiopb53iw)YL^b z`jKwC9TpAs`{rFDsODF>mXa45GupCh%B%m>oNb8hYPYPU-sGM1&Ja{`HWCy2gX^;@ zKGl8iO2r)H^MX43C7ev?Y?rdOxe_Zw2nzx(Mn(%r(4(d=`SCUVW~pExE;Vqr9am}2 zebx4X?iTeVOh`;_e4E$TIEM5f%oLhVm{mvkN0VHqkBi+AzL?P7;8f7lCSb)!T=P}i zX4bIFRBEbVUQ~j|MYTtXHBZ>>!d@5sBy7Cn#846S${BPx~(*jFn~;Pp@3u zqr-pq8107@WKvC<=`E|O_f)QF=2{rsu)t~lrQD5_So?pfj<(FEdChC2-CbJS7mhG+ zPT)8}A$u;9MfSHp(E9g%kK<*vmh;n}7Hp=4^zt+=5u+?)0*U`C{ z-biz%MLE>s0^tm+mu_1hxBF<<#!h_u+LnE-qW0n7PTAz>Ilnfk zHLZKJdYbjIgN~o#U+<0Zo!os~`&fm}lUWA|Z2jrBXVs+g8D*BLftL4uZtz*&wcz`~ zXGC8)1=w0w(A{JE*O=vaS7)4;+K1{L?P=$2aeK;U zxZoIAhZ5Wj;LpCM8QL2q|I{+ODY>z;X`nSn&g}WsmpE`u``R$vl8L%Yn9Z2tw%0?! z({s8wPkER4bNL0F2*x||FM4_wv#)Ylp2 zS{&eLvJYz$C&}ZN;F9-pfj?j1p3J@DT1q3MBh_{7>*}-1SCu4`C)MqeM)yUUm*Dak z?>$ENc?5a|ED}CpW+GF27d2aIGU_d|z1r>g2b`VWWy0^=In*2@scm0nR0Uaj7P-VD z1sw|*#QO?ulC~7@tjOto$PkBygf=@4>zG$ctLZS@@{&ZY6*w6p%P@J1yH2`~9sI#R zOGhZWR=mo*H276~4jpddloo0Ge1qcxnTG0?-^$+8zMrGTR8-BpoT;h}zVAbBp%O}O zlsTbX15fzJ7!T%sE18RN44V*i3Z*JV!ygj-8j5n^ z#z0(oz}YCsQ*2$_s498hJOj%1oE&Ts4#j_K-&rWhom@LfvxDgv_#!GasNZ#lJ*<0h z1GB=p{C2~RehqP#r!?3SmK%`mX<{A4-O(@V9NVO+%a>p}p7rik|NTFP&N85>E&#*p z?q+O^vC-WPf{NH8c6WDwc6SGMw_<=OsdRUDHydN!@!h}svAf-S?(WVx@B2K44D@c6 zjDJs1$UDJaO;;1+(fdI$D$rPBZ(^r!C#kEY3*P&B=!Rsmy3uqIv6{S%i*gKhDRRiP z4dno{enuL}9&-sE34I7!2BgoE#5)IOcBC|WwPton`xAyIi|@;xpf2f;3IDpeMEV zQssww)Ih0mA>*_|ms1xnfzYqE>N;6{zOh5H5C4bnA-qT5VyNq0U+3Qw21mO5^vmPW ztMCNN@SpdO$_6 zdhRB%>x8`O-CH-*#!wu=JMjM4&1CKXM~d*>=XzF&_nJnCqdcSIE5SYLG&4=C?VCK% zC4Qp;Jce*6Mo*kZ2ZS^D4$yY>N$I6=il}9zWaOdPNx{^^f&aW}Vn5B69z;Hauf*NL z$x#o$?{sKYit?lBJ8Cv+4EY^tKz5_r zQ19Wt&BN*x`FX_;wWDq+ki?2p`>Wrp*J(c(pnySC1aCqzP!Qxcm^-i&+W;;EGIa#d zMx#*ct6Ha$YKu(Uz!i{BK(>qw#sje8Uo&}2q9nfcK>U1#6 zc{mNT2mKJnvYa%YGw+4WgK?n8Ez=DVx;grM(+}`3NR7p4IHJ3+Ii{MctX2)^mY7ok zPxKnVkmL*IgS{cSFc{JnaUXIXNCqr0A2mKO`UB~;1!jh26Sx4@3n#(W0X|)8(?2~^ zXQkVsv(pzEl@`ET067ENXUYV8s0(3pkuj)hL?bL7^3lvOh8vZ}IYzeOw%(xcG;%@f zps(Rw@LcE;um|V`V7tBuu7z|0Nt;!m4}h0a1$;kW%P!LtBiT5^>iap98sob*4n{C~OhjAJzk$-ER;UtPyqvb{FDrxowKG zyaSRfL%>`%+L)+sHbz5S5E~IoVGqDwmOfLP>8QC2Shr{c9N&ZH{eVZpu$23!m# zn^pkXRyg1vF9+OZLoemkO=XHTmtu4_5)}1Es!mK4Yav#m~zcfAVJt}lma6z$Wj4Z4LRg6kXMDm zb^^!$fQ&|VK(|_^nuknv`ZM|mKzDz#UKyB#Kg@RmfGS24J)`0iN|}3y_=ytpR&MgAu>s3XsCsuJh4c z)+QO7fIDY~#akDlsnJg}W*P|Q8Q^j72}`R1W{d(|ffPW8ks0Xih-7F3_>lRwX*ZDH zc?+1p>nwGqICGwfV1yb!8`kRQ7~B9a?oyDiDO2aJFEbqnH$s(AIe52ux*^3#0Xdp} z=~^vuurgD#F&z0H+7I#&dK7IByJ^Hh)}t>1ez4QVQwTWdji%mm5%JXUNp?h?VJy?1 zQU|NA8?)dt#3@sN`iUV8u@^lC{s1IcRzTdbQY;8ERl7-T1=v^DlbR^!QI||u%?AZo zecjXxjIlmMt)*PYP|sDIQ72h;BTCR{+)mVK%K~k){Eb8-wbmVhknmK7g87BI3O57# z&#+R{uPBqT71PxtI<)09{0@eQi@_emTt@L>4(3#~uk_JGvaEe#*2FZG2@n7=!DjSH z{5;|%s)=$AGXUDAA5%S)BV}i0_kop-?}`LXmLb`^8*tfJuq&_$SS$Pp&JJA+={9cE zQWR4pqKPO?lVOc95>$kmLi$dN1@>pUG4CzEiI@6Y2p`-Kv4z27oy1)= zyq9E-EEDknvuPWN%^;GG!d7V>Pt;0F^s^C0;0S?mq1G}!Amf=0$S=6%2p{F=QEz1> zd@7!e7*p1ZG}=boR}vQHtc;TXgukT&Pb$qDGmMylYcNzRvp{2L4--Uo&ZIy^8D_}n zplEcG0i)6b&b4RKa~e0)a&WY+47MA5dt$2A5pl?{+N>rO!)Iw4kj^+K^JT<2Os)E} z(T|)273l7wZJ}S~EckrFVeL6nH_=BMIHZJdF@dB1#zN7tW`6&KY8&MQY?j;$7RlU) zU8A!xe!&!Bjw&s~pTQe#RAjbhq3$)7PKF`xDdGp8PHaOtlh;9PB{N1AYuO}>RS@YC z_@Mmr$bjSv=rL|GDW5nU>^L?$8Vq?roDMX^M#+z0YgFmd52EftXUS2>1xhh%r_~o) z68ft#S6VXsc{q6@LE$RT*U_;@86%7qsvqeyx*g=IH>sz|y+r8IN#bqN-}(kj7PXC` z}wcl8%4i0~$k+0)!>^5Z<*rJC`z6`&jU)O>^78tdAZ=3UO(w z$q=xN(%)b!`e$!qBy^ZZ+nm|VD^(7n*IpAwbKVk6p-bz)FFD64O2+Iy<+OoZ+cXz5 z$fR^*K@l8WPnmkAZ;Y1Rn=9lXlub75sl40m8$cyC+q&8%BBzzA!hVy(Zm1>~_*Hh< z)Rxs*=-!6+sMrKK9O;Ld(pU=5_Bm>(EZ`b?A`VjT73MZ!I3io@XiS|PIyWN1qp<&Y zo^t%X{YBp(N<%Y0uUQ`L8tgNZDH$Ud@aldb9y&epD&^l*BC4KLjrM^-r-kbRF1XAh zY!Ej#+^loz-DXBu{bdI;)YjEZH*|w`O=o3Qdavtv$(TE8z55&|0<)KOmc~FhS$yQ} zo&R-@PcS8;1GeLGy%+L0XTbH5FqTE5rQ#i6XDss!3&i^xBP;#8U&#FwjS47a2R@gs zaH{hK*}WiqA!pF`!fM61_K^l|8>1t<=|JZr%{9Vq>(|bU{I+@Rv)f9|MFoHdCq^6p ztMF*q-Gl6VFTYRrwtpqm^FKJToG4gvyG!n`O3mmh<0;HB{_ z`>fh1?<28x%Hs6u@ne2_+@FvAD}%ulfiDrE6<*3TuMecD<%xsOy_w{hm0RIxKhL3W z9af%waAmqHpbWVpULl)d7bQZC&I-Frd|H8_cGIKfLb_|jM@YgPg%vHqvy1@^LXre*UatTDH22Jy4w+%{ZgEW6+4 zQO(+d3LL}zo@!&6R=3Ts(P~}mi|Q0qQ`8E#)UJ&MGxezf3xfBdtUC`kY#Dndm^y2o zgK8Yp@}wxZqfu}$EQfa*{d9P+AgJVN*Fy|5@<~`XCj#r!y)1QAHF@;W$bIB0x1&x~ zoNCrEu>!GIVcm7NPd_lZdA$h1+%2fE8+P*L1`)@}^AKahrOnly`q5t^_o3<1g-9yn zI`<-%K_$^L?3`?W;5S2z@)^yBvXR59GCC%`qEi)FGXr_gT3=M$vS z2O~q0b&7&9m)@F@L!iIhc3+vl&>lyH;*^k6<2$+^cHVAisoB)GPHBSevJD5|t2-z? zNR{gFNNnHvj?4X5EmEG|&o89gX&U5mXM5$Q>e9Y$;;c}|kO(?e7E@=JqiSQa6QgZB zg^J+f=Vc>DixyaCQs*ltl!DuS^g6vqjTZMN!*R3l`cBk82EWe3^eyW6VIUzm6P``wer?ac!+g zzA{81o2ckxDM&|aPg+(4A2HPxK83l5zc*|zxJq0+V;|^8u2ePVoyt5~8~iU3q?uGM zOz4?tz(5~)f}F08PX6<_0!*u)dOJKCBWjgYM)r20p87EY{)2X7we-VyA^soj zf~?n}9a?Gm{Vm_5VuaS=ZSW)C=e9183!UeR!iw?j>8dlReKZNbT3|yjpjzAUm{`SF z$D@X*iZ6Avfnv!poaAm56ky-V9k4!3e2?oxgXE_>r*{PO4GrGxc-nSW(*pmi7m-uF zKMKn^m2O?OQ_%Ua)v7}sTUs4D{hM`db)pD^1$%{KWA{b)#9@HDlJ*sgQs3&)G$z#y zbzGJyWFMsrcpRtG>5{wLdBCOzeN{YKzrDP&se1euQfkNYdF5#%*lrQkQ>zm@*Yw5e z{Mfgg9NglFe;cEV%DQ$a=CNWTR`_i}A8PphXa4ZIfcB|f+_B+=f@9tGHoUOef@|#y zzcIy)lYe*~?V4GrRL*m~&M9f$Sf(bl`}Kg|x5mSc30BG6nw{yfp1X!;G_Le44*<7x zOP57=>$c{5+XxuR`6gMXmvPL-KMx!$}+3Uu7Q)-`;taH^LMumN;}{o|HSI zl-5#FG)YDjw=JdzJ1dKxbST|QCmz=$9I#Zs`WEWap#42P4gG=w!sW^jhS0!#D7+%b zayobkdP$Ykyg6(c*`@qu(|sn`=amurm(m*Hc0FX0aYx0%rcAh%=U%r$*xI%!C5fXS zy%#L_0Te!aiawODo9Lso2$9ZRl-<2?xeF_Hh5=JXpjqzcsn$R$Gi5s8&YBMo*_r?JBIgI;y~) zw6AyNWA@iOW+(LRvSRs%bCX)8{8}r~gl>|E9B74;oY+Oe7KJ3JkNHvEU|Vq zWODEV=$7J2l;e~DNOXHY=ZC$%OW$8^GaqWMh|qtr_HIPB2%H#@$gcS|eQ^57e9uHe zRd=5Iej>8!D>srMZ;GP@qh~kmW5%N%G>7sB=tWJBa5GFN3-*$a@e@0Q$UF4022afW zfaRU&k+4Xf{$KVa+fI8>!Ar%oIj+dKtPa@pNH5*VvU*0-^i)VyVY2e0Uls$Dcdy`w zfHh5yomKfJJ!NA4^rcarL-~0VZCkbPZBKfDEqlr@{G-=-Yc;5 z%|~#mbWNYUiQn|C6K!-#9bEJ$E;~;LKMNXNg&a8cw6v~_`?%fuEVo;Ease~11v1(xrlJ9;^`^V>~s z{}KC}*3+8#51NE#ZNN&oNEPU{aAa4l%zClhlfeh6G3ewV@OC0(mJ$OJ zkUM<+kMm(9fcR+KfHQ zCbbZvf-V4BGgmBgfd1n*{Yjmd2@P5bJ`N>9n!p{1!-#ClGyQ-Ht@1GrLmt3qAfA8= z^zB-%zSXz~d<>?BZUANIpQ%fiQ21lWbjw`R63{W^HZ&Rz2W>ZQH?0IIjW*f> z9o1xQ_ApiGflX%14bW*Y9q|#l3Vsz<36q2W8gjLLhEL|bfWdy6nX7j<>L5>0=P`bY~N<*(c#cYI>LA8+2W~$!W z*kB0+%>mDcv|1v}55ZY*A$%&B1v&@=V>_^6uo&ZKzyO}6@36q(l?XX(Cm2YM=r$>{ z)Dn{`{2rEyc>+?H9zj>bogr6DJG4|yj#i`v8z#VU=sWm9d@wc~83OUKaDl!h0aR|- zr*+meX(hT(`rDQkC=C(_-Utsv(UAAx$*^&7tZAeEpVmVeAWf9pYm-eg5ElrGhzjg4 ztP?&F`wnryG*kX?d`LQ~jMJilJjxQ#Rb(?}6{-=*L*o!?<7&C1q*-=eeMf%`9D}q+ z;n6xw0>aPes4SMQS7e*hF-Hhicn}I>IxIgcekt0idW5)2K234OeATs#|Bz-G-LdPb zO7ytKS2nI*1ie7Gf+(2yFg^;u!m_75HQyQ2%ke}%>TJUlMHO-;Q;Ga2>KwB{iOGA_ zQ^xZ!vxwiuyT$+EIB=e13;Y`kYw(xDh%K-OW3Heg+%{#BWdS8l`li1ddI0LL=i*Nx zgLOj88r*b<4s*!VDLM>$hkvefhI%8qMq(siU@jV^E`zoPaYvO+Y{c8jMd*Vz_f@8m z@8}%!_JP;NLySqdo4QkqRgmWxCz-eC7GVO{(tE9MHihG^r;$e419`Y4TL;AY@zat# zy)|)%^(REPq(&sT>nzdb3ozc>GQEQt-a?eU)mrcoFW%=w4$3wD>kJ|p05Wd}d z4#Pi@i@Ry^Y%aHU3MO+Aof~?g=9OJF`~g znH$rni%vZ%Dwr|X4_bcq-x2nbNZC;GhsDtQ%WE{}pBkvk);CpEKKnJ}-J*@@z1p7} zJR9wQM$HFdoo;V&+%RSQ^9{(s#fNi|tv+)kJ(KG{&Gj+esYAuK8qU3=a+hL*Qow?l z9y=?3@@@To6}56Y=a5rxwtLPNR6P6<6$fYid8Q;ij&06wX;Q6MIhTBW3a0L8HacAe zjRh`7lbt?_nwQNXgl{cMNQ8qYX2gelY#;pnjQwWI5$8n}b3dOi-zWS&{SvjgWi$Q7f~3IM$+P_Hd3{`#m0EwO zW!c}WDePL##P|OAjzB{s_sZ3BZ3`WJU+-Cfw{wsrgR*IjGh#15mEhQ%)_|9BOxy}yWx ziu)iGPhauO^JhVC?{HW*^I?vQ>hNUI`2DXm;o{k~rTeRR?lb$N%XPD-yQkDPxCXP^ z9%HooBZDd)m=7$~j?W*D_PCcfS{CWPzOtv~&NectFb^`*&e}b%+Hod$)%S5<7Z2#; zi`tGE-~w6)c>P{VZOK6|vrE~xE7g+lSn{5XzazcgF+@i5b!5jPsU7pj*@aP+OP@E8+2K6m4*wJmwv#nauRW@uHZH`2=5 zt&mnek7Ex_JtkZ|aw01yE4>Cf{)|R*|LeTcPG*xu43$rBN+=mDC@K$^{NSa8u8t;8 zy6f@DY9UfNxWCvn-!Bi5x4s#viL8DuDGBgoxEqdgwcB&AALWT=2d#RQ!lE|3etAGA;;aS8u$KU=4bulJDBCbRh#ux$3|b!K?C{WYt@k;V)9(%G(^>~xl6yud6aM`^e?9H& zTOf5ED|2)O-y~lNYwW^9BfXb`?5YB{s+Zx^AB@2FKE$&eM zsjR~aeCPegt*jf#f2~;_^*yAoChVm}y>HPyUytsmHNhyd||a{KmsqKYiZuJa$&azM`ba*_y)g=h2^SQd1J> zTNlLDyJcpCMFjFuck(u+8mN#ReH7t7~`2qV>{54(f%jS%Y zD3_pv4h_8vO8>yOO^aF7=Nmh6_Ge4kA;bWGuKgHca@Y4FM0Hh*#hey#CvK0=4#KYC zHzk?X*9Rk{wun~_!$FFOOjm(wOT~er_eGG#P2y&?gh31Y;_Sz13w+NLjM#~GjK$_^ z{_X9&Ibng5Sj&0OT=obA9E+FKH4xhd@*CUvZy?1|JgK){qmq6e*sL>Ksr{xz{q&jR zxZJhPb_W)zcw2L>kl*>GZ&LN7MwQ$alxA|oWO;VY{t>Z@dyzhh+}tr#G}!dD!@cQ2 zccknJ#^1Ml>VKXb9)-PMy|?*%Z*I$*tPd>^8Z|%=EqDF~8?kf^9m|x}--WVBa~+%l z-#Pw7r53q7&MG~OMfo)>`Q&?~8JRnC5QE~lCxrK~d9_F0pBR)y=LSttfy(#iRapjR zbnqPieaeaB>(<`l1bj&6_7+!(V0lIJx+qDKFL+khWEYGJbnSlsae*1zr}v2VHS?X?IfXcU@V@ ztAFZMqC7WxYTh&I>x>Op4(Utg5}091UT0IQ&#X%n#HSG?ZrN4EqvH84?Y?2THHnLD zen+ipw0z4DbZ=#nzWhGX`O?46ub@-*hj02eRp#Z<{rKbWnl*vMr60KRj%}&s1v9M= zE_)HW8~LRNm!4cafJ_!Va>)!lkG!19N^~m;A3R|9cG=FQTLPC;wHkJ>GVjB$^F?`M z)pTOe?toQ%Ip+hbPgPt4trRvrYPi&zp{sWs3_Icj_1+nr!#k#ZAWaxetUp{eTyi}_ zRnernk4xZ81SQS*8&T@~$_hdWW_Q3z#XAzsX`iw_G&3w7W)I^e-j>MD&|Ldw&LeF%n^v}L><581kp%WVQ|RuGNKp3$WN2Y>mESm}uB$N$Yvtt4r}Hzs zasiudTzyLgrx4JkVXt%6M?DWO1kj@wM@L`V{LU9`K;x#S#&uA3%6%(Ne7&XLA0zaS z=dF|P{JFfC#T<&*?uHxo`jybUkGsz&9QVGAT)Hwae9v2m&yN$sYxy_n^(|p}Q|#Zy z4ptcoO2fHIj=5AN8K0WZM=C$Fmn{ zuP|Z1XIPBkq#-Jv!nxS^kunF;U-+NPYHM)&h_J$3+2`kRx*J+L-|e^7W@rQBNY@3^ zL9fEzUm)?UM2lw+%StRw$*fczrcbm@46O^SbnqGt!}24)faFO%YUG@gVCWB#v^<~= zFDxv{(jtrI+FRBBUf=ICt7N8tUpuP%XsDa!eajgl*;}|c`9?#i;MD5X3*KR70|r&> z;0kh?A0i}>hf#rZzQ6ib`b_`Z{6jFXI6Ur{*DZ5+HN38}q9y~@wA!5Oi(K;5HIUIE ztdN?jcQl{LURSq67om%%Kj44F-mtd>K66WPh}D&pPRXI=)_12xD#s0NI1zZSXE z6-Rh1U0!(a8?5ao;=46w=3H;jhV!LWBMAh|^aGLE>c2k^y>)}!SWxHnMme{7Pi273 zmblgChp89TZ)5OQFMi!9EA{L1+0fQsU1S6GiyA2G+!*><_`N_r?(Dv1+^!hTDi8$L zw-X?trT_ku?wVKAS}xw`-#XAhUgVah@Pl^C1_Iv?dgjivm`<$8^ zSVLCXBXm{7U+M`ii zk-2c|)H3dk^x5!=kka-)&Dk?m0`}LoMuc-2meZ?&EcQyJC)A0nyc~DOH(BPr-I#cu zG`J+*{eJI;yf1x4K7NtmhT~<7LHzZi+! zmY0~%_L8Yf*F{dgZ(LRWBK_JQRk5|ciu{YTb*k;0eS}vS9tfWO^ZP^mH0od*57oswPl2~1i!ki)*0HIM?*NZpgY zJ3USakKijt*HsAXZa2LX7w`^*4uvgdZIS+~N%?W@mb9eJ~J+O~~{ z>jhcUIqu_&-p#PuHz&Mp!C3n23R85+3i2)S9-Sr%);+f$8B}jRuYI9d+1iAAPOV35 zHMOZ0!rLcO#}_MflJ&>{f`Wr|GPtSO*s|VCeJ#H(6!F{ry8mCnY|ACdqrp1m5z`Gq zGQCnF0)2$up5U5xNT)N;XkDs=;j;*it)D&F1C!)4rxjEi+g}7+Y#6E=@j5=TqoFHy zn)A<&M&f|IP!>~l0Jbx%bddkP54v>nCygu(s^`YvoStudSy#|ILcHd?OvJk5T%P3Y zJY~YgcW_0fNB;x=oMqu5dyKYe%9mZggB5vmo-ayZ{e#X_FUVe=@*O*S(hHXd4wG3H zSz0Bre2o}1cy{E9V0%ay9%K$ZGnp=KILI;Slq4;gI88$L)k2!}`jv)xJYG`6}DM zm}bvMR7v~&52rHc!@8qeV~;wxh!N>7Kl{Zi|brNNpU-wXKMLSjR3%%tJ?<0m?{i8Rc26bqeC zNK+&#X_D!16#6A>4xzc{uPg}NC_RR+f;AYes`FM zcK$i4aNk6B0VQ9mJsD6oONJf$u`pNf)<1cr;q;faDKT!nn~r(CH9xO;l5}I@U{vJf zJs8`j_>xPNY5F&zi-PuesJZJ)&U_1O9n|F72)yUHc(^}ypi6Fi`TMK7?C2;vFeHc| zm_Lo+_ey@UR9xQJA}q72B`GC>Df7%zP^hcBQaxTXzCJbna%aGgf;- zBS{-OM8&T%?)1k)Lp9N|PRxDZICnkJlDLLnVQ#N3tUM)MS_aNv>ohwAVRLTfqLArL z8|!~0NmKkKi$v4h?#zg%OFfv}JuT1w(Tb|2w(tW!xluxW@{p`C2mUbEVvO6mDft#(GElbz%6UiFO04A}_n9*F1jGimTdx0`B|y z9(rKT{F>%p$mQ_?kx$0^R)@-a`0AnsE|u-&uFK}sp({dW2gpbhzZr+syAX%OM~G_$ zPutwgd7RCP0+9fHPvB#mqM%ayEep*DjW4>6s26xX?xV1xeL>&uERztfn)EC*A!z2@|5FPd)#@+wWO2874maEok8m*dJ;Cy zz2KpE%Df{*N$Im9O(2gf0;fk{DkVtmW3D3hSGWpKPu@Cm{Xs>sWoEJWm#i~o-vd^x zDFDlgGrFmk#L&~EprnH0Al+5IEcBLr1>B+OOX`%N32*mF>(EsVz76}7uaM7}vslrX zE>uQSM263RkMuaA(4#N7pj|nHcYJ)Yo@!r4DX&HojZ?`((o^9?+@RO zkHhILvsjnyN~ngY*}+_^Zss!a|FtqhhqK7bG{oRXo~TVn>d2eBy?m z6c*M%J6d|}4Y_(6T*5qQ=j{6dzq|Tmb0FsVTtE)tIYD$2*2PpZfj8qKM7qA zqsFJb(QAt~_Hm#_UX->Yn~oL7ne9%LukBkF^U~2;>gT}Wt*EyH7>T3w3R=1a( z{?G)*jEYID`+`N)OPDBAZ*>)q+?U;&;~Q>+BMd9^W$QW3n7#Tvdb~~|?-%EznY#T$ zS12^jY|=+UGVHB3NA^hLq5rNb5Puw_pwGD1agwz=OmvBGn2yZQf*Z#sFIhA=LwsV|KljF#+Z*kGAAAunq zo@F^X`E%wzSP9CRULuI3dd#hx_6;;F2~b(Ts@qx?OxeFs7Wl7?nek!tsBhl1@6v`Z z+QO}UQ|Q{o!Sj%`XL&yFt`5H?e?tA|5E^(G{I|Bgw6H~JIBcCB`aUudKSjDsjIUXl z7vDNZ;stx?GZJq0mDkE#Zfr(>IMA7dM2UjL;^X~R*45%WU|wujx8~MF zf6kJr%%YX`HNJE0xAmLauL<8FlM0Vgwy_qrGMvwOu1U6;IO_AWCYJas0MoV&S&v>e zVPo}n^o_&{>n;93_#=$PM-5|JJG9l%QR;0}-spC*Qrc<7q_>QQD#j*K5ZgJ!P{O#V zeLgrIbxG|bsA9)JPQsUp)7$Qf8!2?GmN?xx1M*n3x<5_3ml!M^L61XX%*po2gH?*% z*w(TE41;}1JlU=p{=2D;<6%QDZ&m_z-fmRKYLDG^pn8w4%&Bwjrxsit-|6gXy}sV5 z@`dobi<3E_ZhqNX+j()Dy)(OI@8;DG;?{=Va{-MW8+_VPsEdw_@SO`kP-c~8+fGvq zIUbDa4Tu-owto0wl{4HlgZyAd+NAS>0DB+Ft)zk6Z&~w4#{-wRS^MZ*BVju7V@+fU zyE&m3THo9ClNKAE6PoGW#c}{h%LX;PFS_Ydo_8a}VU~L&`?7bT!w$w$?QmD3^iTKs zy1hO2;(x@y*3TU$2iMV{NPo-XicRGSy`Q?US~N>5fV%8;l=AwapSt0#$J>6kTvRpN zSGWrq_GtD*UDNiiMPsi_y|xuTBFd?ew$`VWSBAf^+Fft?hU*gw){Icp%W1Ryy(A~g zK97~4P6x&M{2e%xHZITc^T#Y|5_S+Nb}=9o>xUTb5bg^PtFN}iJH>UqtQ}#V5)VX4XpD=B4DepPKsAYC(}7UNhaxZrwN&q~xMrP<;sik5@rV3G z9nuhCZxQ>{P6)cJ#nNe8G)Ta72BC-KpnlSV{6A(tVnN-A^$ypEeR-%w4$ecn>QAD= zXeXNcTbzBjdCsYIYZkei!rL|9a*Bq|x(C`c)vnFdDlYk43YA%Pjm+`B~ecA$A@YjN$d z9><9}iUly7yG!6fZU`+HRXDh-zM)AxFeXjXU1A`tCc~0=NS9CeOZff9q}Jpn$Cj9G zt>~3>g8D7ul+Oa94(K>%Xs)&IZ|IQI6{9UX#-E~op@x}bc6b{fnx83M4Obj&@vHka zx(cks7P7ET_RMrdG$}^6qv=b3gEV+J0399f>+u!zSM8-)Kfr3ArEnv>v}W?5h~Pm+ ze|hg4#U0x+H#EISYSVgZ_@4P9-k-2kQQT*%I7-ucRIuI-1U2+dOk)I+?sPn8kD#Oo zE*sg+29q)9DS=h>TKv+vi`rlEsu$^MXMWx7>imR>uZRa9@VPgX)&{m3Hv}q%AJl3aY3a;1DB{TOO< zM;j7j`?YnA)-F(pOBio8zo6fgEy|e$qxr6a1WrU~vYC@TD?qWmR_0>6n6EDr5A|Qnj+<9b$-&r1`BoszZ9S5DcdnuUYiVs$X3j8Y(7MEac$WmS_~pk{!r~BdnCJ#P}=NeXN-Dx9v87SE)G==YrzkV7e>ky zQo9puC*`x|Tb=$GoPCkiHu>o`Z*#n32no@R6TulX2{R|=i6>*Xp z`WrbLY#e71j}oB$2Xsw5j&YN8f(c|8Ca|OcmBWZ1-N$lR#h}@X!IG`q7sd?+GBtT9 zN-oE`4R<11+3lju@Qd)%Jv(Va@{0bwgsG^9gCy($3wLB6Wd*Wy{5G>3SJ?j*KfoRv zkV#gOE0hhx!K6*hql%3tf$$P`N4r)uo0W_=^lThSXFO(Hm0lk4fCe!}aF>+(#x|KN zY2Wb2W#kUR$P`>Zx0rI@s1W~^?S?D`Hdj%^^=jSlqLCxAF3>W{62>3&XYIt;Gtm*% zRXquP+uE8whA2R+HdYNL_4i0gYJ1%i5EJu^vXy)bJqtSDa86z?Vax2)-Wpq_pZbQW z3+7AAqQ4-n!+I0XVZsb9(pAz&s;62RV6k|vpsJlfKDZeq8g39Xo!~}j!JS5X>61h! zCc^a^6GxpTS|d{#2F>#kFhUdIBt9Gc4;_wu3$jx9DlTaz%kpx}A=sw98Ws&X)W*YMYBNdC&$tL=w;ferA z7P*TQf$cL0hO5Wo%-;zxW-a0%u*h}Cco=(vCWfDv;$`(vInfCv(9p+EXm6ADliYQ^ zayT-Znqs64#Oq?1>)=}l5Yln-GiAK6O$Rn$?*Q7DW)qMxTefLm_%A7w#iQxdci zZ%Y|ac5AyW}Y@w{X&_oOGbtgW}waRHt;P~ zk;GRqNuxA9Kwra_!ix}X$RUdupjyUh_{tK^Q)3w@5HTCI6!5GZM_}Nqq3bO^hAwT5 z@|*OwBHFML^aUD)`hj)FRwBLN0r0=D^WX_H&B8Og>h>t#E6=E}szxTNq^6qPeJ?P@{B3TB`oL@s4EzT7&omSHX%9TM#Fp@c_H_ zvMEph(@?12tzM$OYxD*G1n-1W5x($~(0phU=$tM^<*7|EY%;F{&xU@3O+#)(z5uV$ z-%~rQ*XVYGHX$*X*T^6+M&GKQuf1i+1bsusqvwOonuBU@!ytG9IS*N3I;OJKhJfcF z=s@3Kjds0G1wM%eQWM52x;LO2gbKV(YtT4=rb4x*-3C5L3*lG*cPZ#SY%e6vH5 zwAl>PD^mi93w~#wY+eS^g4qx+&`*=jG#wy}V?ck+%K>`E1cU>wGufC8mbC!YHU&%t z=q|q@QIMM;nS}t-1Ffa~z-P)de*yT~?WS}S-yC511ib14B7s%`WY|CE-6n(S7C`US znC&e?0R7j=9A@q{LQSzC3V6HaskzCt()`xK2H62jj~IYVj0G2%X9JASVsI%ySiTF{ z2W|p^p{IZ)!Qr;ODiXnpI1{fSYv|F8!>FbKLM>!` znDBuziExF|$o$C~!yX^XES=U{fNm!rz&<0aq(fOILZNt4@&39mCIoRaW*XLyn#?*# zd8@ux;a0d?d=L%A!Z93Dm=%I$1IujRmqY4shTkB(!xa)|vd(eJNi!#o6h1Foq}P$k z2{_VIMh>@~wI5X2fXmrDw1jYsl0^<;_* z+DH1plyHcgG=gv_J5yfxRx^bXN4i56vZC05bh1fUos#;bbscgcwUp#VeZ{b2#S`X@ zdHtREOPZKTIs-Ua_E0MslUOvw`{ox6H1Jt(!M~Vk)9w*sqiYc@7!Eh zZZ)h;DX$em?o#tu6*df;WZpi$D>13_%`a)GljSX+4_>ra=U5tGo$m<-saxb zZlu>U0+?zBhSOq4wf?A{o)?m_M?8gANMA~-rKGd}@dIs?kglqqDe3JYcn6vfHj@}= z{fFOcGYj{sv*VY%>;!~B`G=lD++`haH;*?&+AT7qWoEun3P{^Ac=At0*ker_nu?(|? z^dK6U)6LmVbws&~#xkF$-;@Pd*)iedPR4obowPQLvvy5cS#m*FGAWZmquga6**lr< ziSdwUo!ftN%Pf#Zj38PFdyDl2z`FSqt&;r6`JB#_2&fn6H`q$O6^gGWhW>_Te?n*^(?lsFoVIM)9xdF#JY)nfX2Rtftn=ap3< z)0=e^cB0zjTYANP@JU7v)5)5_+sqr_1~9fOv$9Vl`E*)i_R>8Vt0Hze0tIivEz(u!aM9 zF*FbhO591vpjC5uc4_qMB2DW1)ZG(M<|8T_8$(>mCJIG-D{*#>yliG~_BF=U)=&3eTGX+L&X3Q3B z79`x+ zBiToAMDBn#kS{q()xn(~eb}U1lC)bmP$TZej+v#><4i(|ls7$_6cGa+jGEPn-4pSSr zTEQb*15q^c{O_B;GsZUIPLO628Dy;TNje9nmtVQcBw1onNmO^pY z!o~f<#hr`0Uf^J9VYhs0(%X)SFte-@M^E~5VHS8<= z1CiqmM34N2d8RnlbUzd(J!(YVzO1P4J z*g1`ZB5q`KMtJb+;%H6@GENvHTmT=J%~NTOBH`2~mzed@?^>UWE5zPBrRbcjS^mIy zlG=nhq2yr1sdR_oNAMipMb3KhDf}-@o2iZ*JNP8CCF(_`n(HR|!FJ&`iWid%lQ$;2 z5aX88IG5zt-L;}o!hPHs{Kp8Q)EYfen9~~aI>JX6lrsL7I*Vd>hXjA4U21P5TiHm* z{e-&j6B=`c!!Ry9$Y+Xeli0;6TkhkPQ^>+CSfvCYKa&=5oI||g(p1>v2h3IyrlLo;bL+u5bAz#uT z4f%^2M3<$$C`?2e4I5oXuJ_obTnb7r{>V5Xx-4EN9YF$#SEjp-`UP1{w$X*(&$POV zLc|<+I`R_Pr-(DNRJk(~OD_f)CuR&~OZST-!~*Fr?1akIXcAFwsLPi5F|r;pPf7iS zN5!{LhVr;tt)furaDD0+-_QbIb`9JtDnzD{Sg?L#=M&(!b4MBmxPU=tW zR%$eTDoVV!ZB5*Z@P&2$+)Q3J*8~|L8JdMgB2|{iyk#)vT;$KjOl}(YJGWZ$kle4n zuYRjIE(&d$8l4#>X|d(8`TO{h@I~w&u!S&N=`YG_7>uxtv}#)^IwxKX;=E91DgUH# z)L4lHjfLSi!dJAPlKzRr2%n07fv0GrhOLSbOl|BB%MSV7Zh=pgUJ%$y7NX@OQ*&9- zFIdoQ5pEY;*LD)mMo_+FK7b+`=X3>(il5qRrm#vf0JBWy%3v}iQx7?4@r8%Q9BJMKh6+a4uG79y{JUXkJA1P%MIz_&!U;aQmv*~hpR*Y*` zAis*GV-1V?&=$E=-LD=Zh6gN@D?`>*-DW@LG|{JU@4$!f>#F@G6-EZm%EJ8MuFPj6 z-drDA36m5)N8-parmHQN;j3B}M?Q`^*!qBjGG5YG3P?$+?6RhRRF26+s3n~p*pz1k z-QZBPKRLMM2N-V-m@l*FmCUJ)58fMfR9nlfX6|9U7W!k>O1*KNi8rAc^iKH_aG~fd z{TB~o?c}Ls&($SnX2xr9V`kg$s9-pW?77dK$tmV^3Fnak=CJu`(wX1cax(^xM4P5@ z4)aj{D4|fQGK#l2rMW5T=x<3q5O}2I1nVaMIk!>x7_P^^8IdM+at~HP$*BOxWWJt@ z!2A?J2plMlB@Hs=THyXWhzk3Cu}z8p;>R zk;gJ$I9;TWZXicAw+MchZ}io`soyuTMos&Pj~L3y}+mlaLSN$`i&Nve|w2QXPV73&(N5 zd_^La92U_9o>IiEG^(wKv3>wUO?>9Ef74k=Cq7Oy;c%E<=RzYl6&L`}3x8v6b%&G7a zaPR_&8-J28Lb?}i#0wN7lDAEzA@@V8+FWE7vLOCuK_1d7yN?j+$Ykl9Mhu!Ro z1_g2n#eBfMDD@*RscxFQG(AIfcfXF<>prHmQQQG%n-MCccBzxcSI<&S4IVmk9F-h%6Akxi{6NDfYnL9 zYKiJ9*~xEh$Ow-K)z{DC+y@APl_U|lBfqBEq4^-cAX?Gc5-aoVDDGio@!295>1vq= z8EBMZ{75kcUN}(l)5R+>EyrLfTm;re1U7?A)No8NjM&7QP&nvyEP75)s4z}^Ut%J; zEuBo(C{C%WCcUH^;w?{dq>>=q^qz^}pzGBeP@=MYzOAnulB@u4qElGU5lU)*X!x{PH9# ziPSR%uoV4tKgVWY&ck9ytDp|Ah_;6s?iTksRS<$VYaeoyv| z98pH1_5=IkoL#?W%9#0LV}ZNm2EJDQ0;i##WR7CA)U9()n81xJ%@o+e$AqTRRD6sg ziI|ByDsGd*0`Iy5-`0EgG_8T1gntS@A;pU0M#q`O6iEbsM%7xD9j zdy!dajNDq`Di*vdi)W-O`e1$ zigpj|PuuD1m7y6i6KvyL5Nt(96LS1C`A#`Sj>{aN%)D>@Cz2ccQh7(&1N*o({90o3<5+A2XUuZM3UHIenn?B#crPI4Gu!w;`pA3QDaM125%6aNFx zMsgO!BTLbys*q=43xqzcVG)tO(<={ijkxWsnc@h9jpZnIsWX(#gaUrtu`F`5cV|fz z`x*B#^OSHlG8O$mMk~Ek#>#5xKUaM`9&S!0Z%|ny!{$>75tC zJw1zytGOBQ05?H2DE%hO!(rtl^$T?s=H3|+YUeS#WFGGzJeso{eRRGlZ+(B5Q4eCdq6GExfvA?N|RN6W=G$sR(9@fB0$O74boFYlJ1vz?|ADZC%~ zCh`=FB`zzh@ssjD6a%8it+T_XdP^%AJV)Wba0Q&qhh(4RZ}Iiy5rv!d%|KX+t4C5s z7nQ+p6?h^$#1L|k+zYgt^@>6KZ)SAyYadnQ$1X4aJl;|mLv-R6fTq47n&c12Iihi` zz7Y;!q%!6HBbXyxDz!xJA~dug&BJl>HhRwRIMc|}Coybz8XU?$B({{I(nqp>l)&n+ zIb6A>7CE}BnbwD?yS$wVaLC8!m@6s1cV}7|H2ey!`M3!Ki6qTwnWeZL~^2|)n z(qu>7OKuu(rN~(#mWF`Y<0j=|)e3o%AhhOb(A%)s#)Yh2P6H1YCnKAr+Yk)@s@$S@ zDH+>SoVYFEP{AT<62F!=QxqeymL5Wu$aaDr{19bAC54xRxCx6pP52hV+d>BH4|l+S zNE`8O3Kyc8pV-EY?+^AXz0RzL+r>1AhvX}~5^=@M$Lbk_>m-vc%ftgjPZv;$GVp9=2jWO*Zn2h$Hci|a31^y`AWP}BrDkJJ%qn*QB3^D zqnTqk^`cbP9pQY0iDb#%$=Pa8^?+)MB%|S^?=<)1>eHfe{DqvC;u8`DQjacH+*Y4d z+sJEJ^NTKfya)~LE)^~1%;7DAwUP_6(S%6ZtvarJEwdkzr^R?(Nt;RS7g(}3@kWU; zBoe(ryp^9*#$^|uzhr)XTXVx5GOL^WrnRptUjxe$DxIH-}|B5f+;x%i6Tf+FQdw5=)gWS(T zz32tfgZ_o@Be%%^#YVAwN)!A|q7QX51f^^*ZkSLhK7$0J#>7l{qI@aVNMjWo_Yoz` z8i0g*I6L?aB06j(4a63Z-{pGwGxV6gF=Ml*U-GwMl@O%d1tp^Euvlh_-zO%?A1Uf( z<+{g-4PUisqoKv39A1uaI~<2x!#G4C{!2bzl_EXXDTxSnO~@H!$b`#y9-=zQB=j)x z6@Q9fP|1yq5ue8Aex%#RGA}MmFoP$7WwP72AIO_j5_>hg*u6$cgX@tsugkNF=q#1KQ);zhi3g5U52nE@Y1o{(24 z*2>+`n&H`rK;9vtSHA#0!n-6$m;6Fqh}DFnJV9xtus|8ZZHb{DMOSenT2xcEnujh*jcAMddtz-U z*POYUB}3xSW7uCr4zZhblV2io_>=0&{FnKeHN^17vjf>v5eOf|uM>j=P0^v4N7!=X z%71)Y7F5?F;*Vj!V6TU#p>+HzKAxDY*s0u(7cw6du{_JdzI1Vf?Q9-b2HHOp>&5Mf zgNhfbSiF&bChyT#)9>?iZKBQWtK8>eon#N{habVu%310=#7;(3p7WOv5!HjIU^csq zR|zM=Q)F^5mzhX^~=d%-SHxMC@RDsSoiSUqC!kfik%9r7mCH zeD8J+iY~D?301NY@lqs@IDt1{n^b$1IegbDd(UTHFPle;)^Ixo4$@uX*%Bd`f~pJh3jg^jMfeQ5l(0dMqWcxw zm1iW0UFI=3^bG9ln{EK~{*zx2Hub{QoO1f*!IzvTq8nO15IJ2?yL2lM$Qc zWzr=BtJ078ZvD~JJC<3_DCZ7~nq_?Q7;zgfBV@`KB#r&1j2S472x+=PM;HhvU+9Ar zW3TWm{3^LtWvhOK=-SUj=)A*ACP0adN9;D?QJ5)M0i*ZWv zfHiX+%UpCv94_(3T8SZYo^qe&f$}uhQ2fTdIp}$7A%kL62#&xC$uZ28m_vx=14<`l z5&RFB71?-Q{hdX%1J;V$@L9NAT7%8SBJsQOZgM_2`ahJ){Z^WR49=!6U`-VFi+8}i zQaZX98zjaN=F&pCQ|Y&mtI21(L^L;guAoHhDVi<`lv-hQ(weZ7-sP-mPKl3;|J~@P z{|p`D$>CcfdvUX5H5y995EiJ1a9p1@!!}y<+f{ds8e$7UF5|N3sJI$o;1$45QZ|RS zw*-wy{1Mh(L}juhVh`j#JVk`TTTyFriDHvnFYIYT8$Qy}Xcu;g( zMkn*-=Bh13ivCvS9-ouRUwhQ-t=s|8PRVQ09pMjTDd9rAl;^4D!MP0?VcvnODjv`T zY+GJBJPA%07Qz$pM0^Olrh2M44Ta_=`#p&F?E1vAVowt}!R3N&UJm z07EEPAbTji4DS=iBOCDLqz(QaHr72&)OpU%yG47-mas=jm;hnqh{qt7_zL+Ma-#Gy zLsC}aHyD}InZhw={^p&My^y(!$HSJ`5BZ4VEcTLjqk~9r_wO$Dr@dmgvu=v5(D8_! z_>uG$u~czR(S{i7Q}Pc7xFkjQI&eNPr}0)IS<-vr^Kb=tJI_>BC>9Dz8_q}U4JoZn zU@WC?WNv}CqY3bz!d9sTc~<_H%7(Bs6lE>uFJN!&JJuc+$^K6~A9Er9#KX|9*hI_- zwU++FKuhAjy$hS$PUkyuwsYRYO~f~OHhLZXO$hNCDJ+>Xav?p-?PL7J!6(8X)|ZD`OVA_ILqwGm-1Vx6Grb8vrr3U$dH@4#`T}@$}SOaF5@Z7M>IzEn3w@F z1KW*QxT#bsvFQzuyzg3`y@9ch^A9wIYbrS=qZ21oJ2hscqjbEC6mG7)?j!Oin^$sE zAQRdQ(H&Vn>P&Pg#EL!?m7%CPt23{{<8^FiZx{0swUd2cVukX+7~`X;B!*>Mu@hL~ zh-J#42bS?}Fr2Z9@sYnvvJ1>uZV+DNUJOTV2!WL7NR62F?P)>Kh$B0d`%vUB36>s4 zClRH@Hf#{g3@-E2tA7PP53sAa1Wo7m3n@Aswt&lqr?`3}IJn02!dBF$omEZ4aleyNyr;?fQHz6*aCtspyL^nwX zWT_IZe(bLbx31`vo()_Ye}@>XZUkrWR@GbbqiilJz=|b*j$BA<|C*QBq`S@;!<7l! zcz48s%7>aG* z`xEmi`=NLd@)_+1S}3@zTr!S4sSvPE=GwU5h%j`n<;F3Cd71D*#ELkf97CLgmq=_C z=aext(~Pz+2clbgg1Hkpa|OpmizMqvcf}QSzW687pq#GQr>{(@{&XXHTkj6ucit6I zvj~-_NkkchY2gF1V&z?O%*czB{h$Agou`{5oFzCWIxRXZIZS%X%~50I92Nw6(A?pa zw0CZ0(M!7DaD90TM03TF5`wr+B+Fu@8te@bj~=3x%5^Bz(;$5C!gYBBvvbs3$MH{4UvQb#JRDsmV7{ns^8@(ut2x^0mcT!}-jz;gxAEF}OC&R-OJp$4Bk!Wyq*6tgVj|~G z^_QR_?~~;dxgq>7+{5sDSr@twKSLam%|>eF3sjzbqW*mF70>h%2UZ7XGpkxSL3RUs zhiwOAMn3XR(W<&ElC&}-p1W)EU1<+Fr`Y|XKv^{=!gR7;DS>K~CCVUiL)VS?LHDT{ zcZSX|AF?ir^1vPd6*dRmhoU$oFD9pnd;9nQ?DZ&3itLT1A7|=>XCwoFopl%uM^Auv zdpPk5E*uu-&h)8|+t3}%EMSb|9~QdXuZxl&9LR1 z5>o_eez!U>kn@6M~{HokVS*a3g+HptyNZNJRtKT))N|nKwaf zfws#3Q_WX3s6Hqr@@>jO-Hm*nHhkv!@%D3nav@%&=#}g{xlrk;r~t^Qk)*C^q95*N zRyN4~k2jY+4?N$t(&b=}*uP{mnI;#=`{A+evx5^|Ulc?$Pw_3-YAz)Tz*6vYXe2IB zPF9D?my0&E?h4uGx+Zrj1F%$cA~<`53Fuv-7`+1CP3ttDNEZKR)9KI!ZZ&xx%oXC# zyye`J{Q1(!#7k@wUa3sdtd|c7wzpM8IJsZRX{N$?-kc{~2fhSp!=pfNR47a|S#opn z=gx&u^{$W8=Z`!9=Rk~w>qTnB7R!e0?oQ58Ua0tpTw^@l*xh)K@p)CD>5| zeT7T~%Y&OIT!uMnZkil74K|Kc&c?#|UR^H}yIkKzLmdG?Wx+`J0JfJIX$>)-eSOi6n-4)20v682U{gDCGgX~mBYo4i3DaPQ1^p|;u-Hn1yHjHQVGu^o@c}kenjzyk_lA99XU|ZIrsDcMdM6X2m26zzqmncCY_GCVi#}=1+4k1+|0XQI@UAL zdv@g-`b|!>U?sd;Tr8!U*faov|xu2=&Sg1p-*EGJHGDhrwPE@dA2h~G)10~a1!xY0~pucHCMN{@bHPavP8G1lHhVh0+hSk(5 zphwwj;DI|msZMGN&>X!4)Kju374*Nmqkv|}PKXQfp(x56sCiZ!Y^m{-&JavZg;)>* zIY2g$3NnCQ@tssXwE@^`NztfCgPf|R#zOs66Hu`*P%YFjMTa1$0_biQ8b-j~D=0qL z`F{j>wj9(YgoddR>L}G^aHi}if-EGw3|Xe34?}-;5sGs2t;UrI!z^ma{*#t=?I94ff51B ze^ZTA6;)1A;5@--pusv0_yPf+*ibuFLgfK_yQu~W*Xy)j)?95HCSYh@yI_ zR0@K|0^crBVrn+9X9875Eu>lumEf)~pf(}U4gCs@rdiNTX)1~8;*o;E{euAmy9d07_UQ$bBTdW{vL6DZ&5`5FrWiP_&Tv-WIxHP(7+f?==(7#I zkd~Rn>1VsMuW=7^KGJikr&Q|5!vUj#D}z@Cz6|_NYvw)BSUJX8#6HIAWqLE_KsWT8 zN1!1OoqS;BpxdD7@D_s(8cRRUSjHS-JZBuE7gKliox`7pUJa!VYxLoU2Iv)i6r-PU znKh36iglcMj$ThKFnrK28%Z7NA6hcfI^w7Q4z)9#I0WY}HjhoPW-@lr=1@0A7()*R z!Um#;^7K8>UyO3bc)AVkA9@kcUSZPjQmaOWh6;vh`kw}OaE`;=;A#kgJn5^LR~gR? zS-Mr-MqTW_>x1V@wDO>hr+9zK1=Wo`t%b!+VBC4RH)Q&|m6E$!*Z zK|=_ml;$@)deDDRH*|!8IJSZ!&S@HM*lsuneW7onFEOkikoMl~l?^%323V)qHFTL_ z_ej0rU%DOh4GkT--Ze?f?LIcxK@HJ&(mN;%Y7Ri}>cz)J_c=W3i!P+!YnXseK}>_s zka%$Ghzrw2$d@#U{MerjKEt;SAL%mIN#qrs$qLJy^SelpX9@i$|T zwShHt*r?s8{(PNjUvQHJ< zZ!U%ml7+}(npH<(yM}(&=)lDL%u;DM=jeV zm_{l?)YzvAnfrNVm2(oiG-rAIy!<~#c9;j69nijyK9RhvYpZ;+omkybwI)hY~q_mg?a^t<)2|(8asMiA_uP1muj?YjUTP8+SE7xkm2;N;O`U41^8K$^u8l8 z*FGBj+}mnw>Kt$3pN6&$=7|58+O%oMv|WAUqHn#+egg=M8}xr^ed97_N=BeaZdUpY6<^H-7ROhxN8&CmOyqSNEi2o5sAC zRuxpaHNV;)VA{OKSTK4Yb+yWk9qRIY|I}ql<*45S?#@5*;x}TeQEr3BLzSM3^Dm$Wmi=1$QDIYbySmbH=aMZp#~Lnv zsk(RUyD{H>&e&xS?Y0`G=jW#^tm#&lEgmz=U3Q~Nr{f>W__tliZkvo>!F+K8o zUE6XRYaCT`7Pl=FNOUQG$Jq0~tvEh?YwLEu<|I38*0SpkpW0Nui{JT2?CjcM^JVG$ zxs=kn?QzoQFhOxMvS>=>{Dsa%LgMG~VE6W7$M>_I(q_k$CY_Z0oHyF&^^eOQryBQ7 zKe{o(?q0pZE8y*a-#fI!w)UH*Y-*okDkr;qlNZL}?E$7f(2hWl&(OEebGHvJ9yv1p;)J{Md3{)p+pn!XJ?K@N53?xeaqx|b=?U(Boxy1h zRhGx+GA2Z_g&hgpMEeIeyBaP9-z;_pyQrTPUk{j7>SsJ^cD&;b?0n0eWT%i7@qgFG z2%RT=TK#{*{DEz+KhSz5IVM|qwZ0|`z%Xx2I`F7Zz6QCi-Z z^VX1J7JFT*U+L;zFNhyC*Z$JxarAoYtIjS{|Ferl z#`k`&`I@1M68cYA;NfPe|(j!^$FWW?nwXmYgzi*;is%;4U={2l?8)^8SQ;F&bJ&F4;20$>gZ)! zO1Wc`W|>ZS1uyFeEt!?wl#u6V8~&(PC=9e9rwbip87bW>G-XDo>(a_^3wQFL^-Mun zn#5{{$p4y;j5;$l!0JE4g5NuG3rm)iTq_!EX&1+t6Sj}1Z(qu^4>at~4f=VbJ5k-j z3N2iy9l~|pe-|g9r^mSTUDXv#3z*O0>`Xfw4(E<)S`Za_5?>ttDacNyoDz_t9bJj(;+F5Sj>E7O5 z?Ol}LIK#l>)QLxobe5gQLh@SQiUdV?Sj}E+AJNb=k5@NcX;H~cFtZhd)g0@Rl~==% z`|mIQ$xDVvlWaTGezU2qAcsyjcy&B1DoUMP5Tt#II8IT{>C`N%eHN!_q$6a{#QJFr zw}IEa5z4i)M}xVV3-iCua8amgA`*2uuN&DzyII@WX6Ss&#g@3*fooG^A6FFFQq@G9 zaXe<%$F=DR=f_I^?se}M4t=QLHgycA6}Uxyt%)ZOIUR6nHXAGAww%g}FVF3B8~KMX zQ(0J;+gmyZnz({da3E@P)iM4_$;pvmpjM%g9~UC3?R;BZ7yis_ZQq*MAg^PY=0eL+ zy;eRJD)F+;4V{1MTpB0k3G>66hXt=~4>@;De8I0MxKp`KY_DJPTic5mysBk=&it%y zQ{I_{r(Jq1M15O+JdS_fSi%j&M$~O)_e`YdF=~QVSr6}W(8KQBHlu!qV2#bgr5_gc!$~O@{N-9l+dNKK`O_g?)4mi>>Bc!sW+Qv|3i`rf zo>gUW;$J_SN@>kr;#*Ea`x6Q=8bZ5Jd^aj3+BLPMQiXdjXD&;jnWtyz3oYf^F|{}n zT75F)+CU#?P2BFpKj9sV19tJ2hm!Uqm1XyX0;6xXy`iq@N5Na|DyRImnTyP59Z2&2 z_0#mlmT{(6YopnMY()@^R$pa*^gYb~H-=X5Tr|ZF!cS$-_y$!!hu3Sd<}HXFHP{8z z+Kqdg#rkJ+?yd2%le6r~&xDWmeHCSu%V~Vyw~w1?8@xiZF3Pz}Tvs2~m@TOv8)u%` zTa$aVA*6Xs&a{@+fk(d%RwtXUGfM2=Zy0B?f5C*!=#)8ZPT%OZ_@ehe^+`Wd60;^( zTIuqHR%Qz)@on4%Yz*LLf< z7Gv)s{}u8>$3`DYUR&@`hodxOMDvNV{uP?KqxTZ;Z!T-p7{^7%tBitVS;HaRjIP$g zt?C(0!Nq0|ZC|cPHVnSU#@W5yV6kb%7`CJux=>maJ&<;+f3x}9iF$rPjAh7!{ zg1(Lm#EEf&S?yL1*2~8E*5+p$*)}asgIT$;-d|jAg|92o>C25O9gezWZS$L<;Wak} zWs6hKX`gDg8J>oYF3iDwX<6N`bu$}tpkv_llY?C$?p29)&l)Y6YH#%raw)2aEDAdr zr_0UjsNfWtom%|g))h0S0r2-w=VJLkR;OAI#Ybuho&}x7yEZgf|2}_0#`HlhKBC=J zVgyZ@J#BCLs@S@quZ-3m83W&E2U?)9VdRxm=oJuJ?jZCR>$(!k>D3 znGJ7V583iQ_GQuv=xuLw?AYkZt$Ri-TC;Y4+7{6`124IkQI;ORFCZcGZn2h=W9ER2 zmBP?>^PUN+P1oZ)nX~lfLqWW8(teQkC5tWesahsLcQeP65S5%)NoIi1+(+G5;Y`IQ6C~DZ&=GL*Vx1h^PYa>}9N-2^z zFSh$Sw`5!ZdA#Rn;@6Nj1z7)7jPb=wE!c!-7A5E* z?#ZX~rgUx2Z*8$Lr2qWeM_u@v#sGaibpg{(#@^ksrP#4#UfZByBXZQZ#Z-rucgwQ; z3JYkj4T+t*jgL6}V?JbT4?9vhtkLZ!S{bq}BR#S5(E8k;-8TfVX7vQ-{B5&EraD7H zxm7}Ea&3J-@0-R})>dzyI!k*>xVW{nX%;6E6;H@==rVn6GoJG@w`HW+dOYLj&*uSC z!pGzfbnlfm*e6Y2<~ZH{j;c+!uBtZsR*_-k@6l5%FI0J?rw#4y*EY6|%+TMa*Fr8G zg(5Z1sp)Ag8b7e?{8TpaplxLqKXFaQ%<5F_a^{eE_?TStON!ia8}0rWRoWCK-)C@qj+aiLR(Smx4Id<-}^!sueeb4)ZXC9 z-yPl?t`sdQzSKE|Z4C}lyG)^tiNJR;j@La(oF6qkCw|bww8&zlFS>j`vqiFacoP&& ze@l<#-K8FJ{Kdm9jD{Tav9%A@pnG4F{oAzaaPx4VlDhy?XeOIh6V#+cnl`>pUa$Bn>ZO38B_o%wE&3gLPHVI(=F8>q$bcp40hB^`U{#|?=!-srWhnp z5X}K#rt_e)0Hst@XP|vh4z-dVMYE>n0DhITbRjewprCgQo(4MY4XvHJX|SXSpyB(P z=16UZo>LL}rL=h3Pl`o*4_ycBBo5SP!=Ka{Djs0GMTVn>J%Fvn266_a9i@I7zEL!w z9-VLK)L$_0LCG10834_Msom6lY8~~-kY>OkKPuJ`1(4J&)HJ{f<6(GhxCanqEx?O& z04Bs;u-2p5rIgm4S8?pxW1yeoHE?~(fV3(e< zhSmejI)SV&c=awQp9%q=)i_Sxo9$E4f;Yk;Fz(7R)9!ZLGF|-z`mD5nba&QmO=nq z4Fc+^1jzCbDAgEZfZmV~Vg)qJp%Q>sV~_%}1;}?z+XAIrVb$Y6u{;4ARF2k z8jH3bd_xB)yb>}3+D_9!t?i)JI)JlRfGD$|rPO?Yy3Yc(R8wQ1aUf6d5BR3spffN* zc0o)TQzTUf+FMNhO>L(-451(zIlz}LfWw;rdr;sBMYU2zz{91KhzbY3R)ChGL3$9m zIuHjK;hR0=4$k@^K} z_+OcWps&<}NYcS|OF%S+DH!Ay+CkR96x2=ue_DVSU7#!@P!kK--wbRJK#f#0_`V*r z107i2^Z$915n#ptY?lL1CV-NQC^fLDlgb6wB!gT73*0LLt=R;wz6dfRk>Ki1kbf8l zYLbB-wT0?7q=M{91l14tjK+YN{V#`s1OE-+KOyiR2sMJbJE&6N2_N*0YN{Q?R0+O$ z3b6pc)Bk#AJMbI@R}}#ZQBW>8911=OKueZ^Ywbbwl7V-|PyrYN7?1{v0plDUS_O>< zcFTar`5*!b&^pjd&x3r@BQWxO1NDS~=k_A7%mDIHi>ZU)$<`WT3_?f^4H*`KOiv?J z0{I#S4WZD0p7D?Yh%{8p14bdjkj2Um~e;VW?TlN2o;N_8&k-0#L{uxm1J`8CG zcMWyZz}GBK<~3@o{tUy45kI_7{}0V&Wa6-h{*cfdsD=^KWqkzw%kj%U8+z8ElV`8Q!zvb- zooCeiZYQRVqxX~z(acKPF6LasyvcnHKR8;|%ilZ7^j6-|Yw73OqMUAzE2)LP3!3*$ zOrKgNwK52sEt60GFm(12E5{jZvd}O^zwz{bRjFrZO>K4LrC&XR*~9Z`o0Nz#S2fvc zyUh`c=|-C*AF01O8?%oFuaEeWW?S;OuUxU-xo_3Dd0VAd+XO|HaX$)1n?78=Y(mv= zZ$?`NCU2hmvGMea!rp}|bM%X&#^Da*OS0oC7dx%xCKZKG_|M)lW@l6Bsw4gWZ+Bsf z*Ye7ye)|K7T4~^;yU{!HK9ogQqrI;|er*VSI(gFkOeb5rGq!Kdnc`c0?$!30y8~}~l_#%ma?^*Z zAFPPlGHvv%avOhZ-$Rw_r&*qd7T+!6zBlBsCps^vs=D$qgHiOy_I7c=iiwAJZ7cOm zJnsC}_)$<~|E^^o?EyaP#p*G}IcZrBte5^DN9O^S#?tk17EnMeU_r2;0s{8lTQo5- zz4zYRjp@1Rz1^7Jd%rimo0uMZ!w#r`idYc^Q4yrQ~O)aGfyQ-g;CDndtTG+qKEiQ3sz;{}0@ZE{e(u)(f`C$g$=c>Q&{mTC+saQYq z$3u{@W!Z=L%jT-mvo|PLFAbU&J1>uPnou<(UyRO}+#5XoJLdg*{>^b4>Suf^q%RAb zAj7p#!@JzhR&T)lHvxi@|GaV=neQg6KXIr3!_F5GYoBu-V##}EUrL8a=j&em&J#M3 z+7nr$53?TG^6a+hR^|SAJ-fuQyBm4qxg$I+@|``eS?~wYm26 z$NJw76`Az8GbQsDQez9omGrvYnk4satK$~Ww0j<;>{*&U+@0-tb4XM^2D%ac3BRFr zgG)kaXD8*yZt~9QY^UBY2GQY^+Y2%Y-MQ@6<)I74ucv3qQhz`AzNC0z*LWvpOma$x z|88qx*W@O12U9z%|B&*4)}46XZM|0ldpft&dw}_lJ)fRvxv%vdp4&FID5|3Dtw{qo&-_Lsl`L=~)1ar?O)5y1E zr}DyzO}fVsIiuba=9OobEi(plygij&d8H~-V)O&2UV*tC7ctB|*L1CGb%>P9Z}C^$ z=BSvzs~6?JqM*B)|P}5D{WHg+L-Lq?@w2k=sq#JV^5FL z1PwYftk;c9X?~mg=8wFoZDbr&fqO>&NO|S>!y_fIAxagU=D&)Z)4z1UWO?56JvaBm z!IIUj^v;8A(mpx%wEYq57r5V^y(z2G&xB89pK_l-nP63D+@eU$zxmxRjS7I_+v+FM7Mfjx#QgrYkDW;SJeEtHu$0 zb}F`it;*l4$AoR2wPfa#FcxH}yZP?U)02f25Y<23ci5H6D@|G*H9~zp@J%jjkTp`J zii)})b4suST!(4oB&+@I!kDbs6h8)keef9eJBMiwy>1zlQL6N^M};?P7fP-Pv&x67 z_I7MHy~6myvoO_mEU&Sl#R*s9W5UaWvv|o?=PG$G9N%B59HsQK{uNR(k&-Z*sFN~E zzLq1(P2NkU98AA&)mQlPSH{SdByp-r|Ml&kf-+{x_-HcwXI&}mDhOLT6jQ*|sZwXGCzT()CPohUPuI5QJg{nt`9Gi>LyOT>3S#{HU#1|+k`U~P{6uK4%1c%f(b zXx%7z@MP*Ih48cgN4%th=55A@6OIn%8M`9A0==L$< zd73DGKR=zR_msIo_Df)mhS)l%>S*rL-@A)CD)!VJXi8EGEC>!{zHxaF*qzKDwJ+*L zNLeI4W+&gCT8;f~d7!QrIhOWSkE)+t%FH?Q$G!Cx@_~Y9KaO9Xu_#p-G>-lQ-P^Xj zl=m~T@KD=(2TaW6(YwO&4&EKR{)Bx;JBs+fre92bPuyF*>f7YTpYG6v+~6?z^$#;; z!vW9I9I(4`#^r6IQ>Xu6-Ke@HnB;LZosF-{q0}#lY#0-wx$|+Q>S8J(_MO!GrKppU zvS$2C%(%RVb;sO_)4y^@S^_`gtAm^uOt?Pop@UlB^J;ePSHs_a=J+weDNaecz=r;k zpt=U_2Ff3fFl=^0MeJ<07I#%i740kh{b!~?(PC6TGJdu(dF=?f6_psB9(JFtrj}S0 zp}_`>@@0cbP*sd6ZxWL_s{8u2&W19m%6^4M880Isl)Hx|M&C-BZ8fG`mromb<8My88c;CeS~)LwmiRU*4+uzKn{aRdM=SL`44NGW(k&<<_-ipit7s!vY-jl(98G1MBM)+jkcxsyEaF0YX zuDVa~zA8fGE_>YZuXe~>iz_BAb?o&z5j;9t79Q_sW~H(}vU=zz2?|4{=2G{rChMB+ zvb7bO+DvhV`*3BlKKFGI=ER1Q1wT(Rxte{WE%pOSS<8;mt|0z09_+5zW z*=-&nzw&Gh&(Ji~@$yEuR*yKXucX7Wa{K=@dr0K=4e~&SJ3%lf9+I*()zL^%kgpX-m zS%KKzPSwzBs@~PPx`l@J_Rr4GH?*Y*gI_hSD3Fr+ql=*ETyNQH9ujn{`*?9ZhL;q} zyi-ljpGw%2yv{4Bp(y{Y!8g)7l40s9h%c35$0ryQ)?0lN%*|h_j`NkJG=+S@tq_O& zdQ`e@@HXpEl6TAx`W@Z$#xdnXm8l(l*26q(d`R4Jze^64{rbB71rH0a*Njno(#KOt zet{7VAv<|H+3}viwxvV66ndFPw!ZO}C|G*7=hDa%sKVw2V>$m<&|&|V+&i31FMpR0 zr2EL6fgSBa*#}vGY>c?LCb%KHYm$y;x^I~aEh6j!xdhz2H6hF5Y4O!zsl06-pD54p zJB?17rHU2xhwJu<(;Gh2{jTz^k5}F_9>abi+;QB(ZV%lQ^)%8ateETUyZ}0(_tm^@ zrj#3s+!`GE%)>X3)h@|l?s0bfF2_K^S7W=pvt&l8XWLP%)O}*mK%~t7vYk}M>yyD~gG^b6;X!?~e5jl}YAm9mx@Ln3Rk$Az%qr1#MmSzJMAnuwB@^(#dOL}#Q+yG!*D z&dMg3GUyH}+ePjTpA%@!``ddH4YnD9cAMu7o@nnBekd20g;ZS<6*isft{7T`DDg_0 zdoGuJ=5Vt(RNv5mJs}(XZn&K!8_}=B`&BENSJaTpVgy2=O17}QVDJvP&ZjYOOQ=39 zC-e}X%a9ZA8>L-=b-N1oL*wsoS|n;=)Ih6RxtZ;VZgdRPD)2D*LOw8Mo8> za70_=FRx&j+@vXq7QkKZ4tFBBqg=Vibsq!^^W0h#&L5JWCA8a>2!G^kt{dxcJs~P$ zClXTOC^&^)hkJnq*s{%CtQy%XfNfQfY}LCt%pgS%J39v|G&m zkJ#6}3gnSawmoF19Qux&gBnbWjeSTQwh}6WTA(nD1F8dAiNj&CxemDvEyipDT!e%v zLDs{w(FxcYm}nS_PJzY(HRM!;fgD3eFxN3zCw0#p6E5$0hYpF0EbJ51Xv0# z5bA^#=0ldb$Qbk@@)rIFJE!+WS&dbn+@D#WL%z`U0tAXNh z5bgtNzmu5TpuL&s7YvL!icUfxWHC$yHN;KOYEW_77m&xzSa)n1N{9X6LSz!=93UJ^ zFussA^c|g!AaFUd2xv;v(OZ}$&}Z}pEQdd$voR|Gt(%LThhK>O3lUoa04Y=f60j9{ z4A&uR05jPKSf~-`S-SxlEXVA`o(0o`f|8Vx9B`u`NBfDV-)`S3ls5BY|<3P{Ly zbOW*&5h0t=x9Ah(G~$B^hwxA#P$SwR14uOH872VKQ*1*P1LCp}@Qh5rsdzvG8UpxU zC9v`V#1-9$IRlimd%;-{WDrRPo_vdHkV@nbpjR8f@vXpHOM&+G9wr3kBfF3wpi3P= z{zJwC;3J^6j@*a5xo--or0Eu(} zyfzU~z(BMH`G`o6V00$n%{9mklna>D|7aT(%|-M`IO>lgfU<=HiirZn?I1D(rJ!K- z0#HUrbO?C`m{%OImk(e}4j?jP(TO0BcRHX!C4e?j0iRL0SIL0!E(I#$ zdFWK2Q}zMcStjt46?zWzKNaX?lh9oB1F8Uu<@;a^-UI*j0Iy&Hs~-Tbas=aM1YHHR z&Z$5lOafZq34q+W03NpwSOo(V(sq~>(5DPwonN3w3s5rfcrjpGN5QL;K|kU!bYRJL zz)hJzrHh~jz)V+zQI`k!>EQoUJ72)8+%Ok`WuwtNpqs7))H?^ztr!2_Z#>|1GtnYo z&p6Z;SlAO#SXaQvZGj(W0ZYY!_Ztmf$ph^<2iTba&_DuaAL<0u%VB`p`T~|{16oH1 z$NT|QP7Hj4pl^UJ6~H2UKr6(6%8~%rQ-Gf0ft^PH1GK|5gJazR&5Q*k77JQF1+745 z0F5{UoNqs%ey)JajtA6kGEjVb0ZlRs5KB*Bb1%@!(crfangIHl0e<^}RyzaQngX=t z^?)1q1A2;TVA0uk~*@(5N6z&nZ2>EPJ0;5avw0JPdnG#hB8b)X$CfM*f`k)DRO z0B`?~+_s^|&|#z&oaY%(Rr3M)Tn6;qI1qQPfYhD_uj>H1Y8Y^M7?9HCAPQqaD}*3B zdKT#CFi@KN03tpec+(SD2m1fEF9GDZ2oQZE@XaF7pD}1T(v0YUr-MNZ{tax1g0bF< zlz^C=0^(s3u(<#b;e%l8WdiGGfb%$_b3iL+gGV;Nhqj>I?wE8;61YCu1L{l1TtH`_ zXMyLUF&SVLKnu>&hPVNeI|(xd(C%bRBVhP*fhxQdJeLPrQx8^129XgEuVc|jj1ohH zYCvBnpf`|3U@V)!s7^&U01EmUSR?`v(7(W00)Qo#fQXq3)_X31v*(~}K-G;P^8UxO zKY_k(MphtpAWn)fTFhNw`JZ5I;VgOs-39W@k3~eVH5d&)P#(xUuR-TwRzf$ib8!y1 z570ey2i$7LoA;SKO_NO;^LDfiw}^0&_<>-tio(l*(wJi0r<*hQddPd^3i8&fo}A)v z-u?@Tj6G%K4%VnMHL#9{86t0Sn&wnacETLj@ze!<#-U@Fv3B#FNsbz8q2W?*v@%Uw zg&Cm+y9u3cK)LGi9n*W2NQP6D`wK^rMcK`4d#t@^^T^ZamW<2qc4?{BT(WEP`RPW1 zzqBrE&eC6Toydu#9@99>*gd~(lYQ~-C-o9ZRqJxA>6}0>gUO+(Sb?$r!eTLw5AJB# z-|6o#$?pVxM6+2kw*MDBhF?oQpm1oIJM!CSH@V(Hud~CA;va2)+1vU* zbA@}vHRSGWT5*U!qqS#dZDn^8Ejl#SePQ3UnhZsi{m$U$Y;?p)?9$+BS<99Nq&w7i z*3{_b2d$5CgZX_n`5kv_XUf;$$McqjmNPC6s)dC$b5-{!y@3Zp23%L`9yRLf$ek(p z1n*k`BA+PY7FDF!zg8(fVk4ca`HTrn_3w56X0<@$ z-9!-17A=!I4!$CO^js6b4YuLVpv}Ym(VALy;@!2`qA7BpeitawG0NNB?`J?Dztn4@ zv)p>r$ip5&^GlJjHeP(Ic~4Kbelk9na?Sk*Tg&^-YX#Zn54s#Bn^2zaZO?rLzUh49 zj)psu)AA(clYv-M0@h$N;9Sp4<=*j&^*g}zX1#VdIA)M`VZ4m#gEG~e_J5i?q)O>| z*-qIRSz1d_=aPP`=`!{QF~%c~(rjsHQ>J`E=V|q4y+6vdmXW#CZL}>yrRKd14WG^<&rc-Y;jNjHRJ@`)EHuUvhZ zz3d`>VBmzHCjt395_>Q6Im5#VYqJBrHvG3bvulU^TI1I`VU?n?uO_;2aht2UN&g<* zO&p;1yK{Yi25b&y2c_~Sc~7L(lCz;i!-n3GmNO!Gl}&YoD8DJU+kSW)-obeTYi!WL z@av&t_{%(|JB(n0v_37ntG|@3u3pfxbHD>X%6W~?fBx3NkNrrVV$$3pW%J6axn@!s~#h1M;F-gb4YVUo~ zdm?Q;S`9h%4~lexV@e`rd?+a{$9D%NplN6MH(`oyvZrIbUtFK-=G|wVnC2BX z7*%2K+Ens;j9}Q*8T@E8)<1GcS!7+nR&VmnO{lxm#g1+u_ zpHm4bUaz}W6%9x(Iu<3a4NtM0DBn?b%oq=5i6knoF)*LeHo<*f!l18}dRbm=>2&yh zI3uaiWqI@0KP=&AVo6M0VzzT$^T|IgRXk{K#OSm=>=E^>;@O3)?hHm?qGQ})YM}gG zuD0lhD%5>TVp_s##_ZnPr7eGUipt@g{0%AFV}t1NYE9XPUt23p+T--H$iT#XLGK+N zs-M-KDohus+ZrJYbA9CdgprVSE-{92&8N#rg*MfjyZP9E7=oZ9@mpiCzEkXu7`W}E z+H)lv1@_X#>h+Mb%XFSB(l*W`{5U(J=6ugQlCu2vfr^M}7%qckV3bJ*vo zvk_VT3p})TfjFw^f(C7k7FSgwg8G_%DXl9^JKNkzJWVU`x$3VBO^&b%TOQ!gZln*| z4debY(X^z_)zYlm1r?qZ+p4zBJZ9_m^=xD1XW{G{A)yU)({TOW8M=w9%Q;35CF z9Fix<^JD9YkF)I6ee2uUiI-oM@Wjq_ziKAb-V-irnAbGewzt=AxDtMhkFx1?NTXfx zuwt#_8vULI76laszVL%NJH5<|43`Fq#ij~-$jBckQK6mgZFgHDWVsEqLJsTXv$gu)9yY&lHVowfRZycJ=q1@AHZChBwVWAdnigJSZ^eZoo(0OrJT72Fe%W zGAP>=tlOa8)m0|%lyr-()zB)(2r31es#ezRYlv!LcFP7{TlQMPWUgZlV+HGt|K_mV zC}K=uR7W^5Sm#T1uXHe4MZwc`Ce?Ap?8eD8o#h^756eGQtrbs_t9zr2J$R`-nbE+B z3>}WHjMj#B@(z1mb1bz+N51s*%ha{jf`Q^Ar7vq&$yce*n-OxEXG+krnDqGa$P#{^ z+XuULWOd(@#t)^od9{UlVL;Dq_@cvdpVMKnajua>Uo}+%YnAgvi;4~xtg8K?ti#N9 z4GL;be3q#4UrR|CZjr4momOzR#!r<<7|+@e{V?%%sH2O+Ah+&EZc*VI*LEc6Ml}kTNe1|Zth;i zKBxSI4Wn5+tl_rc@Q(wvLAb3EiRpGB2auChQ@&eOFNXZW>7y_Bml(pzynnuys;CbW zzbAD%e`@~mGq`k=F)Ji=Y+BIa;r&Ib-@CiKynRQ*(UbA_tLFVU)6inCj;oHFZLh9Z z{y0(-Z{-)cEOCIoxg)yhY0W>DI{t;|M30|+RsvJ?D#HbiQ|vE}-N>ujmWo+@8yOd) z(u3y_9h&Er2_;P2CjV8@dS=8BrFw5^ecMC(f5M`pzS9zVTuOySPur(c4UzqE<(~Q4 zM1ez|ZR2(DTSMb47(Vr!GznbR~8UqiHT9v@{ z78>7+*Z&dxIniJ+)4jgMtWS9u*5^1;drL^l`|EdC^-j$vy9Sjs78$o;y=bWJa@}>IM(ifBYqoD2>Q2zS*R3`$ z#7-vNaro&xLbvzy@_EBK%=_dQ7GMro6__6Q**}^0kk#%P%s`!n?DzyX%uHj@P^)@! zPie;&`9^7v_;cOa8kgz`Ra2|hSLgc(_XR>b& ze?@>e=uRj(qCCPqJS{lXPsYB%3}DQ3dSH72UuX`}b!&vG#IF2SlXR{4N{vHhU%9Sq zb@_)1Y0ZcFm==%jZQ6}S12ooVf+N+lF-7VB@1fj$m`W!rV}_<@;66@M+EO&*q<0??CA(`z!6p!-OXMF z-5O*nh_&Qme*X2MpH)Ys30;>5s^PcfIc_3ed)SHS*^&GJtmj?3^XSFFw$27ASE#DW zu8J26+7}q|$aY@jkclzlqIL%S<+;T6i_xa1rs=t;r-mikD$mzuSf#tA1uTka2sLs3 zrCOQ(ZO;_q1+ju>VxOK+jLu~&|6l|&d>@-@w@A0R>59NucB1i!b`p7*T@k)C^topQ zu31SD?GvmMk^8!cT-JrK*C5~Od+Dv}kfY$RC{ zeY|78MUP>o8lTqREZSP#qxEsy9dkP&lhbMr5<3=p)!xv7Ejr=%g0g|CxTo}W$@lhQ z`~L8SN%Q>fV+8dfc^3q2{WXj)5ii41T}bLB)x@eBJ-*a`f>(rpVPt5pR5D6HHes9N z!Bw$m*!HG5VvXQL+bx^&z?3L56CQG|c~mk@UQEgjiA+r7M`M1-u2g=OoYu!XHFy=$ zu3`N8ZnbwR)dmt#?7*-sh7v}~hPDplEJL^l#2!Kw?jA~jKUk7c0d6MYJmERM4Bct+ zH2i0{ZZ5&};kni_+(VRNQJ6fzeo0@gs&lO7Vy&UM(|pbv>)b~_9x7awx~^APmk5%U#Lj7ZS-rT;%? zJ%MfnGii4)A6^Nl$r8XGz9Kn5Yd8T=pYdos+KM~}OsoRwM-~93P9~V)JA%F2eqjEc z4RmyAU~XIh9<>0D^a5ybmSOUN4sI9z zKpn(D1*idfGYViZUO?#-0yIS3;J9?48{&bZO2Mrf5FaA&M+RmgP&l0fx2Iqh-4C8y z0Bn01kO2aCpDBPcMFB=&1vrEg_&Eq#ydLpKd;lk$gG>Xw4~Hn>6l5dV9ZyA$!|gB` zsemhBD=U%Y@!LG~brku~6K>yZ%f{Ci|3paGyL zBbcqb02&wdKk^G`#pM4dE!N=gLx4rjM!$ppX+VFs08YaIJ!Ak10R!D838?BE0Ap|j zqz8xj0%A}D^nlsu9*hkHLE+FaCI{@bzJ+PQP@rp&2-*hy1s#R1K;NMfNCJI;^w4~4 zCj?>Bu=(J>ozQRSGn5Hwp}(-c*cd=|%b+ii04j!OEaNOo zEUPUSEr%>KEJ>C)i!*#2u;+QO3p^FB0ov9yWIvn>#~>JFGN9g`K-on?tx+}LWI7NZ zufRw!0~Ycg5H1*~373Iddj|jq$^#6HjN(9!0|H=_nFtYh!5R3h2i^qg`y~P&9EC^2 z5pXi>3@5>^a5yZ3AA#z8$)MHaKwUpyz=LLj(P01_v;>f08hAGZXbN$F{{#W9uR+cO zI;2GhfpX0V#=8~ZYuhpPm|hGH>H<9HCR7P*rGp+oH^KcLGzuGxeF(jQUI07SKtiYs zDua5VOl&Du3U)j{#O7cdu+}&QwgfA{#$r35L)e>GDwd3Gg>cwU*bwY{Xb>s{wx0++ z#h@4+rWq3qIY1OB0tyBkoB)C3zYq;vMQk8{s0z@cR1oFG7&~yi`JcW{4`R9x+`9p_ z>jGI7;=%ak1KK?v*k(MqHu$3dA`1XZ2G=t%A_rj%i0Oae{qQ2-t&NtWz(?=RiDn=3 zWOIXQuIYg3nCXHk%d`jl+-cfqnq!(}It3m-GMzKsH8IWe%uCJN&8N+0%zMmo^LEQR z3&}DGTrJPSr{Hby61WPEK!QPp%meW;18^xS7#*{K&6&XJYrr^~41C}Xyx@qi5eg!O zh42-)0iFzAkpVndg3JL|nP|XwJpqZe0wa?H=&cptip`*=q7ID(RGtUMycf8FxnM4W zwTeox24Mj4`W?hJ4l4y~IzHGYXe+cC_#g_agR-F~K+(xz%9UC!kKVe@CWge@Z0fQ@r&_i z@mctl_+|JR_}TbJ_&fM(_;+{;{xhx-C&N|XjM$^tVQe$@9`+G9<9+NmtOENRc-t3S z2U(yt2nBIL!rs6#ux5ybRX~y0Bajp`9&(5JF?)f6lmX(L3y7lxe6DQ*>ef~00(2I* zDpVtX1OF{Vrh`v5Pr(0E0n0xPteb+ogcrb(Fdn{P39~G>^qI5FSAidsO`nXnjpfFc zkrN}2K|Y2)!zP1FU!yP9!{A;u{C#+|-d^vapQ$$w^Ym$Y47iDg!Def{yPmB-rRN#G z=^yJq8Kj0b!?ckFBeO^LjEpt@FitWaFs?EcnHHJ0nD(25MsL$&(_Ygy(?k#@`%Sw{ z3rsUj9_CLVgT;9BHuFdGYY;7G&0Nbp%Pz}!iwk@Jtb~mM*VVOP)rW(Sfu=VRs8L%m zOQC1bPiQ&T8Mh1k~fht zwn}mZ873>p2$^czOqPj0_=1#OH{zY6x6cX+e&JfmuZy(zUU#wHCH(2kqt_E2*_E~+zH{zb- z^dK_6VrkfeP$VP)+}D5}1)q@rTe;W;u7DemcW{>Fo_VerH5q`7@{GeHt|Nm6yrEV< z-f$LJeC;r8xJ!3K7owXnlsI%`=;Pp~!IgtagEt3z2i6bV8+fUmr7h56w8i~cZAkx+ z#;Ebth}Cj+o0_R{*RV8*`lY&A4XYzHI?cuY{n{M^rw7^w&JETMULRVclj<%GTj`zk z6#YZ}&i{RH8hL8WHl_k!jIp?btE~ujLWaN^9Rxn_+`u=cT5yfu2(DAlpmW$OxUaZL z_!O&~R?Drp))a!t+CXq2S&{yba%?7%&)e<*S=l}8S2@rq{S;eAcgFpH*JHn|j5)`F&f| zU)6tWPW4aLt{K=eXg73P*ExJvPa4@~oNW4RP5>*g_LycU9`_AD&RT6fj<}7qjik4k zZ!58NwU4sD3aX(#rL3n8P-7kUIcg50TIE_E)GXn5LnTDlLtg`Tbu&1FI%pBwqJYw!NIvMX8_Ur41eRXw%wF9YITK@`7bKghR+@55m zZ})|+Ii1bzAKEGu?-hNmW_hEWD1Y42)iTgBU%p%J)f%aYRfrXd?RPpqI zRadWEovS(2KX2gSAbseg&QbrLK6Qj)YBx8)#h_|!Jd}?0$K~Mvwc2EzOq@bn|HlY0OelieW=tsd32vL!X~JI8Z;+S&hh44x zEl`o(otNQ{fNzC&b!8a|ipr4A>lGLVaKFUTf)i{?bPW|T6D+@jn!yZ5@s zcx?4p?vd?IcK_-2-EFJeNybZ%tMU`A#N~$bE5|z&sr?^YKbzMCy47ExE^-ReVJ7ng-2QhV-bCPQ(qt#vHdCM!@Cy8al zzQT$0m2;N*-ttv(A~*}!9X@)Hm1UOucDl~xKgWv>%k0M6*b<`fQ=nF4gXNOR)%acS zG5lg^*1)I!26bF7t|y@TK&PbLru}i-V?}Rkc&l9Q-MUU50)9?WXxmELB^|EaGnGE7 zGiocX$KVZJiJo9AG;OeyBdah@SR)=o&=S3Duh?Ip{N-c7{=_1y3Nm~M3Oa@t1W+lP~P5|3MZ;Z2wt%heIT;mm>G>a(h?-RT{| z*6}S%nwZkU#_ooR4Ko_-8y+;cNo=KWW&D;mtw-8tca7`$+B>3NJaA-4smmBSWAd?l zK#yaCtbP!ekWbk6QuR*0w7qnW+Y9#{z*6gdd{`MQ5i6PflJ(f53UjC7v;s;UCw|H%ln$=829;%d9)W!`>A2}DCuu2EOgT%HhdYvYVY()yD^=; z3U~RpCZ>!oEpL3@h>_qW8fk6Q@#ZJ;F>TvB;=3+)<5Z=6%>K0l%Z9S_j;0G119}rz zV!g*^qTOYO!;S$iUtFbbRi2N$VtwARCbL*RdERwiqnQq#LGDZGIW#gY)A_h#DW%4K zmF;2Te0&b(xMl4~-VmYxQZJ|b?>0wywk%9CwgFrJyuPqO-ndAzM{-@Zzd57@*IJ_( zXs_z7?7i53Tvuq~U{2s?+Z>_na9QL=_Ik;B=!@`}V5Q8_Z#Dm49-jwsk8)tvGN0>S zuRS&~Celn)lkFhE59fxG%@_2>ffjYB>TXwEdxj!ep4Z~v@}zlp%WOF+pQo7K{;T6j z=Zx+`p9eUFo6juFON;Z+$MYx>z?@Y*vLg z+pER%Ajo+9hXK(CU7k24Q_^i85|`k=pl?lix=Z~URbl7N)>%!zCDevXA_oyec%trs z@V(Gmv`jp{KC`h>`l{KcZDrT?-VyBy{VX^Lud@+SueyHs81_!)%-}ito%PfEP4wU3 zpXfiuPr?h~?qMTd!5%)YUDRsZ=hm?pqcKKTs0r?A?ijC_)l%P-Ec+&nm(@sL%h=77 zmNq$F!E5{8F|&KODo#TmoTYzlDnv{1WRk_s-YL;F%{|sDnH9i^_Z{WS^fhs~oL%fa zEE^x9_d@1H50d*X`aYLi)K#|I2{%AirXa(Xft7tHyG3p6mi5xV8W+^R6paym70wps z3eSjq#LeQIhAWbOSwicFj{ka6v>AqKq{aGxJ^Qk@9-{wSk;qYRI#Ynvfg8 zMS&mv$MU?{U7pKaovE8`vayNgG~GD$`fj)_PX4gTD9x65Npcz$jjJVRB_E~R!S*p` z`{k}rs^{A7;a}#x*hFH99meUr>jY3i#>acBkILr(tB$pa6~?OZx$7O`Rp(jimQS1L zq_TfOI)~?>VMfBxef4E!Vf(1ojAo!^V`;!9Rocx`k(7~CO6Do>$|qY)bp>GSe#G{O8DTj=nS82`2cw#DmcT*& z9p0dM){SYaZC)rT7f%*mtNB&EwWhK5nwTd;6*GIr44gJC#(I##C^arG-G-Q1tQOyT zp1_Y3u+IOAUjw(E^@PczXHdu3{KO*WO}as~eUC@SMnzrAO_`naTSJfdsklLOT?{qO zlYuhjo#m>e0p^GRFnW#cRHq~K3Q%&?j>G59=dbtA4tN#V8dw*w-Y?5{yZ1`QXzEf@ z3FgfRd9b@TvJ0>1Z7OWc7FJeYtXNpi5^S#wsC`#|r+HTwS-Z_R3mXa6+IPRiwXZTFwZVJ!^9S?B|l?1N~6!L6X8{OQgJW?bi8~JD8k!n|GsA55r zxN%rKSGcq;wl2T!o~Wro+?3u<>peQO+WZ06MV{=4rBC%t_vz%k!OO|@rQKcpHH%XBNd2_CLeU}9)?3$|uT+#jDhn(>R*_QEQcrJ9Q~3_hL<5M+ zD7#&wm>P~}KxJrNWMB00Xjb@0Kp~sS5Zk}Nlg#S}m-TolPDuoHqpKnWE#<2#$#st; zGurTdd-NyJyTnPnT_LF8Mz3ilA?$6cfgy zaU<^s4))&ZeAg^AK*UZMV~C_dK63yqiI3Vb>yt zBT~Z71n%I>aPy&jv5K;s8k8twTL&7h)?TRiQ~I_PTj3>)Xm0LaKXeg!Nxbc7c6ah6 z2SE|bqEAPSgr@n`Fqb%eAiT3|9&}b>TB<}ba! zdrZLN(ADAOFmljlZl>o6r{AP&C`SKXJ-u^A(``|Gbwnkp;!b63U5LcHP1rYJxC2eM z5jsY>J@F3aZ4U|#I~3L*GCwemdzpF3We<5N^k`)9!2O;vibly+VQf{nV3feKDne)@ zYv_2QNgTmrACQI4Zp??ghLDUXTx?5>Pb4dd$H`*^+poa3>!q}--j&69= zZr0vKXtpkl4o*z)x=7!c`BATe4|DS9k8D?A=IOGPnavM{MHTDIkTP=>y>VYVr+?6N z%DR>^-)(^XC$K2|dt`2SMc@gx2jiu^3P&?`YtlNiWPD*@)v}8C%44-BC8G99O|?;v zYqj;D-SOV)PY;_Kxj8Z`w3#2}_0V|%iGnO2+N7*(dSBNq$STE^@vBxf+-|GV_*$-# z*fbJL71$p+Hui2bA#{MV#q}2nYuTmlXo;&+mcB2fmh7zlM>eD9#7G5^#PH!;B53g| z<7&c__$S;R+1QvOdV(Yw6*Yx^g~l>Rac29!gF)EQj^3<$AvUod@v^9GfjwSpsLvt6 zfV2&K;THrKhZcucAob}Tu7j=ULpwkB$Go#)p)uT;Rbk6{O!pxB(GX3S()FiNS^2ai zx7bZ^Q8=$9Of{m<#@9L~dlv+lB0k3yMXw91WB!Q~2r7Mcd%2}dS ztu`8W3(w{reGKn)#Mbx~@rKBo0a!1P;~K3}FKRwnU0L)vuch!&wVRxxb%EwN&Sk5^ z79?~f)xX&m z(ZZFb%%bFS|N0x9vf(Uih~CGqiYkjg9L)?k>+zS}XZUsBJXu3UexX$nQh|}`mE4h? zgh1EL+*je^*f}wFAs1MyomEzGh5+RqNm1q4lC33QDt9$1I$!8U;}1Ju;miszh<}&h z5*6d8a;qh?Efu}lGKZ?WMOzCy%cnIcyXF|=gdV!w?`KR>GA*GdWQzAChY;jrZ?J@2 z{vlsnxUiZlKQS=D>J?*J;OIDJl1EIa-)UMEbh0m`akyk`?nr^Q)~)M?CCSOii;N*A zwZu&HyXLI7Z0OuyJEI6LI8yne<@wM>(rspW$Z+iD7;@lfw^A$qz=9T4wY=0{5L$2U z8a5W%&Sav2e@ESqyzJlWR!SNknV`JgxU}+1`GZ=d<*FtQk=c2A_WI2Tn-XU7JK@=3 z--G_qW-GL!e=EOLmWrxcC-v__^6c6@nEw3mqv0DH+OZF4haL zbPZT`I9vJENB>S3iYEI#bG`uI>~gFNDSVqZN?_ZZF%)jw&AAryF1ahVhriRYe&m9j zB*@G)6l@Wrn(riU*2$>Qj z(!F__ibb96&`i%a5vfUwBU?SRsJeZBrDMT{vcFq~O)p(tLmnoqj75Dt$*VO7M2teD zV41LAHP-51uY}0R1i$d<%$3$N)z`(-i?(WigiMt$|bAmf{G#zr>SE4MldOroa+U5yEkC}$;*ziO8>ic4!tsP(fn!z2^CHA)_{f$wDhTl=Rz zt%O_3k&M;C5Si<<)at*kGgXnrFKRq` z!1#cH<~XJ2e#X$|7m;tOmArwVi|C<5;KYeVh!N zyM3zK!-Z;rvHc&&ow+ZvJ+6RXO``T(t`ZbXt(Nzmu#XPHC)S2Ppe-0`6xJ4;70mC> zCuaoSPjCxYI_vt+S5L}ItlX`JoxX?LCRGG&w4K}KSvE0GSXZjkG89pw#H;`exwz|V z*^ykwn!Wurr=wwelFP#{xLg{P)K%t@OG6X~u_xJ$aUls5{x;I1ZnwIb8*bjQ?%`Rheo()McKsGT1>b1)2O(pzr1ST`%TIc@3Oj9 zTPq=#8@nXT#U;F-RP(;5QnXIL&?7dwI$G$FH7pmtF8*6|*)X0N5XA_$aviJ9tvOn{ zPNqYvIhL69A&VTGm97<}(o-!rt-kxsiklzoZ@;wbY1x!wLDM%V%U2m&9bQD6F(9iU z6(m)E=r44!ji^oF1T+v`I?k3I$saG=JM85CD*A3xN5D?f=#DX^0lCq&grQ>E;_$~w z@-Vj3Y>jvI;2&JswC<;NXM>+7*F+b%Iq7*Kx4eXsjU7JZACt@*m@ zd4p!gwCEiP`+ZlznEH)*#)2PRrsxZcHaiqi%}V| zo4rmOnuS-3e%Gzm{q4RjS`ee~Ts1sYt19@Xc9iax`;VwSF?QZ(jJw4j3cpo7)R1Uv zA_C(kaMF>FjopP8D{R%Vu7v2baa+Ci=wwx{1wM_BvB?3~5@z{-#IWk;6#N#3BG>$0 zC(QQChNlS`g$l_xLTBjW_$&`s)tVyPvTZ{-TqKFjzp863`JR zkjWo!Y^ri7p4_2w9uu7q`oLP+;8aqsaCE*Fy(h>Q&8o>R#c1(dLc&4!H|-0HZc9&5 z@Uf{u*2bBF$5k+TG^{k@7_Oo!r}UPgGMFDX%66=9R#AUHy;6pLl>d^%W6T-Kj3URHe-Qx?koMA-wqGvL)rzpY!@?47x;|{B=Ba8gSgnbS zO4#azlQsT<8~)heh+P{+COoeFl!xnT^E#A7;BqyGiju33;d~<)x`#i zV$P8_)(MKQ>N7)G(YG)^OK}xGq&JD>{;clRxkGJ}e6jK2BvvJ(I@)eU+-~>t(!8>X zuw6tv_iz`fq;ha>=*hrGy(c#ps<6h@Y2{ zu*#THURl?R!o|uV?!p)yKEM2Mm5uG#1TJ?`mnhG+MaF!cvcla~>Xvt1$>0?w{GxaZ zy^5-a(?jk@W#OMy=9KQX+=#4=euvSOvP$Noj5x=rX(QQ1LA4!ro$aKpxL-(B9{S$uiAM~W>Sn} zvN#KR?$`I)cm<>()9RiWWd0GGR&@Y>3Ga43A(6zTK<=MZ9oHm zmC{ELV|pCa;?drATDS>M3z<)qia5y5#a`E;bH{RnlXeVytVmS?|7w#oKHPt zq3Gof??(O|Fgd-4O?}H8c4OH?1ah&Npx@@EY9)$`1IbVL$Pp zuH3|-%rS(UX4)(p8jJoPM`r=nM%Fdpk+>U42m}j(1gi`6t+(5yuG{U_W$W(l-MYKG zyLam@NC+03gb;U~$>hJ^|MY30VKQ^>xp!`cnS0(hm6PSlifmvHb3Viu@UD2j;sk4m zLmz%D>h0<8jff@%HaNzLtwdVWKHg~N!zLr?3-=F;F8WM*-Wqa@k_U+N#y$Myyh6h* ztcCOxD9E}V*EagOHkrRBrnuolWJX+rmF;>IcR0M>%obfoUo`$=Rj`j61>|c2r|Cna zh`-y85T6B6rwGsEo^wpYE7?BhdZ?Zu@vbFzfK0Bo%*D`(;3nGjFdmMLwh9lVyazRW za`b#a672zxi+m*4MegIBpbOZ4pbD%SJW(BjsbCRceaVp7AwQ_TgQF0Mfs4S}j<=x! z@Dj`s`9=B?84oa2;yk4G>GpDOb+j04fp0k|FBy)lZnpE#a;tUr=0Lw$s*Fz z;8hwU{Mmn*J|nQ7Xkz^BzXj3Z=YBWIPCSluK)Pb9kQAsNzC5}NSHL@oSNIjki!FqD zA&(=kiSxK1`Y*zTuMkhMp^^Q>V6fU~X>YQY+ldSnpN z9qNF7kIula*m4?9ucYQ&JaD&6Gq!psonQ`rKU+E! zJ(ro&d;tGa!`!d#${_ti7OUBI)`gm#KOR+oqJ-6?99S^HGQZ$~_Hp1|Vo{5s@ojxw zwORRnoc&}!a~z89k<_xvUq{Uogg*5RWgK+AEMBwTGC^FPJw~1z9$iY;jtEr8C1z%b zG)=cP!}My_NLA;g-$K*1Z_3}p9aX=n7Dn5aT_`Qa*QEATo+DJ{A9TA&y7ZGt^uTv5 zQA^>_GOMKXEOU$6n>n$kGH!G3G<+{+_=-}-s`E%pU9XaEk;Q4f(`yL3c1bxIS(cux zT1Tue9b8s{wMaXeah$}f%GCVrzLRh~vq8|?+^Hz5eh)KFyyPhoU|s0vobr& z7lvAuO)ieO*U7GCGZTt^TZ&I;wt4o%56?-7Kka+1RTua4+>$-au8sf8+f18N(k^%* zX>7JWCTNxvKvf3PVb!#Z=QL`~qWpdqM0zLZp!}obQsJ+vF;rQWIxPXeQ!=3>C0drA zsDhB4<#)?oMDM2^Rj$OomGr4vN9~=lN#5XS($q9gj@h535{4TXMGW_?#JG%y6r@6~ zJro(4J}GS>enHEwC?zv92c@`tkBbKCD741R>58kt!s16|`yfvGdKHaaTV>YlaumsC zXLpc%wCgn|D!oXV@}*kIx~{htozcHzCa4FeohBo-Ng74dCl0PoQo^KLHGa(?(?D)} z^>T$1IaxEd*kM}AZKsY`42CCHR}^PhX9%9F+a(>r-&Z^HLev5Ht$ezsH}exEW}ok(?8eb8?3uTOHQT2jwf9VuxS{-!vXE`qDdR28RCvU*qY z7;j_I^SWZ*q3nUunZ_9fmDc_8Vc9Ow)) zekL_1KIN-tSV3L6l1j_YRTV~Jv^ko^AxA3OtT&fc|KisegMq&whn}j(_LRIX`rFeW z&u^}cKjWHJI8r+{yil>a`Ax|ttFWNFG#bHEyEpq7v%_+)V0(E3k)PT<=b}(->X@(4 zm7*=w{j)LVyxPIPcI$`mD|0l8;UTx?esL_8r)-yEwhJXP^JigiRffRaG`NiG z?jY-+WKmbwmgs&bu;kr|3qwcChc}F1y-y!0IcW`On_IU_)~hQyG5TXAo4oB45>yGa z2{m&|i9li!S2d0Hdu{)c%^oc7xcV%p`kPfW+}KuN%Q}_###gQRtCSl#oO(B_J9mts zbK$6ZGUJ5mTG|*&dF|(-UyYyHaT$!Xp`-=XvkNyG$-Ho;L-{WPRndxSOlm>%tZ}K8 z(2L4-nsL^dqV-v=lI`K8Wm`+e2eu~m%(C+njK>Op=q=n$Syf3317+Ih6+7URw6L-f z%B+}IT926&C(;85UU9Q*8Bv|uO+mxzOFmX{>EALIC9HQIF1%m=ogd7)CP7T0qAKfg z887n^>tT&cWA&+1USycW`vbwSGf~4y7 zoBSQNTJ3^LBR&I&PhW}lIDVE0s(MAfCv;a`7M!#fl)7tt5GQ#}+FaHrCym8IUqSysXy;wmSHe#ow?3*zTQ7&QZnPwMq#U5Zb=TLL)tx(ix&(=-|- zWwGk1bS<_WRFSr?JCEC9KdJ|&{$M3J-j{1jem9a)wY)~%Pm#-N?l@dNU3*-g3C)g+ zs*foz^X_|nRT1s=nvH&h-zv2#JzMq!YSuWYrfplnh!NIdZOMU9qly6`Yqa zMg2#74Dna(^Wt9ho8jD~$yuWl-$8Hco@i35Plxx!ax!M6ETpbANy~OtaGV!7+mtI* zt0kwfn{^)TyQ)fWGT)+nsoEj+MN;)Gv|Y-2TSc_3Nv|`CyC&Z8C8dOZdjH$Fr4S@}%79|0%3?unLD)7OLan;_RGA_=j8mC6%XHnWX+E$L&d@Jo@e2FuK z6I1z_>B?T>6R6d(yn2iFLfH%bb>9j4rTF2huIf2S9DYJ{n(au{q2exO0~*`->qrkp z|0wk8YVa&$DW%byqkmi4Q43d}vrfm?u!kj_R}ELKOEmDC!K3XZm5}C7ZCRbq-2fKj zcTI1ZIWg4}^AnNUD=P;VHu|wXdzM0u8`OB-VnbP6=cU z%6gu3jw_F(n%9>53fRRTYwKO}(DS0TslVmKWDiJD36~*PoY!j>Xny1qT7E;GI~K7D zW~LCCeX>i_Zp-d7o8b?w3#x6!YK^JP+ywE|HF6&WzY?r>d71pDk)AEw0nsV~Kjsro;)Ec=q4v znQ=#0A-vT3wd#@PbkUuPu}$5Aw`lF8w^D~>5^8$#EFqO-@QC!6OE(tvF8NwJ);=?` zlEX_X&(bw(ua1#l=HwB_t+kblH1S32OZU{pI%1-yxp@h7s^7D7Gu9{lF8oZk1aqyu zY8RI_FXoo&s_&Vk{sHJ?o~TnhyJFm7SB)!v$r&VmGwkHm40FL!AIDh z)I8F13spsn%U;#n?8`6A;AzR#+D8^m zAdegn1QIIJXJxrFf21iBRtkI3;v)}Ts~THWC6#U~wraoYO!@*l9+&|IX-@?6Y zv(mCPsvAk&B~LlqDa|8xcb=(NU97IQq_((CNuBP5ez~o!?=@~Ay<``NX^9l&5fvq4 zr)rJD9JgKAl8K^6!@XQCyDfwGtalF5|A3?NZp(!t8IE*OhnTTMMtz zcSp;;A(O23P1%m(>6(c4P~}X+Ec>{?GpHNmtFXQNcjccMWtmUasi~9W<3$gcacEUo z*@Uc*#d$8qV{k003_Cw*Ig0+QbwSQF}Hf(cD3^Jkn)O}pNcwIt|v<_MR z93uOT`eDi}8C{S={}-AXOmfaL4yXZD>&3T1VDOPQ3|DUK#C=jv!dcpAp?k8(Y-9&6gGe^`6Ja(el% z(h;RA%T89zsM=q5&!~0G3($$Dq;w`gR*5aLwTV|1@v6R=53{ah88Zf^pG`g*CynXI z=8;n(>;1o+gDhta3u{hSl$G4kWEYJo8dZF^bWoK=f5DpNvqqkgTd)fFhb5=t-z00& zbn14Q-7;&{FO-84i=}7zOvXWEA=b;c-Z9OL>sQuv&|N6KsNJn?S=zm#bv2|vV|KaP zhM&VNXe-$>fOq$vEGF?z3Pt%ktuWn@p09k6VoUfQD-x|``zfcPxnYZUxkGLlZ)|E9 zTz9|vbR|jWEniWdSI*Sws$%Qf8s3%th@>NbALCz?8yV`?JNlRxG=6G8Ye!ZNF27MS2J~88QL*N>Hdw~5 zx>7&iw9s`flmZ{6HF7qHw#IIiuS}LGuc?k^9?O1|b1J)6=Bf1DlyL6p_gsY@vYU!Es^Oaj`lMEkB^Bw1Xo$y!4W!fT658)KaF4T_#%rDS?aMN&e-U$Vc&qXk#lLi!@oPGU!JhR1HtGymCms$q3)%W6ku zy)LB7t=wCwsM=GNTm4(j@w#UExsA6?-)(E$8~nS%H=`3tY4mFLRDoMuEbEd$P1=^0P6cm_7#_sEfD>0r!jxKPu%YNl>m zdEc@&r7cRzwY^I6N~f0}sO(T%tbb-IvFCf6!b4yRRmQr`FOC@#3(1!yDwF9cmXxcC zm5T9-P6|!Rh~%9K1LOKgD8j{@BlHyV1n4++*Y9zq*r%G?80`)0y63fhYjJQ7aYJp( zy5H;1G!XhtjhRhibB1-LeWL4wcWE#i_e4LSQtD&ID$Y56x0twCcKpPIZAo)dzA31w z=?X{6rj*pA6$umM^0?-)Z6uYV^MY%qq+OKw-s1j-5L85 zX%9^SYm?j4<*ebHYrKVmzeVfCghVL&8TUS(k+3J>X~O1&`SJyza2t_*mfnsTD!j=r z=6q&tXG$0yXk*A$BpF^x48rOI=e$GQ!yW%xWF~i`T%TRvvlg$as#H|Ub>}O3=%kfB zDz8^{ug$C}{Q!Jr3XUpdQ-{EYPngbcT*Smp@8`O7deL#&1sCm;5rN zcWUdjG3hz!*V4A6QI*9hn&hIy3-SGB4C&mMkAj}OOY9wtbXo~{BT^kD5qq!}!LJ~W z+m4Oa=H`E!w2d)_(gsn3x_(AopmriX30t3TT?){tkUn~dgRwt0?j?i;?d zf!(19=8p7$vXF!5J@RBg<9WuYX4%=*+zo<=@JdWG$-k0X>3mt0>_Obocx`-7`AhkE z`2u-!`Te-Nv42Xh0k?clfsFT-UB(DdXOcf4SAkz4h^-7Q38ed)xw|^{TQ{3*jV}G? z`s&)$n*6Hr%CkCKMVE>P=j? zj0(Vt{;!M#b_{nPpCw!=+9q~L7Rh$T7sdBaxS1H2bT3JgbSBZ47*32NrU4)Keffy^ zYjI~m&mECGj(H$_%FpNQVvS)urAo*x5ehUbQWw4w5(OT5cX<$3SI1Jj#VWH7wxpRS znz}W0Gp=o%V_2$x+3>gl(U&#kH&_}H^*z8U{%U=z#>&PY#?wvPOv^3ZZ2cYOE{kWe z|F6(p%o4c;%|<(s4^aCurZcO7L;fjms9?Bofd~~_#UrJ^V$aH6#a)ZPB+p4WC4U2i zPOu!2$I6?)n)l*Zt&}6(Af6(6BlyJo$oa;~W}KzoCVhYxM@Qn@L+1lQuhQMsS#AGl zJ!B?K8K(9|T4R61dVN>@c>M+a0=>6kik@fC>(dPmLtJBALaw$Fbk@S%CU&#{jdhyp7ohVhbTd<7(o>#-o;@)TX zVcljlqg|#LNv9AG;0N}=SBJ+1E&jvaIj(faY+I$}y=h&OylH7;is5g4{|0^i`udUe zd+S|w`ns+SuN$^B4Awu@%M4!{aU;i+Y?fOz);>0+y@Ru@Yrm(|cgSBIycoWK-HM!y zb^#TmF#zoIlQNU`h@Q(}vIurQ=R5Z(|Ab(G&?UMEYFu_mvLp$h>f@Yrv-FH~t#qJt zrSyU1u=unX2bD0pME3-`5=+6%WAvjBqQz4i$y!nr9g9vxY`{tK zkoXXJg1^FMg@vK1!NY-fzC7=GPrZxgPFbJZCOIxUAG;>IJ)TMa9sxrD56%vs$Lk_Xz#Z;f z*_xz;@9-)~>Scv2L~uHK&-z zn5UUWHYGNVFjh9Y8%H*FZ8SHg8vikNY1-SgyJ?2$fLUYlS{K-k+t)kixtMOZ=aKscPXY5Yju%OT5hOLq%m-ELiKooW4Koo;(? zQ`pDZAKD}KYWsYL%hB5z;~M1p;VN_A@SO3E^Bwgm{5Jx=;L^~xFoL&_m?BVA3k?Oe z&U4W9r2b^!wWeaU3VK)OSXLjFhIO3ti2IF)3ik59@_Pt-3dae1i4KYmi#Cb&iB^kF ziZ+PyghJtbVT{l(m?_8U;9JVr~~s9{XR`eAYykn5AU07)gw7jPdm0 zz_8t(%A(d#K-$Q2NQY4fEkSxBli?MB9Cs8{ezzxfr))<-Wg6Eu%zDc*+Oos4)^f_S&eF$n+%nP9&vMCPv&^spd6ea@ zm16tdcEq;Fw$k?9_Q0OuIO1sSoZ)1+{H~6kquyb@VSY(qZQymVU08}0U`Ox@{393_ zlb}=ZJ!A*c93_$XR5ze}IM=)-Ky3b6LG(>NVDEx5yYV|eZO%>{D> zR|F=(6X6A+R5(fakI*9=D$)pRg*Sv}h3AEP1=9p?1w27({$ySew>Nhj2j!%3p0kTs zy;&caLB=!20>*3l5;{SX(?S$2xq{qC4v-Q^+2~!wjr@kB0}oLwsMe1I)yld^YGf$h z13!pe1`NBD@aRy-;Qc_E|D*4T@0u^*jraESc-@EGTU|-6(XQ9d>yAeIefux_@AiBf z4k{-*+eo&)R*tosHQ74S`j?etZLlb;)mEAcK`FhCQ$jhIJ753!dl^ad=aQBe;K(8tTfM|Kj9I`9`qyn7b$@(rc9@Hpi-%Y z)P6J#tu1JmjULZnGQQ9e#(KtE#vj`Tz`xko^dn>y;yCb_j=O||yXC~(* zCyN{AL^x&K$-F*11J}qM!yC?1^ZN59^7``Pcp1DyU@pn!HgICW_ImbFHdx8Vy2(sp zE@Y?~L&5wqmAZ#=hrEfbAQz*jkd8)cn zV3)u$e;Z)@e(&AjUFSLB{^8DWA939Vqiw&l-WheAaC~+wbNqB9I=eXR_E(P4j_>w= z9cLYP9Dc`S$5uy{^OW#1uw_@uk)9_>Eu=DQW{aqb>&s;8YN#?$2KT~%De1rVzz}>*Iz{Nl~zzb%D+J=SLF}xu13NTNN#J%WlV4{jcx*$!cmUNALjPi`K zirRv@mOPhq3$>$}l!vrb`gpp9xt9~id&|AXR0g zv>$p96_ZX=W>JGw7K1@6M0dk^pyGTR;FLaqMv*p>a-uK7iP+P~Vf=`{ySvJL-9It7 z&p$43DfCZB5DEon`x)-rPN{caaCY!_U+=)J;GfbNe>SO6R#iON<%*6le!cUS~QnH~coEL2$S>~VSzZf16nGOYE5t>3?PrgX@l9FI= zWB}0~YL2#|4rGpEx28XZCD2sLLFQa)M`Bwr7qg;;v>0j^@<`eM<{R1~>TB{^zz;By zhcVAElW7UGQu;dD6yO&Ok^5rJ!*-O)NT*(fCPy})JvguUT-FxE9KMQwCOa9&>2lh1 z8k@$awxUGHBgoUBNf-lvOWcN+p}mns=s@Z$@=(BY0MAR|y67~(Kbea#=~LKp1{>0b z6QV1TRoGL{{lHA(4j{Pf0d&nA;MdxLe1y(~iv0oXQ*@DTgjpkj;6I)T!JiRU zXpn~!oP|E8Z9~olKD!FNC3q(CCfY0156hq^X&<8J0j+p!q+g^4-wkT!Xh5)ZF3KcY zMMshxP+52_w2jgZZ*MEM--9Z61aA`QL~yfzX7nV(O}~Pjbt1kn_5wQ@$%U^#9pL%s zGPD)?5{`-72|V*zA}birX+xuSq$P7JmB7<|uR;z78Hi zITGyC8dq=IIzKyoQs-o?veCB%U7L4uI zdcQ%-8UNCzpgc-H+AC69v>bhb?1@edz4jG{ilX-r1lbCAr=(F^5Y-_I;e`@#S@0dk zCEaA?ao#b-XkMf&T1P&CehviPJ40KE6VZO~2850-L8d_Sh#uIcVE@p_@O`3@x{1~v zIupG|wK5=5IGh3~1GhuMC=+fT(FWV$y~#@f+3hKoL+lPK0(V1eqiphDl(XcEq$21X z)Q@xm?g!RU3<=(GuLwLMJ!Gsxt3pToJ;K}JmE=BfYyT4aG5=fYQ1)Aj5PVv1LS8^B zWB`oe!T{=<66^r(Aa$XRKorDtWB}R{&kC*zw~tK4Q?SvI<;YXof8g3uN7CRT&}-Ww zTU?*rBO_CgEyzIlcJwqnfwYYj3v)xmeO3My0R}vodyw-O>5gY3MQkINNsjk@Hc@P? z@QKuP(oAf9B%K0L{(}+Hcak`w@hMw(H_vJ=tq2bV5_gvw;rp3cSXL3 zY2;0u{d5+Fx@Eq@#2ZpK^}6bm0^SCqj4|P*QFbH=dWvkM zR8x9GTKtaxn{BqQn!ZJtN*nJV;a!BR87jq^Omk%{Dk*rw1Lv?qB5(x1G9 z#DyAzW8BKXgy=hROZrV@6!e)AOF|+Qkd)yfd)&#UeV)Oj7wkEV8Ih0P0xUvb#-2p< zwjKA3MBkFzQD@OEqCR94`k0hQF-G~=cYl*JKKz=TK=|E1!&1%`{$zw3ND6d`tR>x~ zbcD5-0oubH&H^N!;A7Ga`U(7vZ?az+q(+w@{gK6R@8~R2DJ28`N}^Hr`LZoUXfA(F z%t~~T$K=U@VmNkQKSbnwW!FSDQy&nM0{x-S=qREDnvQDV+UU*j$IutNEzD+&rXGs0 zNeJ^GG~QPjdQaWT*p4rDys-Zl*i7!oX`nU_=LZi^DwzT7wyQO^6gh_8AWt9#&~M=JV5QJUu=eLQ zm`&GtAA6@_Wnd;dgKQ>-kYX95p-k)yavwb$VA(eWW;6GQjP5P}SyL6F83yf@&QS>sw+4?G3yRk(0`i!{jN@1Z-7g z5atN4!xj>BZl{>bu)))n*vaa_T!&A#I1Ph54P-v+J~Awzz?Lu;a8E(yzL>~6e6Md2 z=#xB3AL>S6qTE1UOj%CRV9Q)zu@mHK?2Pj-e<$)uRsyX%{?@xD^bJWPJF$FMc|gwk z5ED;*j~mGZ>fF=uElc@9n+>Z4ew2KxnbIr!y`XM^;mseJ}M?co_7O+8PnSyYZ9W z!VncnCx3#%K^So{R^hAt>9p>gyV(9FyYU|86=`A@Qm6WF`lj- zkXd05GEdZxdmy~o|B`Zn$DvI0jJ7CT@A0{`^$Zr#*E1RKKqsQQfDlbUn!L9>i^7K} zS=>aHnx0DP54EFk(MaGQY#-b%(ArYz{zggSkELx2K%NlR4$KF+kqq2{ zbz3mjSp`z!fg6= z3OBsXam_c2>|?g1N{IBJGI|GT0XQgfSOwpN<49fL{bV{z#9BsG1MXFCWVIi6)I-y0 zGlTIq*!M3fj(fP!6zgZlf?ylK zDiXp;q&WmDcnu{ODVWh3@m^xijIWlRVV{nS^qH`=`x*yY66_}FO;67Nr77I0DRBA*u|pWWS2wrm>jW@i^(J4O8|234U5sE zj7yZy*j1cD{Z6@pnIZ?Ndn4yvi@`M-7yU(fO^}EwQ7%pJ>F^DPlPJZt=#kKAaE;E7PG#=kz9Ua$h3_Zt}QnJvW$SV3ycunvM^ao2s8SEKrUl;_!Lxv7w`MY9;Br0^fb91@U-l zDmx!;W67@TWa=DRfKCT%K-W^|an_=G%!EFruA|(iwE!zkowTFGDbHoN26}HD;fkPs zLT7v9sbMAyS0GK!mX2XWgJ7QQCZnr2=xxXN!EX+o^|S-Dk>mE8eitFf+(W_&|TYH*A(h7X;;>IC&Mt=!G^QgrA#hqEO{Jv zF82^}7V1RihdP4K8=e1Rs5f!ke=XdSnnb+we~<1VW(1GI4T4YdIbwD6iOYyzWW(%F zSf?gaUA8j~sfNb+z6RftMv#)oWWfu;SVlg0dZ-U|Cto7%_9XlLn8Du4S`>9Mi=pA} zkzO10nm&d$9Fj&(p<>QDzKyI1ObSL28C2-8_)77$z8GH{#7ubuC_7bn5vHfa6YG8P zXplJzs&HD|Rg{5(2KrAt4qQ$7VFTkH>5J!~XDdlWmIW%pGD;rC^9E=MLK3wfdYwoP z-6NM#Aphk+Z+Ifs)#DGWqy6HoMn*U;n*F|ol-`V4(5O&9XdRy~eNAUXYskAv8eh;i z8n1W9IO_a^LT%t%WV!!=CzIl#Gte)zrQ(FxmDKkBbK$9!f2bLeUe=GbLybMd7UWKV z<_?lyichmx)R)3EKASX(6a&QuJ)vK&)8?;%o1w?{m7#JbkJ{B=;SQ&Mv&Oy_KsReaAabc@gSH%HTaC4UKN54?*tvXNIMLY5qq@Z~TXo z10QC8jy(4CMG9&Cy_@yZ-7q&LWt99obxCl0cmSinC>CurZ+7-W;81TfCD;s+dS2Vt zg*rnSY*7r8b&E4iyn@mWYoMQCzwxfDxuF*Y<{|S4w$o%0M$gBNRh$yOi1dZ*++s?| zp>&Om2>rY4hXb8L?E=lvZj>w09O?tcC1e!s4VMXjb%eZ9>T_nx@Z!eZrm65M!5Q{7 z{JLYLzmPpIrYqdpGb|z_+Xyjn75wg;9zIA^V@IKLbTyI<-Da&rm)MsE?sEFEVm;{% zYwYpV10o$~CNbRmiC8Z(#f-$(SbrnPjEUih>lOYN{@$AunFB8ftOzv614IpXyTpg~ zA$khBNmPj3Q4V}mYw33Hi-uE;SNu0fmx(5GmAyOPq?up+A&yBwDk zN2O+Pen?^%#>iyv274=ig?pEIN?1;Q=6zy2K^h?6F6n@|99_xl<mXNzUBNMC0V2VGA00m%VIf(VAIf5769?TjWJFR!9&EANS2I>glF0AP zEgI;K*Q9H_VAN&F4IB^-jNi;WhAv<>Mb~)J(RSp^o@D=aurtSVVjPK;D z_!U}@6tj9gm*@EvEM)Ctv2dL!T-~5Q>b-!C_1v-E!Jcv^@n^(zi;EG>;QVAL(N2^p zguL#Kej)VJb*H48nZz8;RD%2c*98lex04`YUDRyN^PZ%}MF!TNs4e%6h8%%|;kC>w z%!jmnqF3B0$Z6^#q;(()?~hz>V404QUo(0K+L`~rC^ALDYRY8K3{OiWPk^umzQ|k^-z@*}S|*~T97hMDuk4nr7g1mwYW|Oy zDCosfSQWZk&aTX9q`9_9p?$Kxq7LvKP7yB%x29I1UjGL)tKs^;T_?l2BB{y4Mo&~nuH()W0_New2h%o zyn(Q)bYQIxT%Z3{dbdF7?;{_Z_KaT>3sC~o30v%*m^AO#;_3Bmg;u4G{HGz_lur)I@>vSs z5VTnI3z?5yM?IlJd^`EB%W8+A`|b(W6_gXyUACxO7JsJIdwJWyvc~vOy6jop(7-A{ z$Jya~%esxq>$3C;<`(wsC=sJhBr%tQ(N^Xd-FXrQ4=eenfy8{lTLSeBBQeqT z{o3AS=GLyL{lprPsPi2CTwZVrZzekL`>PbeREpklqvDW+fixaR8?(wU_dldusW;@; z`md6_hPx#uw>n{z>I*yBTZ@I#KXyAI9$uNNtqO63PR~CdQC$~qA*rrO1x*p&ay0&FZpVZ-O&4k|F}5{JlUgfJm3`%Q zjs4izrgk2cOj_#BMFvM&dmb{EQG-EB{O*L!=7;7I`MK09pcXc4hvh@SO72&`n%=q` zSxLR)8O~Sr8zenD_GhXaUUCm*Bt}L!+{6e=rjrJ}tIn$?qT4M+#wqG!-Tou}UbT}l zx68E-n@G!lO#86fcPOC?tG}k^ZjO$b8mHJ4xX-)UeSLC?=R4(9KO7s$%6p!v091(!1)F*Y&_Sh#;Vr6=XSu|xD8C%s~xRw3P?oMB(~Yq>!zJ(jy8 z;XlJ*oeA!go}GCP?ND;9{FVSkNjaZ#J0@!ET3Ayf#b>~)OnVD71@-O& zDUVuS5mtrDxQfi)vMm2~S9)}VYfr-(@WkI`n-X4Weeb#@=7|^3jwD`7cOV**DS9BO zrEInHVg9-zhc7X%g~-=5Py5a7PkgOvP5mBfCZfg63w1*!>W>4dr=jg4@_h!Z`95WFYGcWge&? z`!9MuD8=*vy{`ht!yJDV@si$;+LgfZAIN_4dUO(mMV?0V(Vb8wFiYtGv3nu#mj#IC z(LsO?J_nKm8(t&y8}MSaguT#A;1KHrodia&NzsDnDPSC10)CJ7L{p(gKtVnS6+|}! zyWtt=92b3J}8w0RPrR=oRzc0zNo!umMZiUcez$0}tCeP&;P? zjN7Y#Kb{}$2mJu%wTHl^1@55%OV}hR4e9|n*slN)c?CHBQLqO;Ap-1UHHe)Dbp%gm zY9J208PKs?g8Ru*V4GVIHAPPVbK4fc&|Uz3ei&K+Smo`3L2NE)$6{bw+y4Jt=U36e zfE+#_FxVde7nqKC2?*!gf!nVU7{~O)N5I)_2Po5r0Tp~K;3uOXy15_+F~E1{0DSNH zfEYan7`}9Xgk2c@31Uoye1KeD1?*urh-1LRwHnaOb)YX+1OM7sVE5_`NaSX~UhM;7 z8w7~#HGo$h0c`0Q2#c2IUJ_IzR_50UY+z(cXam+zwg~4FmM>HBfV?6wuZy0q1-`v^shKc>S6KpBqkGj^+WI zSs7r(TL>k%qbCA{ZF%%Iafz4#cH= zss?ttmmvGK;9W5211Z?j6ZHZcMF7;xe%!B-d9Gq{xiBFDkLO`ttK5Q`D~ zp@3eF11*8Unc@d=694aJ3xlm10Fiz!aM*>3WRSZyfGi&-_JQ_TK|}^{PB6ez#AblC zuLOS;;M_|EE#?E$Bnw0i&Pw3Fdj#x-0zkP(z*we07GUv90+AZPabki0%@|DtZ8m{O zSm5Z6Cd0>I1|0NiylI36Fgg8_PL1R&>M2JSy2 z$lw1gc`%5V1{pxV3xO5S0ouz0(FZ}Soxlj;g0@&eBnI%)WRP1P$Q~Pvgf`&33xHSu zm(eiznGI~i1luNnvziN~KngGxSfGtEkZU>MeJ6oqH2(iF+@J^Lz&l_EXQLU6xe#cP z3!FnV5DOJ#BnKQF1FzdaU+w{f__knmg$8&XgCH{_!Tf=P#-AV}9(WZ4_C67q=ZZlzI2iS{;Hwy9rU=Al1FtASUZWraEy!d5yq^nNWd+B9 zK|J4pm$VA>9}4WLUV;YN5d!YQAzkQ^33_f{MUC%Rsx#z&5Z3%>ILbHDL&tCH@1g(u2Q7(1*ET z43z@fycv8xTm-g)mLQsgU|#qJoDiRg1ww5HUUDvG15RB0q4tG@Xi*XExVv~ zz%`Hw{SH1O-oj*L1H2siJ<20O_-DKm;e@_`8fpO%4sXU*MVq7P=;7!<{2r*io`{mr z(b2P5GwcG<42_Z&A)6!pz$!HqeoLvKImxA{$VyculRN0`Oz=r?ko>?32!uS z6n~cBH-VQogwqP#3+RfE3_T1S0Zh=1<_aU*WV0S}w72Wcd(9<|!=dBkXMA4V?8MT< zF^TsQHpr%NBw>wyv2KHrMIM<_pSeJ03sjU3&^9@$#ZlEpVY{Y`!jVR{_-6LAm;xhO zoMbbJ`RT2A<4xs7^D2t{LqxwRrX^-@oA6(bIMbNM;ReWPv=1ltaqmfw#>`_kFqU#B z3r?^|hzp}co)c@rt6VH|r>1)IUv`Gm>{#nO<;}o9gY^hUS$x6p*z1YA6=+&w`UizU znnjxt=x90Jz^kTL=qeT(GO*{Onv}`Xsz8g(?7>_HVb}!)5LrC$4pPZl@^j5f}fas>c*G-svT&*j`gMaM9ReXDg31T zxId)51OupYoEl8Srba};);6hrM%`&WZvNte{HuL3_aRre;6&0NycyEha!1Ng)#S`t zl`zr3`5hxS3CiCWy)Ij3;Zo8QdZ(QiN$t7c!(R_OuP08?U6(EAZUW zICLLKh`N8~Rn~DskAj%8lm6dif)?+Dj=mvJdf63u$Tv2mdm!)f4nOa?6musT=m(j}Nmt|os z+GI_PpF)Z^zprSg-$$OQ5;gBAf~>ZR#}0MOPvslla0{a{-))d&rcS4KuchYCGCbv_ zHH&1@Swe&2$AGV!O3zyZP#(WDzKx<^>e{5&(%~Ezk`a_S?le6%j5GjRU2R@vRqYAq zbwp494`txDq7ymJ_{oadslC&Nt6A#b6d5uV_j>e_X;0yyx3Au%m6il=$(WgKNF`6+HB%9mH(9=2yf5%r_~^GbFEV|5mvT%+`dkjS$F>Z$FF*mhBGF8SioWBDjcHYkXAMx{O|)Q~ih0>u!p?A+Kh7_$Ol5ChC)JCK%(mvL4baF%tw%_Cv}J zT;@J%8dW#9Wc#;PAG&?X({442iI%Z3t?qPL-Mj+>s(JM-u13Z$$~Cm=4L>TVEJ7(i zS_v|yx_5pVUfx4k-f?s`HF)Bi=|j4HGjCT*cIUjD!`#D8Y1Iqu%;Mc8-D*BLRv{42 zB+iXnnb?#(AxRhakGL04LO*~QiI4b~fX`M_|3UY;{6#rbzPapk#pl|9iSTtMpO-Al zyxi$=PibeTqBz>O`svU3(pSN5DM)*jvb*Q|_ek+L>YJPoi6tiV>#&A>@~gSiWshn& zZrT1t#DOa6*KU`L({L=L8p9+Vq8~jz+TVc)kq^eJHOP&0~w5Ql6N37|urfyAM zy$Gz7pHn;Du+nUEWCn7e7IZ6*BK0TMt8C4OwIQ`uXYG+oXl83-;jRC6z428b)$p1>4ZrNWgA1bF(OVQ2D-zRZ5!=PCc=?CP%3{6tRc&pB##dflY&&boCHLzfXP?gv6& z#%K$9i#py@sv1wejj!31IK0Q`?4E&@KNX*j>wr-vsdbxiZJ(u`;%PCF`C{el(o;IJ zo@Ke>QsSTK(?w6DePyTR8w5nukPsv!ct~(3B*g0OzUta_U2R>xU0qh6 z)VF@C>*`%?m2GWTcV9h?5?liW2niAh2@v4-X8-?lACk+x=f3B)bGYaIoO9msd?AZZ z-%0M46-@uPJi;lI7^Ne*4uV3J3x2dcf%{NS1%L2zJ z&-3Of?E}*1k}q+-QOJ6S24VYl%Ik|8;htH!^}|xrzBHGtSTPWHO}p%EfAhWv=*L5E&NU~+|MX~kepxn%Qz#l2hy8Zg`&Qqxy*ku=cJuMPC`=SJAD<#jEXmJcK+*mhf$Y%v$3yO zP2vhsPsXX_C)-nXoqH?UUb>(F#w^tXtCt}{g$D{IMU@}>S4~GFdW`CQjnu{ZzT$-S zZOR;0Ty9f560IfHh5J~RRu8FA)c#~y+B7}Vj^yHc6BlHU&ak!N8Pa%iWJ> zDr5e$%&bm_gwI=ZYnK}OVFnehlbvsmtEvs7(%sW2=8-!lI3!)2+ttkQxUye>wUTgZbR?JLeD8%K*!asv(@+w zQ8{D{8=W#EZ6!WH97HQ+_7x8*33oSly&(UYfl50W$Tg#?wv<_G7C6G~Ur_9a9K%<8Y@PMQ4dkZd7H;r)!3-%}uFm zD7W}q3)!7!%4gCGlMkED*N<>2T%GMtjW6}T)NS<@#8xGbL&l#2X~)QQ%rMKEGc9K> zn=hCmILo?4U6r+l@F#8sDz|O0hhUzpZ2EBVW&YEFuMo8xylQl6?$o@uoyL}OxF-|y zEtRSX4JD*Vsizwzm+Pthkf>Z7kqV3C-(XnA_R5}q6wlu6xY!c8St~6+ZY@Z8$zH3F zDhBdzk%l6_2^5-Bsy|eg>Qfqi3JVeG`2Xdoq{hNTev{x~_90v%wj)p&_|zs%w71Rl zcuZPdtMMyXDPyRA>b5tY@e^DBNbXDHq1)3K8R(o-k}r#pow{~j(5aVV8~gLrYUfv# zlV6cv4XE1GBqsKdKUTD5-wfe2_0^+Y0qi;M-`Tyxj@qwllFg%YCrW2$k(?*W5s<^L zyTDj_K=K6!+b~O=R&zv;@Zqt81fP{sIycLPG4gN^5rbPNx)Hi*)txmboPUQ0B~PKg z#m*!XnAP0Rcwca?GLwu4v}42_*guep#BZ@_ZHi!J`0#P^oC2uF~N!8Lu3n``ee$ggE)bjJE)%FYI$%Pu$`ldh2wrKC9 zk104SfwhIO)!Xf+SkBhc3|U_4C(~bLp&CnL1@G>|VS41FySdood`~LZ7;obG z7ycyXx2dZJsu!i`#2)_Nv5y+Owk6t=?@{#4SrMFEd#zk(UKx8q?ml!CXTfNLY>z!7$AmJkeNT&$3{xvmDO) zChJ-4%G#y6@0|43g4C}G{b+r-W%<3kmG$1&b8^XIeiFN|Nuor&KUCGwa0Ty@Y%08- zQ|OPD{roA`QYu+cx`i-Dr!Kn}^2>GIFoY&$+na8_-6Cmc1o^7z?!Q}=6>)@cUC}L` z7`ex`zw*mZ1kHfPx2eyV1M|_vW%*=LoHd@d41;aKT1VIRsJ*0rZb8+Zx1DcDgkf_! z{H{zv))?~3oNa=!(j8(8cPW#W>tS@F-o!JI;z-Q*jbobbb=8ee?>>#Iyss=W<~HS| zVR#!lxA!=wct<`Q7_Hh`F}%K%ytC`j&NtIP)!cvk)<0fW)pb1KgL-=92gHZ2w|lgs zuh(E|2*`G`-GLmaEd$3}%Zp8=%lV`~4 zj8wGod^wI)`cQ4P`jw??BPH~-$5Amls!~(>%-9t8DZLUI)O%H6!A`r%{&A(idlj# z#XLwYZX>vGhP^5t><^!(DKVTglk3#=^gwX}m7a^NZm&VB~9)bO+xuoiq z`aXXTcaL{eHKuVLx3$v@%J=4$51So3$goGeke6=s3^$PUzqwM7kJ;KnN?{3SPx?23 zzLx8d8)2|*oL3&6-F_i0LVn2IELkma2@Y~b=0r(en1x*3LT_@^Kd@sR6}IkXr|z1; z?pPlf5h-m)CT1YdVCRtEGjEBe%LVcj@m%fz*7}^a83 zjAMfL{85Y(X?HxElyl2hTi2k?vSev7TM~YvUutJz&q|8e@0;UQS>_>`KNf7DU#J`O z@nBO-;^<)`v313tUbMg~tb9gFC*_z=ef{q^54(RX{gIXt7+gEEe3WH7@_#}?XIZhE zGY}_gTkNhejIREt?0n@Z%gwgQSwmS}xsN#oymY}zZdrCR>tB2xrh9U(|47|SL!k+6 z`PW#lO=<;}hwfj37u$!Yc``!et_&>4CHz`SmfjJ$1xNW7wuQC>zY=*r<_gX695DA( zp8dG_&DGaCKX%dS{TFd{yg7wGm2A$R$eDuYhQ=G8RIE}iXu3f}7Mv-vGoFNQYZj`C z{NrdR^bR@UjH=r2My2ruGw@3pgS^cFIn&YP@2<=0S!0eSV= zkHc;Aa9gG8WHG`&?m+b}EiD$}O;GHi+;^_78s? z&c#3GZ7OI>(bmL1-3aB2yq$i_@it;A@wF3MCgmpJtjptJ9z;rVsBbA#>0i~XS1vV>ofCYQg4wMZ ziTl_)^c}qI;u+H6vWT=?Sd_bzum{=Dz9L49K5VJ+nw;vod*+vhKQ;d<|EFA~^V`dP z6WhK|UQ2fpFEV)Sy|MLn^_nCXmE%TRFQ|}hfAPK-R7GE?R#<+{Y*4f^2Q&??o!-=% zvtE9UAg-IE{2}m+Z7!aY>uthRoKc>PQYDYN4-`k+eyaNCO@aQ)__sOxq5l61C$g$bLuyJkbt(TJnM!t>FQmOk4~mpDx$0VVJ=99gSLTF!Mff^$1>rARh&4gP zkx9iFoFjA*aV`#n`YD3;A9LNdQ7jLPy9_=`x+Ku(SqMScrk5B?rF7~lks_~!(&gTBz+@Ub>kq-V4&>WI19FU89N zkr5#NMtqOx1sI4I@;fF|aF9u}2T}u>9r-EIlryl(X*jYLGMPR}S)Rf|_J=iNe<4$l zb5pE{ArRgCEaiU)7er>)B6cFK0Y-8(`7SXT@SX>NftUcvQYS_xU>o=N>-f`nef($0 z|9KvARiD)7Y z2+;;SE0Ze7!0Sv-L##&hgSfO6V%3iUv7tibdl+I8VkzPz;AUBnS2Z1; zSqIs8Ujc8!AOgxn>__|x|6e1{Lwxxj#A|;9RA(_nu6F&ukV(@5Sx@PJ zIP?dU=XmlhM3`$6?!=_T$wW!=YskhMNW4fK1{P=11vEJnrTmrfhhVZKsFf3 zSBZ^)Hhc}SXhHH);#J}dIH~3!22^O=}G>Ptb_P55784* z1u^Q1WFF!G$SeoszzK2gLcj|C2J~$&#KHxDQ~V9{u06?p5KA8ohz=TJ)eB)3_JrV6Z5RXh z`T;KHg;@BL1JQK{o`TQ3WWPC>evy zv|>Ohzk=0Kec&t$axC{uo&X%}&3}kmd*Wv}D+C`K2@(E1kj0h-5%QjZNXo&IlfaU5 z0gEdEN+ZD{azKQfiHD%m3b1Minz0CW3?)G=rOEtcJb?p@t}}eRiALZ)2dqtn?5V>INTW`iy z7zVx;PK*XE=ED<6K*gvC9$dc#mTZL#z-*{H0Nz{*^bvq!qClR0NeuXK9(Xnfw26c| zMesKa_(*@i*H|DW8)O~=_j)IZAXx!O-xtmY!I=pBFaiin`F~gp0kEE~;H7DRn^6Hp zO9B2e0CO<`9~~%TIv`pY$Shp|cbtGca-qex0~VJG_)sPwKs+eX21wGhqz=|E;=u>w z(Bk?*Eg8^Cl;GpGBpYh@5j0r@9z#poK*lVvI}2=(0}{o+u5_?qAzY0@3(JOYXpo>7 z+Oiz1ga$2>ftm=e^Z~Dvz`+8&b_Lm)pfLvMts`G?4wTpnZ8sH;j=H)50z(8zP=F92 zphqf2D1cufpo%(>yb)R@9W>hjxN96ZBmmCefprl;{?$OKGt@%=?_hw=xzNraSPU(K z2JM*#eDnr;i9lKoaN3bKI|rViz*#<=@!_xHKV8c~6HMTz4Sa|4pYC~J88-M;Dacj; zG+6MBoOC3}@Qn)=3^XJH{pG+11+9w!&lBN!DO?r8eGz=xz%Ql13lW~m0ZU^+Ivj8qfgIVzP*XBd z1lpHEJqqBoqwnqL8#}0lfxC9#j|8PUbRSQkfa@gO&j9KQC>sW^WkE046DZ3;w%))M z9?tmS=N(cOfee8J3$A5=^eE6y2DI-q=zYj=pAMWNfso~z@?1rCTnITLCUL){&-BozN? zqXektKz&%C-O;k?|Fw>e8520rPKUP9aKA%`9o`sDWI^ru@I4c1lfan(IK_i5J0v8- zITa2(9AwZ@M@IE*phNsm5ICo^I-GS_91G>?AX|sLSs+s`l*|E5WWaX`)Jg>pD}igpz*|RarvkT0 z;4A~4NP`0f-$_ue1gwVq&oc_)js);+Hq4WtKwp-GcZhU&k6Muog3l%3m#M?TRH(h+ zKiw6Bh4~;|htH)#z1N{dH^RJ!v0%l{PV23o&bPDK!2(2dw zt%e4*^#+ag1j*XKi}0WkF7WFEY9x@dqrDNpY90MuA9xQK0pB}XToC%#CU`>#z?*0~ zXrx1)j$f08|7iArx0SEp=|sZ*-@7UwB>i*ZyUEQ1Sy}6w;ld zO3sge*S;+=J|zRymNF#qXH3yPJ=q6|MwTQq+c(5+B*vmpsi%|WQGeui!j&3MJB|1$ zQs4Rl_7r5{yr^RlXJ}I7BWeSF309HRgdR5Ui9SOo2s3bV6Qbs9Z>N?*?p#&r`^vvuhXSM!6E5kwaIl(=3p zfqOj59vxi&Sba%ZYTh1NOd#@a$Pj{FS+m3S=GDrew!xR4d0xU%;Mfo1OU>Tu+vcYU zD|@uOl-0BSv5Bk3dsKu}*)7R&Y{0IpLE0;^ze=Br9w7^jyK02q&*@9^GP1AudZ>C? zU!&)Xz5L@zqEV{7-pb;X%g2!WxR+HctbMVo^0rAArQJ21t(xlWocTz$Ml=&$;0UX) z*3U+Z1#H$+a){BoR2=|Lz*6` zl6r2VE@ce;v2d?+v!IQBCS4ciIoIo7ssy_3_VU0#Nm|BF^zEGYf=7G_(?=wte{J6s z`nyqQ-)gGYb4&y50~&u0S=!ISs>KKND%ND)5q^SqfP>BbiSis@g<98M-m=U0t9yq1 zgr(L*Fi`-_=jI^w52%U90S;Lg{Xqy4eYyX~8S)W6;U0 zV>w?54$C&kr%D;T1@vS141_9ZaTc0VwSQ}?&F$`=!$VTD$U`_MCC_9NgqP{Y)Qy3J z#i{;EyW4??oF(AcXyGx=KGN!BbK@Gb#PGr~FglHF5>Arr&t4EeYFn#v7#4;Ol6r_| zh@NJq`J1&lI(}dQ>7}GtxCC?9Ia9UW(l^u3oY+W9kHp0JIV=~ty3EVB>MVVMnMy*A^ zG1|iXMlNF?ikr}c((%l;i}mZY+G>i@YMAA@*N)B1 zWeqA?qXiCHptl z(A+xuUh+Z0mzYtg(+PR>*OrI=h==OB;ZWLZ?A7*FcE0_dU0FZ5VYl~e^Vea1bX&rK zqTpr_TWI}rxoj7wkT;9JLC`9Y3eItNGiTB-5clG;QwPKcglGG#uDZHQ#$%AxI8=MS zc5Cg;+P>)oGK z{qRX+xqwXY3kxy4=ZPu%nGe=5J7cY~4q`z-(vd>aIRCY7p4tzpC z$ze+O6&#gGna9x;zH5eORXZv#tGC-9h8Ci?WselA3icM@#HSb*?AGWqPo?om&B2PL z)qV7%oXx>NavFXXy^x2Lj?Pm_YIy<3iL)797CYqcRzK2oR!hOx5bWoEV&2UDmhvHUGj<;8O?#*CH2-x^ zo@2{ea zk1C%bTP?c9d78~7_Qh;O^o%@djyC?!iK-i6h^T}$e^=eEK3;oGYcf4_Jo0XiyiDzp zHHy)dH&lvJ2#dcg`by3f6fm|EMj=OpJ?>McB2`^gq>7_LnGU$8v>~vCIlUxVg=dSD za>y@DoF9Mg?xbB&@lW|%)h5TM)>hme*1NnJMSJ9-+!v@U&pUOeigh&;Y$u{}3Q00o zF+ehye7*IWxl7Gv^-<4I%xpnPky6GcO%7D327Own?H2xtUM??_wGrjM(%KVM1D$tp zucfhqU+I>Bvo@(*=Qq&Rg+$qPbRX;Gs(v;B&LBUfc$Iab{&?jKQ&pNy#wwVbQE6TJ zX@I#DTbWlQS%>VU8&NUaGl!8Z`9VmEJgr@#!bh(QelJ8(ce+MYtTF6KE0DiboFN>A zOoT^FvoXE%odshvo>;q;-Pd(Sevnv;<}j-KgKEC6CV3Z9=PT&)p@jRcZB^EqpPSYY zE93{j8J8D3)?t7(vS^(R6kU{(M<^S$hat|Q>f%Ca!;j*TqM=k zl~Z9a_SNlQlClIU`7e3poHGPdMC>fngsaXfS(cbzfci-JiT{tBp_nU0G56!gxBukr zQ@2}lyoO!7#qfo5R&ZsKguh37$k``8npd6IRouw5WtE}kMXLO(-3r@!(GY3ZF^lj}|=@jTP#6!`NwzcH&D+HsXiYrM`Wxowm7_J*N3ayb*7D zVQH}Y-CKOyTkNrU$Og>ZtV6WFbK~qo++yBV-U?n1?s!&9b_3Z8d*${`r6qriJP%It zK6m%1zhJv;b(`mz&KY}~mRPE7kDb+?PJT|y)u;yXRr)`K;grhkolGw$Q!rCBQEV5j z7Ea)AwKmwdW`0pvcCFmjaa?P@TzXR`;vcB+X(~{ zTT0r^DB|`L50#4)`hsP7HNrIZPqgubS*dl=&VhfOL^DcrwJGQQ=#o|+EDtD?{O+R}UM~&Dy41uUZ-b>!fJ4js8{l#za<821jl5x+Qe!?wjXKR5(8CburffSn&a;E=noMd?{1 z85^>0M!UJi&fh}Kn188%WG;xSnh2gXc&Js{HaDS2!=?8`krR7k@%CYe z?#RXzZE_y$YH&U|Af*@b4uX)JkQfBx>f|^57{UfvKQb^uNN{0XS_pHpS0ooD zvJ<5czwZu_Zz4p*uO$SDg2dp&uP}CH!Kmm=!X5uDULBtdqrUmDx?=^z^}8m*aK#nh zlt92(r!g@#F#_WGQ(2!v#o$21YoVux`Zj= zhDha9h=xCcsQVFEaWdtLo4aNPmaufJ5~$ef#M(- zNlt^=S>s+P zJ2bH$#=UC+Q>X(9;l$9y?@-VG5@#S18vBC zoWDz~2Ye?Dw1tMT_S57+i2j}fF8+eJt{0@7gqRNz>KVzium)ogL{HB^9X)~nQiz}a z3Gu;WuyP^~@fnOU*>HantVvl2TAd3~{8Wez8~)S7F?jY8;vtMQ4Irfgu%Ow1ay){y zM;Aep^&{Ak1NXUrbo>FD48mbbo`f+oAI8peA!615 zp0XXJECI?Yh>;*+CDJO0f1n{!cqq9XM(H{5-2pb1Kp8efJ5K=OFbudj4-x&BfY_u$ zJlF&}cn(ogEBFSing;$vQ1fQ+tF8Z`FfD*f)q|hD0N)iQHNdw4M)Y#9dJ#y)1IY&f z?t=&IFq8k!ZX+NjAOeZvfB|Jg+&Y%*j(C#XlgNVA1FH}|R9V`-v})AhBtEvfH5hDd z?$UBJvJMeJKf`4bZW5m2e@h>SG_;Rzo!>m%H^O(UIT&8hj!Hd=|AX{^DyPmN{esU% zWk*l?E$&;+>8^q%Zzvq!id|1}F)&OiV>D?SMhEluHTBE;Nbaiz7z zaL#nx9&6eiy@9dQint>M+qttC?+ND+{hAj!ju@qeUo1#BA{0oOOWMw!Ees0~?0kw9 z{iJPTLy`Fp-6w;^_Q5-=9gVMM>=eXB*Liyx%L$TXz?Wy+sz0r>nP<5FX&ILK4W%bH zDJm45U=N@yM1R|Q+=Vr*&}}kG9Yy{~ybB?kyGfW3&FA&arr~jMy7x2dXKp+saMv~oWA6nnK<0iGP&3MPL z*I$^pl(91B2)|J?q5a6aCAz>z<=V3BNK^3F z`q`%528QLS3lmzC(v^6J^_#Fln8*2o`X>E$WQ&JmIjV0p?zdNXhql{siR?2xn(zv@ zB0GifO_J*8*(e62;k&nR7|KhkTd5Zqo?6un8(WLeVd^yATTzZ+c5Yc#XVkTjqy8VGOJ_Be zJ3kK=r*tDeW$A>gM7dlqZ3vFtj&HJ9()H5~qii|eJF%mgh0j=C<;Nyinb({+eGq%wv~VTpbRK$a>vGp&<1(#7 z|J*jiyCt?57B^1ji$#}thZ)rw3ln>NJL}GAo79ztJq~r?4Wcb;5Q`=}AvCes)NG6) zQrqx_>62!d&SYNa-WPtH7NRcaeJ}o>a49RFJSy!#_@T4b&`sOP5V6(xqKVC!x0#Cv7-=zOI|)vxd~x?rE1PS9!lkA4sn7D(D2<`{`R~a{C%*Y)o2nwI_ zmN8Cck>a^X0SxUq+kL25cMUd z7FLGOwOrBN(`&3}JtN!Br(dIA;WvxF5Uyt(Aji|3;Xj>kjQ#X1^9Sd=;Ix#B#LFCq zWQlZxpnpzF#)`ycuik3a&oO4(JxwE`vGfQn#ycgZi%Qvd$=0-{aIx!zahC3o5ee_r z-P7?kk#`HRgX{mLddu6ak@_NRGoNPg! z=ojA5oa)R?DStQbb9juMjMM6tc`Cw6)Cp26YocHRe+9FVa1we=7m5~Uxatgx zHJSRgbq||T+mGNEk$s%g zqHl$>S+_|`Q}>1@JI?6ns&^WeHM2?HuE&dW#Db}!k=#SH8Q4!zU&CHwo~o~EIBaux zJM<`ZB5gc>q68y4#cC%JQ&)vj>zM|UcBdiNAqn(Hq>~WbUQ&@PPjH+e$)Lwuo32=| z>P5yy_Km)*czMQ2SUJ!|_EOZyDkD))@0)wu3-rBp0rOk;>o6Am4Q&Abu|y_L=X^m~ znYONFef>23*Xp=VVRL)Ggr~EatA#kxWe$xN!mNunHY_zFR9y8}#-UC@xHno$rSMjX zzU4PF7@5qJZGo0Lt)_>nw|L!{pFTq#B#U_`q?2WRg@&Agglq8|jbq{I zQMw`4-#zTeGt7Z(i=bL^T)2`|MEZoJ1pC|8X}YS4b;s%~-b?YV1THfn&N-DE>V0EWtcx4oQui9h_tTUh7sh==#{SUU6bY)&TbB zk|nZ3g3-Cg%;v;BZ=v;1-7WpE*38C{ku#WM41(yKY=*?kIZM5VW{0WHLGTM)Yu@ZC z3Ef8e$U5FQsZ2Ug@H3-7er@clhGzzoilzP5g7e5C8?aGEP;gQFn_y3FM%H{pSKkgZ zQMCZFNd95lA4o;EkvemmMD>E7Seax+>cY@H+X?kfrC|ut9$rWkDV|euJ zDfpeStj2UR8*(e*Ee{$dv`xW87-ryXf_Nmm2f26Zq|kcDFZzqxHq&d@)|R2EeX0Ef z*YXT`siGx0Lo?pB&uv_5?xL0JqjgnH^|6!qmAMr0PT5WIHnx)V67`|EzwLW1QEk;% z*_-|4$>z)ntP;`RA`~}CZNmH#l{OqP^j0lbM~rh_jPRPYZ>Zb3qlHx71I8Dbjhv~TrKN_YvEnQoy?bcQ>I9>kK`Pd8;-FX<-f-&or`>)IUXZL|O{Q(_g> za+Xk^rAMGw>to$y{9q2%4-VueVvrWSi|DoFoM3wH!Yn@WaF9@MF@7|-E$7{*TYSh~ zq~7e|!WF`K94~b)=3(SRLr?Qf$eVJ(@~|PTH8*WIrI7QzfWj-zIg>dpWqb28+XHQz zYN?K9o8x;JH)TxAEfAa)Tw)KR-cG+8In+>Ws?hwUEw->c{o8`+pR(-&wd8Lhg>{g0 z0W~=^w;pX&=nt5uyH13DMSf4p;KG^#u(yU$fu9*a?u8`j2Bq=59qro~!{dUCUc#3W zv}g;UMh$ zw?0YDCy8=pY+bIB+J=`SMl|#5U8YgS=@x{OANUUTrM^v9v+uIY7^$Qe=+C0dn#S6e znbhVhjuI~_%D}YJ>bR=}N4W2EMiYNWsajUMORfFPCv9b(M`3bmfF$EIh;E9)+`{a9 z{DpW|-(lNY6Us8Jewc54ECqL$9^!ID!vqM{_oR2ISg5gnpmC$F#<<2Y$X^!Um4VDX zC|D=l&ymmv){=@oM*BffEKkYH7Zfsl*t?NE z?x;bdIir`^Ci}c`LMDfEScc3WD}BV7NbZMP*<4~+ z)mj7A`8*^{dq%q>e3t(ZvcHgx%nwVRaaiQ$ zltlrXRix3Vw&{O%j0|d!vnWu2JJ28fnK~%;_eo zl+^M+XLw*w-ms^q(NojCW}o(jZE*mNETGKdGi849X2{eRNoBMM94#6~?QQiHv#)V- zJD;$c-A|UEe_67Q-J7&LQ7aHn-FoRO8Icc8j5l0QUJGr+- z_oN}=kGZnU%SpfQt@STWzIv9yQopw4eOe#-JK<>g_`Gt#C`KW!dt`-6WZ0!Xq+{DI z`tZpQSq0qDd7Avu(tp`y#1P^)UpLEX^+YwwSn3RfJgL9a6v7*MlOZdin)++n^3X&3 zH0@Wl0nH5S*rrc0HGV1cJMl>APC-G=-Hfl}jK-K@r*eT3p$|J0p}nbDv;=>TbdvZb zyN=9AD+^t+-_Z0@{;ghN`CsFmm_FkJtE==u-YoGVScP{H(anF_TCX8%W*7xdTWD)q zoSr5gmVYR3j9_oJ9y=g1%C%9yURA9P)UEcdPLyRj*kS2Cxks{}Q$c=!8q}<@CRB5k zUA28}GXryxA1Q8re%>E4x?o_o3G=M=^ZH{NM)l#E5&GWF#bIr_iBT-Ro;Oj#XGgN` zB(F8~GhV4)Ua6}+Xi4#r*KO;_Hmtu+jA{nT2C4Kf>L z>lC)UD&8;DAJPtm8trR!N7N7X7wp3WQ&NbOn*vgSP|;bMV13BCp6uuS+B{PIkNS>j ztGi!ob$XQXnZ%dBD(^c!oBnnBm6mSypt?}mTT@|CG`gcpGrF>iWnqXzAI!OzVQp7< z#_B26)m4!3)Hb~NG)h7{B9O_>h?VTO~A{Y;k5V8{J>cN`3e6K zx#73!bUS4GGfz?PRv`?ho!sbTl2EGZ#3>oixtEgfFx7NZ?e+A+T@u|dY!SM!`Hgqf z{Z&U?H1t!xyeL(MBNjG)rGHhs!)A{Eo>MO$m`9=QZ*>_t$~z`aD}^eRI;F2^qR1vw zqROgG23j+_iss0?IggSj>_%vNGWX=P>+D*|N!HcWW9~hw%}R@XEn*&XY~E5~FM`@{ z)&5?sG3na!X`f3O(v!5_u}yUoR9_pv3*kuJCGos(m@iURIIpRf=>F$r;wy#T0*oMp zsc)3){?dv(U!ptlhAP|=PuAe(EPZ)xwOxWRGKmGLdE=<0*8dsnYwAn~+78iH>s<^9-Kkkt-U&H0*doZ>VUS0~@e-;Vv*xKV|vdSDi{Kg<~+ zkBJWuS{l!_q_)3mfqMjc z2LEbdw(Jd&*1SqPt7b=?H0fd8DEz5_&*&cQZdTXC4I5j~l%UL2aExtAVL2+58qHF# zBIA^pShQDk5BJh@wYF!C$vzP|i8CbsC*f=S2=5*>qxx$LuAP!op4U_Ii8wL1LHDkz zx3Q>oI(4tKuQZ>!pw(!2UU^iT8~l?rS^90BCi}107nZA4Me6R}OBoTE-gc0sNlB^y zSNUGG-g67vARMB2!&hLajoY>FH5G12+7<3N$STXo8WRZW#sm81Ml55kD)>APLme7^ zWuVl2ZyFkrXUFo{g-9jmq~|90O_Uo04>cqypH?DbTsbT!BAuT!Ypl|^N83^mpgG?Xd_S)9?sjBG_| zVG;B1_|&?&wT%XSFqm~oGDk6yw=S*Kd82lOM$j}K*H`qeaJgt1j@6i5vYVS7e zNI%S5oL9%*ls2~Ej_O+_%~6#yi1ReRRk$ES|nAJbL*1IIfjWLHi;^Y78G!6P}7`A)#v(+fxOIr#4*KvK~HQ^<1~$5 z4Ms{^3^Q+j7y9tNrp4;_HJNrU0>{eAXG=1O8=LXEw^d2Q$gqJdmE=k%(nd#4n!DC? z*UEftc(Y)?Y)bC?q{Z%mWg1-@6zKCDi*z@8Us`w1TJ0kB3r9Fn!B`|U3YQXbVW#DO zn%24_(ExRl#3Wfszu4YjS7~=zj4f-4cSXbVhO3J(_1Bt?YmmkQ_tR(>0){E!mviRQIO#W9gbt-nr@3Mh zcow(M%KD9yEw%_c=dvA1KG%VXMSVdDK|Mf05@6Zl24sbneE6UixzA9ZRd&P^9?;(gyX5)A2A-ehY zmf(Q&P>w-NlJ(^`k%uIC4fC|4tGCr&vP1$NbY|{ZF-p3byNk3jvD^Kb_TOrOD%}cu zE2nK?+!isU&v*+ddk`l)+jPHHFRIP6<_5pTtYCegx4hu6I7mmM1~>KAqif!&^tOud znhYcpSfCa4%N~)|=-+Ay2#KbyMBf5p^L!>=hceB1a4 zfu)@hmdefu{IurOt%2F*CCX&YCxgS&oMnYmOK*Zv$^Ti;7IFw)o04py02VW?bSpl zuK?CztC?To7qlL;muQaH!i$p|m6%6~2oL7x=Dpw;GjB)JoK~GxqcopvI+&~>^8_~8 zCg~9Fa^mH7t?QNkf@ZPlpr=PdOHLJ~6+F)Sg}WnjWMqZobItFyM-A89f5ePgSGhS- znWThWNf;BgIrgXz)Qr&Vvpd55@M@MtGF>{I%O~1ncxNv)qUy)mH+4^13h{IfQ8FtWK#wI#Q0eYy42bp$VdBfrpm4;%4B-x z=h(-=-z*m8ftue9M;dP9nu0^DPisfitkMPCm*VwAD$g%LiMHqT z#tOqNwvDQB)i%u-=flV&LYzaDHjBnHAK{m@?RH=_f7J}qPO2Z<#wEPqER!wB%itd- zZB68Q790Lh$@E*CFK%K=-si8JSW+!Ahw;mx!GfJPYY}?^iF@ukkpOX*8dxT)sncGFOlJ zx%pq?r7Bq^P5Z$8Rf0u*E_yE?C3%|jd-}NMt|mv-hRQLTD^6Ftj`CjopkTdB&YFqs z66BeOS92=IX*Rp?i9#ArcDHz%LdDC?jJIB_`%C$tdcMA!$CP|Z|1t;dPDM-NC9#m^O61#gHI(JS`5N^j*$%|mBhER~!i+NxMtKosmHZiue3 zcT!F+|E195DjOPu{|119sfuFRY-Qn1;ob+i))eF=1<^$M~oO|;3`QHhUWI|l% zyj|PyiByd?uMYf!v2n-+%)&p#pV3|*mNo2A3o1TTe`S_6pT_>p+gD^NJ(o{mWu~9> z9o9ds+E8=fbl-mg{T=(;!XG-lRAlk?<2MKAnC4c;s*W3;d8ekf+@s8g*r!f|S>cfP8eRx-iD~5fUvC)1eB+r*Bk0*O$fiQ)Wvl3sVdBbC+av zZ`o_QP_d}YQ8VA>Zfhs@72PNZ8f zcFGp2xl=(?&9E-+}AqQWkcx7iKI zsf|MnBo$8^w8z6k@LM@k;f*IvmY#b*&FBjo@wI=c+!kNp2Kv|BrLrXjTcksn2+YJl zk#Td?f~rY6RKvyOW!gG1wP3%jH|r=iC%Dh(uSk3%sP;Ql(H2q<(bN3SvH)v3zGur{ z=F%#1d6TlA?NHk+;!nbHip}}Ud9SmakyP9Cn%s)TwR+p$w(p2(;x5JP;?9zkjP<*lETHe38cT*QMdbIAUpKD{*ywDsxTYxWSb~;uthW!EE ztLcp9+X`FN0i)DEB)y0;Pa!R-kpIQ3N_*4f(Tx0*_&<)$0xHd~fy1N;7IrIO&~41k zb$7XTo9oSW@4xQu?#*?F>v9%d0-}N~7}x^R-`#gS?4V~{-h1!+qT=&=exj%9QLS|l zamdA__mtnsQzJ7y{&Y^M5DRGhL5gR(%Z^<^KNGJ};DpDK>ph!#K4>W7FyRS>Thn`+ zf8c|}3AE>=F|ks#%9L9xl0hYns(hWuzAk8GvYPRp7K0xau-Ce?;iclEj8n0-In;p& zyqI{FK9$yj=VLb5xlMai)8tBZo_>M7Bj6prk&30>#L+QUyP!#;cq3t`el$w^kNaMY zN06QoxUuW}`mD1WRb`jNYZS}tdyS`2k7FsszGOqpPTyY3rG}AZ&7wJF)wLJ;-g)=L zR1vZXe`9%mCR=CIR^=4Qm$KuvUwdD9-Hiz*9Uwo$O%2q##s5BSKRw+?+6MOf1+QG_2()ST^BKQkep3d&5nkA}Mb$HVivk$sG&PvT@ zgcE|pGZE7ayVcoZmE>Gyguwz!4EHCjr>sxx3t108Xh>1B#N%Z~ZAVwA$NlJy6e-h1 zmPViQEbW$SJY)^hvP!@9I>gdQ4(S5*XJT(?F`~|JpqwsP&)=wE=x*812d+pwNg)vS zL}@)&_FUJ_mHm~5R9Own;Wxqul8!Sc(EM?1zuo4N8iQ0RmZ(~rHrw`M1PLY71El1b zQt!KcL$n#9ojjiOXRWt+yl*$QoEk+_;CEn_+DHvm@&qC*rsHVXfNw4k?tMA@QwV*n7--QQSA`BIYL2_^9jd zM>+_V$7SObr)roz#h$8IDeW!m9Z4Vg(;eBlPn|0$=Z7c{>p_+_^a`PsDooag!;lBs z9Ex6Uv_PVE8iJ7RQFmy!Q%V^$TnF0NYtpz&1Er5E9~fpJU{Omcp-d>LKGNS~S!c4k ziZ`0emNz%j9r~bsNeEgs@ntmCYkya*dbaR`NLR6<-Gtjqo!u-qJVSwH{_eiZIL?9cRe>WR?#TI zS;e)c1}h?9HU2ntICUfb1}4rS*M*f&Dtl5nyk&_aA#hIOQo54rhm-mLv;{QzC>D#h z$ii!?dJlLXkM$*$5l+XR@VjZf4^|s1=#mB0Czzl5Ji^5>9xy|R72y{VrnX4+YUxeo zoBB7#CiD^9MEY5JM*=E{;ToaeD1Xfj5USO44F?eT$RQ*oEj?*>=zLgki&3#s2$71k zM|;+JY2&ssPI2Z?7sPJ&QgmIfTrbX&u&esp?;`u74p7n=dkE9RA0sv!GF2mZhk10x z5FO508(fimoI#_C;+CL;`zC4_lEo51MSt5axGQ1^)h%Tk5@+cay-L z;Hi0ZZqkU*B-o{vjfw!?QvPz)`<8y_WbtvhZeI=_!y{ZP71+z!sh3|CX|^Q0xzxdce$2=}~BS;avCl82Df8tN@y0`d~8 z7*GZ~VQD~s^>TekS({{_8rO2tbt%N38O3@;&sQzc1L!*0S7T81m41b&j;n$9kY7bQM~F`Ls$ z!Gi9Hx9y87Y9vH?cQvE;6;U@qtpF)mw*eN z>^iS|B6sIG1qBtOI@Y>n6;$6qWd(wyjOcSlEUO&&>X!QKyC;_7K0qCO_iD_`1p&(!ICAts$vNx>)Og*UpN z>{$)mYSHDvwc1WEL_p|GJTf^8yCwJ|oYgU->WXxq^lL>zYn$s(@SemQ6eFP_>Z0eO zZgbTg=~bDpR^Ii-Ga`Nhb5`mox+ON-GqmG|>KhNvcPbaPf^P+pXUVDbxyc=&c-V#J z=knX7k^F#iM%y~X^{55ZCyrZ$SZ-hr+gnJ@9X;iELQw*!@@T$&M4pZ@}*BDZr>-=6v5@Ux%76p7p4DXXQ zRa8%?Tii0*7=dgEjEdD@IkCvlAKpXl%npifXj5U!i=M~M8m~K;;NYE@F=#Y=q3L() zl?FroGyVR~2FqULE}yCZ7UqxdZ;x{>hsnE#(>=fUN5275>Phtf6POj4;SWs1(DnVL z-I<-8otJtyTc$uxxvla{^K`j&Lmk${zMY-v9g5Biy=ZG9jPJ1n9qsExef2zxfI5{H zN`IC`Zuf?Ma`Q$pd{WTMz3^_CkPz!oV|&l4-etz){dh+ObR)uucn3cS+3Rq#B20nC zi6%db+%XDI^E%?`?=cCv6Uug;v){1CJLf^#VR1;No0nTCVh(h*GsE_^|B|WD zC^J3oe_|Wp+~N8I@q&(kOmjVPjI(dDa~uTcSjZIk4`hP7&b`^a(~XH-4qB7Xxvo23 zf)5hN;08hd0>Zc5IlwW_#<6Pq*YvY32dv-iwGM=<0+In*D!EWNbSJbCB7-c4ErQ1* zULYvQXyikL7;zQ;47w3A$@RmT??5~99jhE2_6)}c`v_aM?YQltt=M+T#<2T2b~)ZS z0ecHAhZ>dd%8*k9~xFp8b?P!#>=;!M?-ZVaGd+j{gATJoH~50WfQCgl52Qz$ozR@EG`d z_-MEcHV$-yJp(?5en7;|c8&%V_$gq;4s>YjTR=xK!JcR5+og6mIG$aA_7%B)LIy*R zLou*jutt~&wiI?5+}jW1!X|+ZUp{0qppv%(I{3J=z`=23gK=t)1;S<{0FB z4jSy<0b>ga^eQ}rv_J+ycSCo={NW|Ar?4w9FPIeygJnX$13tMI?9^W80OvxGjh*61 zaq#U!9g`h9?Dh5|4zVN2838=v0iZ7+3%FZo&_!U^9>X@lrow*1vS9mQT~I5e5s={B z|I+uM-I?g5JGVMA9It`xk?8nl@3*f9V~%q8IXys**u!<(wGvVU5rc;31yCYP06h=g z3@ZV4pQlhP^eb?)=m5*?bjG;WILjS0=W7SUu^K#nh+`4BchBJuX!=HHBH)S70h)Rx zq!j{(o`tHQf1rKPhtOTnBIp@tEc7Mdt`oueY6MjHS*MrN;Ftu6`dN;lj+bD?Cpl^z zna*sd7T8i|0{T4~cUV~_@`2eX!pU-CL5hInOaS{%1{C;zz}(&g#!vxLI|=d_ z5(w=CW~4R<4628G0Q00k{&gkX1vZ#ykd03TH$N~7GRSUwfujUhFc{wld?gK#_ZD#T z2R0OUNIoPAN`~sdx^w|b90rX3<$xvE0>4Iwvj#kGvg@xi&$-yCazN=gp#r&hEy%*h10voA#y#RH2dV#QkWFA-HNeo{1!(<$-2?sL3T_1ReG5o@ zCS(nu<82^e@Xz;v0t|hXQ|4>~sr#`m8psr|0gK-SMqLh=`7SU<1jsn}0gudQK*@W8 z*9&)WE=K^5%s)T>VBknv3wBQK{N@}7^6WzatKSd$L_ENmGY&BOKfwA`yRv}|-wa52 zE_g%`nAs9=J+uLCz6+Q~5<&JN7OdNQ@T}#|9N+^2Khc8w7r_eSfolN=#xsD-y&K5W zOMsau6j=LX!H&4V3eN#9C10@K1)y_)0{TGy`CgDs-a|*~iECq@Fr62?F$Tbd}lXT#5sse5WH$d@Y!PV98JPOQ5wcr^x1KYtakPmVD z|B+;Y+{7P{DbN9Xj0vpkzpez3-T>_`N#Ju8m@OP69w1=HoM4r=gH>q*jT{TWF4qDp zK>_geq(kOHOTlZ?E5}dEbn_H*v#k@c1a%#qiF7x8HZ18@Lwhl-P@KoEjzcw$<~zuW z7)<qfj(g>(Tai1$|ObQ&pN~NT`>44VIt(NHvVJ@Z7 zz1~)xEG^VdM!lw|Fifbn>O!udvfDkFLSRhsJ5=|S_p)LP@;C|0JcPO4_=dk$9R%M^ zG&52I_vuCm8dM)4TasrmF9n}$h6`I1Rd#Jc6!S#bSwp?BS-!_|3O9=NAkx{nM_eNf zGKI&VVsT^U^qM3Ql4(8im|5&{Y*ar{MiUNd-y0FbRweFs;1yb4xo%+41}2vD0Y0gG zHg|2^1K$e9OWJbJ>s7|mZp|vx5&9GQc63#B9#>g8-J_W*VvWO~8y<0Ws(#oyGM5bv z@oZ-DR5H7bnRJnJFS61wh5uT7*i?u+n8Jul>ahv31=6lVvD(xji8baq;!(U0hSun) z0Y^w@9LuGHOH{fiVU1~Nv{=|sIkZSxvm@YG%3OxK`!MB-g5#CL(bX&$d%JIlc6;Gg zg$ll%_Bovuo~jEh9w{L>zfz_Q42T`wHmrmxOf+{VsRrgIEbBeZU0h5vxW@$!l93); zq`XtVkJZl(lVvcOOWE-EvmZ`7!h|*>bco(o(gDK!CT<(w9 z$P97Nqnfn^FQpc!h&DBSdq`kC?(YwAmSZmIV_J21m+oWH6VV)7A@Re2>oGsuE|zW) z-?ttjzQ|z51$M0Ejgu_4jUWorr$@hT-C9y4gj=R18&dNlH?%w~ZWf#~H70yYr9{6n zoZ~74m;0XLC}|ulrk5;QESlH58~ZH%dLpV{BJmc!>lhQ0kgg_dvFA&QdEws@?H#{D)=VlL zd0VmQ?+p#oSHS+nnu0o3xw5{oka+7l- zQP07IY4mj^qeZukBy1%sHma#Tm`@kh8FEzT~s_qbsPSe)JxCsomed|T&AGI50R1Vir7+QzRviDJXSdi}o@8+*Xw&=g>&+jfzeOcoV`AoV zPT_Fo$&!`4=dItv-?F&)GnOFf(bD0%oxx97{A813g?t)UsIvsW=7du_5ODQ4!5n>7 za9YZ7#xJjHZ36m=_P?U<3KaYYDViA_5K%v? zG*~$kvWB=V)jcZ5a95D2IO~KG3(_aYujz>pPm#jRMEtMRo!CU<0dWNXLYpQ$gS7>B z#r#=ZU9zpoKWHO!GQsNlTBa|VQ@_iNN5TdG&^o5!aY@EV5rL?d1P#c^Z<$eomSKKc~JS zdfXBdqGRu;)q8!a87IQmvC+qACA1?xsD?HHT`hL)PMAc!gW>5<3d-b!eni|6>hRG0 zhE&m3*=!R%{u%pz+|j<_GO2vFnTXS;n((2PGi9@7mfot^;hYK_=q4_^D*WD-9)6oy z6L0VH6w$dGS_XtpVl2mX_akN7_-!qDp(<7=0STe1CW*ofhHzHOPSR;Oukwv#ersY# zH2X1mAu^znB&e^i_C?U95mMnd%4$LK?u6CphZ17@$H<0D!;Md{x6+3sm)Jhb!v)V<_k`c) zOid=(+oh`F|LW%X=P|YtE1=bi_eEDU2faP$I~k{adTZp|l*$M0OK1~P*M{9{*(my1 z;e+6kJ5sA6XS8PtGiArjv$3~XSE8CbO+v0s81l!+F^W^+o#=+)lbKw6cA+xJ+8oJ0vQO z+8VK`8!b5{!gMG@KT)aJeruL;m9($pe7J!%hQNisuh2<-J0^ywutyLVz`HB6#ou*< zF>B~30_a&)pW_a$U5ZYjbSIM$4)vl^gQn86iFB9j@M2ZZrB z$4GZJ-uCUIks0d)l)63%v0CTWNQ|VP!SMBWg!`0A+e@5<>KTM+9U=TKon*|4PNZxI zo7@>Ac`tq3ONc3CW#BVxDXJ#f;O_HLc1{FQ4vnnPi66DXL(5o�~I4>OyW%?KiZL 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�JZSu*uIjx% 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�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�Cw4&GR%{zXl(GqN5GHW@ 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 E0G*{Ap#T5? diff --git a/demo/misc/sound.py b/demo/misc/sound.py deleted file mode 100755 index 33dc017..0000000 --- a/demo/misc/sound.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# ------------------------------------------------------------------------------ -# Copyright (c) 2017 Eric Pascual -# -# 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 NON INFRINGEMENT. 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. -# ----------------------------------------------------------------------------- - -""" Sound capabilities demonstration. -""" - -from textwrap import dedent -import os - -from ev3dev.ev3 import Sound - -_HERE = os.path.dirname(__file__) - -print(dedent(""" - A long time ago - in a galaxy far, - far away... -""")) - -Sound.play_song(( - ('D4', 'e3'), - ('D4', 'e3'), - ('D4', 'e3'), - ('G4', 'h'), - ('D5', 'h'), - ('C5', 'e3'), - ('B4', 'e3'), - ('A4', 'e3'), - ('G5', 'h'), - ('D5', 'q'), - ('C5', 'e3'), - ('B4', 'e3'), - ('A4', 'e3'), - ('G5', 'h'), - ('D5', 'q'), - ('C5', 'e3'), - ('B4', 'e3'), - ('C5', 'e3'), - ('A4', 'h.'), -)).wait() - -Sound.play(os.path.join(_HERE, 'snd/r2d2.wav')).wait() - -Sound.speak("Luke, I am your father").wait() From 5febabf006db99b63cb6c76e8e85098ab7d40718 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Thu, 21 Sep 2017 17:42:36 +0300 Subject: [PATCH 009/172] Update motor tests to work with BrickPi (#213) * Reset contents of tests/motor to that of dlech/brickpi * Switch to python3 --- tests/motor/ev3dev_port_logger.py | 0 tests/motor/motor_motion_unittest.py | 35 +- tests/motor/motor_param_unittest.py | 238 +++++---- tests/motor/motor_ramps.json | 2 +- tests/motor/motor_ramps.log.json | 2 +- tests/motor/motor_run_direct_unittest.py | 2 +- tests/motor/motor_unittest.py | 651 +++++++++++++++++++++++ 7 files changed, 819 insertions(+), 111 deletions(-) mode change 100755 => 100644 tests/motor/ev3dev_port_logger.py mode change 100755 => 100644 tests/motor/motor_motion_unittest.py mode change 100755 => 100644 tests/motor/motor_param_unittest.py create mode 100644 tests/motor/motor_unittest.py diff --git a/tests/motor/ev3dev_port_logger.py b/tests/motor/ev3dev_port_logger.py old mode 100755 new mode 100644 diff --git a/tests/motor/motor_motion_unittest.py b/tests/motor/motor_motion_unittest.py old mode 100755 new mode 100644 index e2bb152..8bfac60 --- a/tests/motor/motor_motion_unittest.py +++ b/tests/motor/motor_motion_unittest.py @@ -48,6 +48,8 @@ def run_to_positions(self,stop_action,command,speed_sp,positions,tolerance): self._param['motor'].command = 'stop' def test_stop_brake_no_ramp_med_speed_relative(self): + if not self._param['has_brake']: + self.skipTest('brake not supported by this motor controller') self.initialize_motor() self.run_to_positions('brake','run-to-rel-pos',400,[0,90,180,360,720,-720,-360,-180,-90,0],20) @@ -56,6 +58,8 @@ def test_stop_hold_no_ramp_med_speed_relative(self): self.run_to_positions('hold','run-to-rel-pos',400,[0,90,180,360,720,-720,-360,-180,-90,0],5) def test_stop_brake_no_ramp_low_speed_relative(self): + if not self._param['has_brake']: + self.skipTest('brake not supported by this motor controller') self.initialize_motor() self.run_to_positions('brake','run-to-rel-pos',100,[0,90,180,360,720,-720,-360,-180,-90,0],20) @@ -64,6 +68,8 @@ def test_stop_hold_no_ramp_low_speed_relative(self): self.run_to_positions('hold','run-to-rel-pos',100,[0,90,180,360,720,-720,-360,-180,-90,0],5) def test_stop_brake_no_ramp_high_speed_relative(self): + if not self._param['has_brake']: + self.skipTest('brake not supported by this motor controller') self.initialize_motor() self.run_to_positions('brake','run-to-rel-pos',900,[0,90,180,360,720,-720,-360,-180,-90,0],50) @@ -72,6 +78,8 @@ def test_stop_hold_no_ramp_high_speed_relative(self): self.run_to_positions('hold','run-to-rel-pos',100,[0,90,180,360,720,-720,-360,-180,-90,0],5) def test_stop_brake_no_ramp_med_speed_absolute(self): + if not self._param['has_brake']: + self.skipTest('brake not supported by this motor controller') self.initialize_motor() self.run_to_positions('brake','run-to-abs-pos',400,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],20) @@ -80,6 +88,8 @@ def test_stop_hold_no_ramp_med_speed_absolute(self): self.run_to_positions('hold','run-to-abs-pos',400,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],5) def test_stop_brake_no_ramp_low_speed_absolute(self): + if not self._param['has_brake']: + self.skipTest('brake not supported by this motor controller') self.initialize_motor() self.run_to_positions('brake','run-to-abs-pos',100,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],20) @@ -88,6 +98,8 @@ def test_stop_hold_no_ramp_low_speed_absolute(self): self.run_to_positions('hold','run-to-abs-pos',100,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],5) def test_stop_brake_no_ramp_high_speed_absolute(self): + if not self._param['has_brake']: + self.skipTest('brake not supported by this motor controller') self.initialize_motor() self.run_to_positions('brake','run-to-abs-pos',900,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],50) @@ -97,14 +109,31 @@ def test_stop_hold_no_ramp_high_speed_absolute(self): # Add all the tests to the suite - some tests apply only to certain drivers! -def AddTachoMotorMotionTestsToSuite( suite, driver_name, params ): +def AddTachoMotorMotionTestsToSuite(suite, params): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestMotorMotion, param=params)) if __name__ == '__main__': - params = { 'motor': ev3.Motor('outA'), 'port': 'outA', 'driver_name': 'lego-ev3-l-motor' } + ev3_params = { + 'motor': ev3.Motor('outA'), + 'port': 'outA', + 'driver_name': 'lego-ev3-l-motor', + 'has_brake': True, + } + brickpi_params = { + 'motor': ev3.Motor('ttyAMA0:MA'), + 'port': 'ttyAMA0:MA', + 'driver_name': 'lego-nxt-motor', + 'has_brake': False, + } + pistorms_params = { + 'motor': ev3.Motor('pistorms:BAM1'), + 'port': 'pistorms:BAM1', + 'driver_name': 'lego-nxt-motor', + 'has_brake': True, + } suite = unittest.TestSuite() - AddTachoMotorMotionTestsToSuite( suite, 'lego-ev3-l-motor', params ) + AddTachoMotorMotionTestsToSuite(suite, ev3_params) unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) diff --git a/tests/motor/motor_param_unittest.py b/tests/motor/motor_param_unittest.py old mode 100755 new mode 100644 index 4c2da56..ad2c3b4 --- a/tests/motor/motor_param_unittest.py +++ b/tests/motor/motor_param_unittest.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # Based on the parameterized test case technique described here: # @@ -10,7 +10,6 @@ import ev3dev.ev3 as ev3 import parameterizedtestcase as ptc -import os from motor_info import motor_info @@ -26,13 +25,7 @@ def test_address_value_is_read_only(self): class TestTachoMotorCommandsValue(ptc.ParameterizedTestCase): def test_commands_value(self): - self.assertTrue(set(self._param['motor'].commands) == {'run-forever' - ,'run-to-abs-pos' - ,'run-to-rel-pos' - ,'run-timed' - ,'run-direct' - ,'stop' - ,'reset'}) + self.assertTrue(self._param['motor'].commands == self._param['commands']) def test_commands_value_is_read_only(self): with self.assertRaises(AttributeError): @@ -120,6 +113,7 @@ def test_duty_cycle_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].duty_cycle_sp, 0) + class TestTachoMotorMaxSpeedValue(ptc.ParameterizedTestCase): def test_max_speed_value(self): @@ -146,7 +140,12 @@ def test_position_p_positive(self): def test_position_p_after_reset(self): self._param['motor'].position_p = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].position_p, motor_info[self._param['motor'].driver_name]['position_p']) + + if self._param['hold_pid']: + expected = self._param['hold_pid']['kP'] + else: + expected = motor_info[self._param['motor'].driver_name]['position_p'] + self.assertEqual(self._param['motor'].position_p, expected) class TestTachoMotorPositionIValue(ptc.ParameterizedTestCase): @@ -165,7 +164,12 @@ def test_position_i_positive(self): def test_position_i_after_reset(self): self._param['motor'].position_i = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].position_i, motor_info[self._param['motor'].driver_name]['position_i']) + + if self._param['hold_pid']: + expected = self._param['hold_pid']['kI'] + else: + expected = motor_info[self._param['motor'].driver_name]['position_i'] + self.assertEqual(self._param['motor'].position_i, expected) class TestTachoMotorPositionDValue(ptc.ParameterizedTestCase): @@ -184,7 +188,12 @@ def test_position_d_positive(self): def test_position_d_after_reset(self): self._param['motor'].position_d = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].position_d, motor_info[self._param['motor'].driver_name]['position_d']) + + if self._param['hold_pid']: + expected = self._param['hold_pid']['kD'] + else: + expected = motor_info[self._param['motor'].driver_name]['position_d'] + self.assertEqual(self._param['motor'].position_d, expected) class TestTachoMotorPolarityValue(ptc.ParameterizedTestCase): @@ -201,12 +210,13 @@ def test_polarity_illegal_value(self): self._param['motor'].polarity = "ThisShouldNotWork" def test_polarity_after_reset(self): - if ('normal' == motor_info[self._param['motor'].driver_name]['polarity']): + if 'normal' == motor_info[self._param['motor'].driver_name]['polarity']: self._param['motor'].polarity = 'inversed' else: self._param['motor'].polarity = 'normal' self._param['motor'].command = 'reset' - if ('normal' == motor_info[self._param['motor'].driver_name]['polarity']): + + if 'normal' == motor_info[self._param['motor'].driver_name]['polarity']: self.assertEqual(self._param['motor'].polarity, 'normal') else: self.assertEqual(self._param['motor'].polarity, 'inversed') @@ -356,12 +366,12 @@ def test_speed_sp_min_positive(self): self.assertEqual(self._param['motor'].speed_sp, 1) def test_speed_sp_max_positive(self): - self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed'] + self._param['motor'].speed_sp = (motor_info[self._param['motor'].driver_name]['max_speed']) self.assertEqual(self._param['motor'].speed_sp, motor_info[self._param['motor'].driver_name]['max_speed']) def test_speed_sp_large_positive(self): with self.assertRaises(IOError): - self._param['motor'].speed_sp = (motor_info[self._param['motor'].driver_name]['max_speed']+1) + self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed'] + 1 def test_speed_sp_after_reset(self): self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed']/2 @@ -386,7 +396,12 @@ def test_speed_p_positive(self): def test_speed_p_after_reset(self): self._param['motor'].speed_p = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].speed_p, motor_info[self._param['motor'].driver_name]['speed_p']) + + if self._param['speed_pid']: + expected = self._param['speed_pid']['kP'] + else: + expected = motor_info[self._param['motor'].driver_name]['speed_p'] + self.assertEqual(self._param['motor'].speed_p, expected) class TestTachoMotorSpeedIValue(ptc.ParameterizedTestCase): @@ -405,7 +420,12 @@ def test_speed_i_positive(self): def test_speed_i_after_reset(self): self._param['motor'].speed_i = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].speed_i, motor_info[self._param['motor'].driver_name]['speed_i']) + + if self._param['speed_pid']: + expected = self._param['speed_pid']['kI'] + else: + expected = motor_info[self._param['motor'].driver_name]['speed_i'] + self.assertEqual(self._param['motor'].speed_i, expected) class TestTachoMotorSpeedDValue(ptc.ParameterizedTestCase): @@ -424,7 +444,12 @@ def test_speed_d_positive(self): def test_speed_d_after_reset(self): self._param['motor'].speed_d = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].speed_d, motor_info[self._param['motor'].driver_name]['speed_d']) + + if self._param['speed_pid']: + expected = self._param['speed_pid']['kD'] + else: + expected = motor_info[self._param['motor'].driver_name]['speed_d'] + self.assertEqual(self._param['motor'].speed_d, expected) class TestTachoMotorStateValue(ptc.ParameterizedTestCase): @@ -443,28 +468,42 @@ def test_stop_action_illegal(self): self._param['motor'].stop_action = 'ThisShouldNotWork' def test_stop_action_coast(self): - self._param['motor'].stop_action = 'coast' - self.assertEqual(self._param['motor'].stop_action, 'coast') + if 'coast' in self._param['stop_actions']: + self._param['motor'].stop_action = 'coast' + self.assertEqual(self._param['motor'].stop_action, 'coast') + else: + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'coast' def test_stop_action_brake(self): - self._param['motor'].stop_action = 'brake' - self.assertEqual(self._param['motor'].stop_action, 'brake') + if 'brake' in self._param['stop_actions']: + self._param['motor'].stop_action = 'brake' + self.assertEqual(self._param['motor'].stop_action, 'brake') + else: + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'brake' def test_stop_action_hold(self): - self._param['motor'].stop_action = 'hold' - self.assertEqual(self._param['motor'].stop_action, 'hold') + if 'hold' in self._param['stop_actions']: + self._param['motor'].stop_action = 'hold' + self.assertEqual(self._param['motor'].stop_action, 'hold') + else: + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'hold' def test_stop_action_after_reset(self): - self._param['motor'].stop_action = 'hold' - self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].stop_action, 'coast') + action = 1 + # controller may only support one stop action + if len(self._param['stop_actions']) < 2: + action = 0 + self._param['motor'].stop_action = self._param['stop_actions'][action] + self._param['motor'].action = 'reset' + self.assertEqual(self._param['motor'].stop_action, self._param['stop_actions'][0]) class TestTachoMotorStopActionsValue(ptc.ParameterizedTestCase): def test_stop_actions_value(self): - self.assertTrue(set(self._param['motor'].stop_actions) == {'coast' - ,'brake' - ,'hold'}) + self.assertTrue(self._param['motor'].stop_actions == self._param['stop_actions']) def test_stop_actions_value_is_read_only(self): with self.assertRaises(AttributeError): @@ -491,80 +530,69 @@ def test_time_sp_large_positive(self): def test_time_sp_after_reset(self): self._param['motor'].time_sp = 1 self._param['motor'].command = 'reset' - self.assertEqual(self._param['motor'].speed_d, 0) - -class TestTachoMotorDummy(ptc.ParameterizedTestCase): - - def test_dummy_no_message(self): - - try: - self.assertEqual(self._param['motor'].speed_d, 100, "Some clever error message {0}".format(self._param['motor'].speed_d)) - except Exception: - # Remove traceback info as we don't need it - unittest_exception = sys.exc_info() - print("%s,%s,%s" % (unittest_exception[0], unittest_exception[1], unittest_exception[2].tb_next)) - -# Add all the tests to the suite - some tests apply only to certain drivers! - -def AddTachoMotorParameterTestsToSuite( suite, driver_name, params ): - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorAddressValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCommandsValue, param=params)) - if( motor_info[driver_name]['motion_type'] == 'rotation' ): - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCountPerRotValue, param=params)) - if( motor_info[driver_name]['motion_type'] == 'linear' ): - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCountPerMValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorFullTravelCountValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDriverNameValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDutyCycleSpValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorMaxSpeedValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionPValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionIValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionDValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPolarityValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionSpValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorRampDownSpValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorRampUpSpValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedSpValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedPValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedIValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedDValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStateValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopActionValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopActionsValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorTimeSpValue, param=params)) - suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDummy, param=params)) - -if __name__ == '__main__': - port_for_outA = None - - # which port has outA? - for filename in os.listdir('/sys/class/lego-port/'): - address_filename = '/sys/class/lego-port/%s/address' % filename - - if os.path.exists(address_filename): - with open(address_filename, 'r') as fh: - for line in fh: - if line.rstrip().endswith(':outA'): - port_for_outA = filename - break - - if port_for_outA: - break - else: - raise Exception("Could not find port /sys/class/lego-port/ for outA") - - for k in motor_info: - file = open('/sys/class/lego-port/%s/set_device' % port_for_outA, 'w') - file.write('{0}\n'.format(k)) - file.close() - time.sleep(0.5) + self.assertEqual(self._param['motor'].time_sp, 0) - params = { 'motor': ev3.Motor('outA'), 'port': 'outA', 'driver_name': k } +ev3_params = { + 'motor': ev3.Motor('outA'), + 'port': 'outA', + 'driver_name': 'lego-ev3-l-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], + 'stop_actions': ['coast', 'brake', 'hold'], +} +evb_params = { + 'motor': ev3.Motor('evb-ports:outA'), + 'port': 'evb-ports:outA', + 'driver_name': 'lego-ev3-l-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], + 'stop_actions': ['coast', 'brake', 'hold'], +} +brickpi_params = { + 'motor': ev3.Motor('ttyAMA0:MA'), + 'port': 'ttyAMA0:MA', + 'driver_name': 'lego-nxt-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], + 'stop_actions': ['coast', 'hold'], + 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, + 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, +} +pistorms_params = { + 'motor': ev3.Motor('pistorms:BAM1'), + 'port': 'pistorms:BAM1', + 'driver_name': 'lego-nxt-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'stop', 'reset'], + 'stop_actions': ['coast', 'brake', 'hold'], + 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, + 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, +} +paramsA = pistorms_params +paramsA['motor'].command = 'reset' + +suite = unittest.TestSuite() + +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorAddressValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCommandsValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCountPerRotValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDriverNameValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDutyCycleSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorMaxSpeedValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionPValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionIValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionDValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPolarityValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorRampDownSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorRampUpSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedPValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedIValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedDValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStateValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandsValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorTimeSpValue, param=paramsA)) - suite = unittest.TestSuite() - AddTachoMotorParameterTestsToSuite( suite, k, params ) - print( '-------------------- TESTING {0} --------------'.format(k)) - unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) +if __name__ == '__main__': + unittest.main(verbosity=2,buffer=True ).run(suite) diff --git a/tests/motor/motor_ramps.json b/tests/motor/motor_ramps.json index aaac717..3b2482d 100644 --- a/tests/motor/motor_ramps.json +++ b/tests/motor/motor_ramps.json @@ -2,7 +2,7 @@ "meta": { "title": "Test Large EV3 Motor Operation", "subtitle": "Positive and Negative Ramps", - "notes": "ramp_up_sp:1000\nramp_down_sp:2000\ntime_sp: 2000\nspeed_sp: +/- 900\nstop_command: coast", + "notes": "ramp_up_sp:1000\nramp_down_sp:2000\ntime_sp: 2000\nspeed_sp: +/- 900\nstop_action: coast", "interval": 10, "name": "run-direct-test", "max_time": 8000, diff --git a/tests/motor/motor_ramps.log.json b/tests/motor/motor_ramps.log.json index b921fcd..1684864 100644 --- a/tests/motor/motor_ramps.log.json +++ b/tests/motor/motor_ramps.log.json @@ -3,7 +3,7 @@ "subtitle": "Positive and Negative Ramps", "name": "run-direct-test", "title": "Test Large EV3 Motor Operation", - "notes": "ramp_up_sp:1000\nramp_down_sp:2000\ntime_sp: 2000\nspeed_sp: +/- 900\nstop_command: coast", + "notes": "ramp_up_sp:1000\nramp_down_sp:2000\ntime_sp: 2000\nspeed_sp: +/- 900\nstop_action: coast", "interval": 10, "max_time": 8000, "ports": { diff --git a/tests/motor/motor_run_direct_unittest.py b/tests/motor/motor_run_direct_unittest.py index b3347f9..c0d63de 100755 --- a/tests/motor/motor_run_direct_unittest.py +++ b/tests/motor/motor_run_direct_unittest.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # Based on the parameterized test case technique described here: # diff --git a/tests/motor/motor_unittest.py b/tests/motor/motor_unittest.py new file mode 100644 index 0000000..5933ed3 --- /dev/null +++ b/tests/motor/motor_unittest.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python + +# Based on the parameterized test case technique described here: +# +# http://eli.thegreenplace.net/2011/08/02/python-unit-testing-parametrized-test-cases +import unittest +import time +import ev3dev.ev3 as ev3 + +import parameterizedtestcase as ptc +from motor_info import motor_info + +class TestTachoMotorAddressValue(ptc.ParameterizedTestCase): + + def test_address_value(self): + # Use the class variable + self.assertEqual(self._param['motor'].address, self._param['port']) + + def test_address_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].address = "ThisShouldNotWork" + +class TestTachoMotorCommandsValue(ptc.ParameterizedTestCase): + + def test_commands_value(self): + self.assertTrue(self._param['motor'].commands == self._param['commands']) + + def test_commands_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].commands = "ThisShouldNotWork" + +class TestTachoMotorCountPerRotValue(ptc.ParameterizedTestCase): + + def test_count_per_rot_value(self): + # This is not available for linear motors - move to driver specific tests? + self.assertEqual(self._param['motor'].count_per_rot, 360) + + def test_count_per_rot_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].count_per_rot = "ThisShouldNotWork" + +class TestTachoMotorDriverNameValue(ptc.ParameterizedTestCase): + + def test_driver_name_value(self): + # move to driver specific tests? + self.assertEqual(self._param['motor'].driver_name, self._param['driver_name']) + + def test_driver_name_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].driver_name = "ThisShouldNotWork" + +class TestTachoMotorDutyCycleValue(ptc.ParameterizedTestCase): + + def test_duty_cycle_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].duty_cycle = "ThisShouldNotWork" + + def test_duty_cycle_value_after_reset(self): + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].duty_cycle, 0) + +class TestTachoMotorDutyCycleSpValue(ptc.ParameterizedTestCase): + + def test_duty_cycle_sp_large_negative(self): + with self.assertRaises(IOError): + self._param['motor'].duty_cycle_sp = -101 + + def test_duty_cycle_sp_max_negative(self): + self._param['motor'].duty_cycle_sp = -100 + self.assertEqual(self._param['motor'].duty_cycle_sp, -100) + + def test_duty_cycle_sp_min_negative(self): + self._param['motor'].duty_cycle_sp = -1 + self.assertEqual(self._param['motor'].duty_cycle_sp, -1) + + def test_duty_cycle_sp_zero(self): + self._param['motor'].duty_cycle_sp = 0 + self.assertEqual(self._param['motor'].duty_cycle_sp, 0) + + def test_duty_cycle_sp_min_positive(self): + self._param['motor'].duty_cycle_sp = 1 + self.assertEqual(self._param['motor'].duty_cycle_sp, 1) + + def test_duty_cycle_sp_max_positive(self): + self._param['motor'].duty_cycle_sp = 100 + self.assertEqual(self._param['motor'].duty_cycle_sp, 100) + + def test_duty_cycle_sp_large_positive(self): + with self.assertRaises(IOError): + self._param['motor'].duty_cycle_sp = 101 + + def test_duty_cycle_sp_after_reset(self): + self._param['motor'].duty_cycle_sp = 100 + self.assertEqual(self._param['motor'].duty_cycle_sp, 100) + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].duty_cycle_sp, 0) + + +class TestTachoMotorMaxSpeedValue(ptc.ParameterizedTestCase): + + def test_max_speed_value(self): + # This is not available for linear motors - move to driver specific tests? + self.assertEqual(self._param['motor'].max_speed, motor_info[self._param['motor'].driver_name]['max_speed']) + + def test_max_speed_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].max_speed = "ThisShouldNotWork" + +class TestTachoMotorPositionPValue(ptc.ParameterizedTestCase): + + def test_position_p_negative(self): + with self.assertRaises(IOError): + self._param['motor'].position_p = -1 + + def test_position_p_zero(self): + self._param['motor'].position_p = 0 + self.assertEqual(self._param['motor'].position_p, 0) + + def test_position_p_positive(self): + self._param['motor'].position_p = 1 + self.assertEqual(self._param['motor'].position_p, 1) + + def test_position_p_after_reset(self): + self._param['motor'].position_p = 1 + + self._param['motor'].command = 'reset' + + if 'hold_pid' in self._param: + expected = self._param['hold_pid']['kP'] + else: + expected = motor_info[self._param['motor'].driver_name]['position_p'] + self.assertEqual(self._param['motor'].position_p, expected) + +class TestTachoMotorPositionIValue(ptc.ParameterizedTestCase): + + def test_position_i_negative(self): + with self.assertRaises(IOError): + self._param['motor'].position_i = -1 + + def test_position_i_zero(self): + self._param['motor'].position_i = 0 + self.assertEqual(self._param['motor'].position_i, 0) + + def test_position_i_positive(self): + self._param['motor'].position_i = 1 + self.assertEqual(self._param['motor'].position_i, 1) + + def test_position_i_after_reset(self): + self._param['motor'].position_i = 1 + + self._param['motor'].command = 'reset' + + if 'hold_pid' in self._param: + expected = self._param['hold_pid']['kI'] + else: + expected = motor_info[self._param['motor'].driver_name]['position_i'] + self.assertEqual(self._param['motor'].position_i, expected) + +class TestTachoMotorPositionDValue(ptc.ParameterizedTestCase): + + def test_position_d_negative(self): + with self.assertRaises(IOError): + self._param['motor'].position_d = -1 + + def test_position_d_zero(self): + self._param['motor'].position_d = 0 + self.assertEqual(self._param['motor'].position_d, 0) + + def test_position_d_positive(self): + self._param['motor'].position_d = 1 + self.assertEqual(self._param['motor'].position_d, 1) + + def test_position_d_after_reset(self): + self._param['motor'].position_d = 1 + + self._param['motor'].command = 'reset' + + if 'hold_pid' in self._param: + expected = self._param['hold_pid']['kD'] + else: + expected = motor_info[self._param['motor'].driver_name]['position_d'] + self.assertEqual(self._param['motor'].position_d, expected) + +class TestTachoMotorPolarityValue(ptc.ParameterizedTestCase): + + def test_polarity_normal_value(self): + self._param['motor'].polarity = 'normal' + self.assertEqual(self._param['motor'].polarity, 'normal') + + def test_polarity_inversed_value(self): + self._param['motor'].polarity = 'inversed' + self.assertEqual(self._param['motor'].polarity, 'inversed') + + def test_polarity_illegal_value(self): + with self.assertRaises(IOError): + self._param['motor'].polarity = "ThisShouldNotWork" + + def test_polarity_after_reset(self): + if 'normal' == motor_info[self._param['motor'].driver_name]['polarity']: + self._param['motor'].polarity = 'inversed' + else: + self._param['motor'].polarity = 'normal' + + self._param['motor'].command = 'reset' + + if 'normal' == motor_info[self._param['motor'].driver_name]['polarity']: + self.assertEqual(self._param['motor'].polarity, 'normal') + else: + self.assertEqual(self._param['motor'].polarity, 'inversed') + +class TestTachoMotorPositionValue(ptc.ParameterizedTestCase): + + def test_position_large_negative(self): + self._param['motor'].position = -1000000 + self.assertEqual(self._param['motor'].position, -1000000) + + def test_position_min_negative(self): + self._param['motor'].position = -1 + self.assertEqual(self._param['motor'].position, -1) + + def test_position_zero(self): + self._param['motor'].position = 0 + self.assertEqual(self._param['motor'].position, 0) + + def test_position_min_positive(self): + self._param['motor'].position = 1 + self.assertEqual(self._param['motor'].position, 1) + + def test_position_large_positive(self): + self._param['motor'].position = 1000000 + self.assertEqual(self._param['motor'].position, 1000000) + + def test_position_after_reset(self): + self._param['motor'].position = 100 + self.assertEqual(self._param['motor'].position, 100) + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].position, 0) + + +class TestTachoMotorPositionSpValue(ptc.ParameterizedTestCase): + + def test_position_sp_large_negative(self): + self._param['motor'].position_sp = -1000000 + self.assertEqual(self._param['motor'].position_sp, -1000000) + + def test_position_sp_min_negative(self): + self._param['motor'].position_sp = -1 + self.assertEqual(self._param['motor'].position_sp, -1) + + def test_position_sp_zero(self): + self._param['motor'].position_sp = 0 + self.assertEqual(self._param['motor'].position_sp, 0) + + def test_position_sp_min_positive(self): + self._param['motor'].position_sp = 1 + self.assertEqual(self._param['motor'].position_sp, 1) + + def test_position_sp_large_positive(self): + self._param['motor'].position_sp = 1000000 + self.assertEqual(self._param['motor'].position_sp, 1000000) + + def test_position_sp_after_reset(self): + self._param['motor'].position_sp = 100 + self.assertEqual(self._param['motor'].position_sp, 100) + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].position_sp, 0) + +class TestTachoMotorRampDownSpValue(ptc.ParameterizedTestCase): + + def test_ramp_down_sp_negative_value(self): + with self.assertRaises(IOError): + self._param['motor'].ramp_down_sp = -1 + + def test_ramp_down_sp_zero(self): + self._param['motor'].ramp_down_sp = 0 + self.assertEqual(self._param['motor'].ramp_down_sp, 0) + + def test_ramp_down_sp_min_positive(self): + self._param['motor'].ramp_down_sp = 1 + self.assertEqual(self._param['motor'].ramp_down_sp, 1) + + def test_ramp_down_sp_max_positive(self): + self._param['motor'].ramp_down_sp = 60000 + self.assertEqual(self._param['motor'].ramp_down_sp, 60000) + + def test_ramp_down_sp_large_positive(self): + with self.assertRaises(IOError): + self._param['motor'].ramp_down_sp = 60001 + + def test_ramp_down_sp_after_reset(self): + self._param['motor'].ramp_down_sp = 100 + self.assertEqual(self._param['motor'].ramp_down_sp, 100) + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].ramp_down_sp, 0) + +class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): + + def test_ramp_up_negative_value(self): + with self.assertRaises(IOError): + self._param['motor'].ramp_up_sp = -1 + + def test_ramp_up_sp_zero(self): + self._param['motor'].ramp_up_sp = 0 + self.assertEqual(self._param['motor'].ramp_up_sp, 0) + + def test_ramp_up_sp_min_positive(self): + self._param['motor'].ramp_up_sp = 1 + self.assertEqual(self._param['motor'].ramp_up_sp, 1) + + def test_ramp_up_sp_max_positive(self): + self._param['motor'].ramp_up_sp = 60000 + self.assertEqual(self._param['motor'].ramp_up_sp, 60000) + + def test_ramp_up_sp_large_positive(self): + with self.assertRaises(IOError): + self._param['motor'].ramp_up_sp = 60001 + + def test_ramp_up_sp_after_reset(self): + self._param['motor'].ramp_up_sp = 100 + self.assertEqual(self._param['motor'].ramp_up_sp, 100) + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].ramp_up_sp, 0) + +class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): + + def test_speed_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].speed = 1 + + def test_speed_value_after_reset(self): + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].speed, 0) + +class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): + + def test_speed_sp_large_negative(self): + with self.assertRaises(IOError): + self._param['motor'].speed_sp = -(motor_info[self._param['motor'].driver_name]['max_speed']+1) + + def test_speed_sp_max_negative(self): + self._param['motor'].speed_sp = -motor_info[self._param['motor'].driver_name]['max_speed'] + self.assertEqual(self._param['motor'].speed_sp, -motor_info[self._param['motor'].driver_name]['max_speed']) + + def test_speed_sp_min_negative(self): + self._param['motor'].speed_sp = -1 + self.assertEqual(self._param['motor'].speed_sp, -1) + + def test_speed_sp_zero(self): + self._param['motor'].speed_sp = 0 + self.assertEqual(self._param['motor'].speed_sp, 0) + + def test_speed_sp_min_positive(self): + self._param['motor'].speed_sp = 1 + self.assertEqual(self._param['motor'].speed_sp, 1) + + def test_speed_sp_max_positive(self): + self._param['motor'].speed_sp = (motor_info[self._param['motor'].driver_name]['max_speed']) + self.assertEqual(self._param['motor'].speed_sp, motor_info[self._param['motor'].driver_name]['max_speed']) + + def test_speed_sp_large_positive(self): + with self.assertRaises(IOError): + self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed'] + 1 + + def test_speed_sp_after_reset(self): + self._param['motor'].speed_sp = 100 + self.assertEqual(self._param['motor'].speed_sp, 100) + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].speed_sp, 0) + +class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): + + def test_speed_i_negative(self): + with self.assertRaises(IOError): + self._param['motor'].speed_p = -1 + + def test_speed_p_zero(self): + self._param['motor'].speed_p = 0 + self.assertEqual(self._param['motor'].speed_p, 0) + + def test_speed_p_positive(self): + self._param['motor'].speed_p = 1 + self.assertEqual(self._param['motor'].speed_p, 1) + + def test_speed_p_after_reset(self): + self._param['motor'].speed_p = 1 + + self._param['motor'].command = 'reset' + + if 'speed_pid' in self._param: + expected = self._param['speed_pid']['kP'] + else: + expected = motor_info[self._param['motor'].driver_name]['speed_p'] + self.assertEqual(self._param['motor'].speed_p, expected) + +class TestTachoMotorSpeedIValue(ptc.ParameterizedTestCase): + + def test_speed_i_negative(self): + with self.assertRaises(IOError): + self._param['motor'].speed_i = -1 + + def test_speed_i_zero(self): + self._param['motor'].speed_i = 0 + self.assertEqual(self._param['motor'].speed_i, 0) + + def test_speed_i_positive(self): + self._param['motor'].speed_i = 1 + self.assertEqual(self._param['motor'].speed_i, 1) + + def test_speed_i_after_reset(self): + self._param['motor'].speed_i = 1 + + self._param['motor'].command = 'reset' + + if 'speed_pid' in self._param: + expected = self._param['speed_pid']['kI'] + else: + expected = motor_info[self._param['motor'].driver_name]['speed_i'] + self.assertEqual(self._param['motor'].speed_i, expected) + +class TestTachoMotorSpeedDValue(ptc.ParameterizedTestCase): + + def test_speed_d_negative(self): + with self.assertRaises(IOError): + self._param['motor'].speed_d = -1 + + def test_speed_d_zero(self): + self._param['motor'].speed_d = 0 + self.assertEqual(self._param['motor'].speed_d, 0) + + def test_speed_d_positive(self): + self._param['motor'].speed_d = 1 + self.assertEqual(self._param['motor'].speed_d, 1) + + def test_speed_d_after_reset(self): + self._param['motor'].speed_d = 1 + + self._param['motor'].command = 'reset' + + if 'speed_pid' in self._param: + expected = self._param['speed_pid']['kD'] + else: + expected = motor_info[self._param['motor'].driver_name]['speed_d'] + self.assertEqual(self._param['motor'].speed_d, expected) + +class TestTachoMotorStateValue(ptc.ParameterizedTestCase): + + def test_state_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].state = 'ThisShouldNotWork' + + def test_state_value_after_reset(self): + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].state, []) + +# def test_stop_action_value(self): +# self.assertEqual(self._param['motor'].stop_action, 'coast') +class TestTachoMotorStopCommandValue(ptc.ParameterizedTestCase): + + def test_stop_action_illegal(self): + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'ThisShouldNotWork' + + def test_stop_action_coast(self): + if 'coast' in self._param['stop_actions']: + self._param['motor'].stop_action = 'coast' + self.assertEqual(self._param['motor'].stop_action, 'coast') + else: + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'coast' + + def test_stop_action_brake(self): + if 'brake' in self._param['stop_actions']: + self._param['motor'].stop_action = 'brake' + self.assertEqual(self._param['motor'].stop_action, 'brake') + else: + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'brake' + + def test_stop_action_hold(self): + if 'hold' in self._param['stop_actions']: + self._param['motor'].stop_action = 'hold' + self.assertEqual(self._param['motor'].stop_action, 'hold') + else: + with self.assertRaises(IOError): + self._param['motor'].stop_action = 'hold' + + def test_stop_action_after_reset(self): + action = 1 + # controller may only support one stop action + if len(self._param['stop_actions']) < 2: + action = 0 + self._param['motor'].stop_action = self._param['stop_actions'][action] + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].stop_action, self._param['stop_actions'][0]) + +class TestTachoMotorStopCommandsValue(ptc.ParameterizedTestCase): + + def test_stop_actions_value(self): + self.assertTrue(self._param['motor'].stop_actions == self._param['stop_actions']) + + def test_stop_actions_value_is_read_only(self): + # Use the class variable + with self.assertRaises(AttributeError): + self._param['motor'].stop_actions = "ThisShouldNotWork" + +class TestTachoMotorTimeSpValue(ptc.ParameterizedTestCase): + + def test_time_sp_negative(self): + with self.assertRaises(IOError): + self._param['motor'].time_sp = -1 + + def test_time_sp_zero(self): + self._param['motor'].time_sp = 0 + self.assertEqual(self._param['motor'].time_sp, 0) + + def test_time_sp_min_positive(self): + self._param['motor'].time_sp = 1 + self.assertEqual(self._param['motor'].time_sp, 1) + + def test_time_sp_large_positive(self): + self._param['motor'].time_sp = 1000000 + self.assertEqual(self._param['motor'].time_sp, 1000000) + + def test_time_sp_after_reset(self): + self._param['motor'].time_sp = 1 + self._param['motor'].command = 'reset' + self.assertEqual(self._param['motor'].time_sp, 0) + +ev3_params = { + 'motor': ev3.Motor('outA'), + 'port': 'outA', + 'driver_name': 'lego-ev3-l-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], + 'stop_actions': ['coast', 'brake', 'hold'], +} +evb_params = { + 'motor': ev3.Motor('evb-ports:outA'), + 'port': 'evb-ports:outA', + 'driver_name': 'lego-ev3-l-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], + 'stop_actions': ['coast', 'brake', 'hold'], +} +brickpi_params = { + 'motor': ev3.Motor('ttyAMA0:MA'), + 'port': 'ttyAMA0:MA', + 'driver_name': 'lego-nxt-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], + 'stop_actions': ['coast', 'hold'], + 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, + 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, +} +pistorms_params = { + 'motor': ev3.Motor('pistorms:BAM1'), + 'port': 'pistorms:BAM1', + 'driver_name': 'lego-nxt-motor', + 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'stop', 'reset'], + 'stop_actions': ['coast', 'brake', 'hold'], + 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, + 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, +} +paramsA = ev3_params +paramsA['motor'].command = 'reset' + +suite = unittest.TestSuite() + +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorAddressValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCommandsValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorCountPerRotValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDriverNameValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorDutyCycleSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorMaxSpeedValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionPValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionIValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionDValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPolarityValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorPositionSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorRampDownSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorRampUpSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedSpValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedPValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedIValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedDValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStateValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandsValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorTimeSpValue, param=paramsA)) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) + +exit() + +# Move these up later + +class TestMotorRelativePosition(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls._motor = ev3.Motor('outA') + cls._motor.speed_sp = 400 + cls._motor.ramp_up_sp = 300 + cls._motor.ramp_down_sp = 300 + cls._motor.position = 0 + cls._motor.position_sp = 180 + pass + + @classmethod + def tearDownClass(cls): + pass + + @unittest.skip("Skipping coast mode - always fails") + def test_stop_coast(self): + self._motor.stop_action = 'coast' + self._motor.command = 'run-to-rel-pos' + time.sleep(1) + self.assertGreaterEqual(1, abs(self._motor.position - self._motor.position_sp)) + + def test_stop_brake(self): + self._motor.stop_action = 'brake' + self._motor.position = 0 + + for i in range(1,5): + self._motor.command = 'run-to-rel-pos' + time.sleep(1) + print(self._motor.position) + self.assertGreaterEqual(8, abs(self._motor.position - (i * self._motor.position_sp))) + + def test_stop_hold(self): + self._motor.stop_action = 'hold' + self._motor.position = 0 + + for i in range(1,5): + self._motor.command = 'run-to-rel-pos' + time.sleep(1) + print(self._motor.position) + self.assertGreaterEqual(1, abs(self._motor.position - (i * self._motor.position_sp))) + + +if __name__ == '__main__': + unittest.main(verbosity=2,buffer=True ).run(suite) From 6f8f6af3769cb7a421dcec7c90959b240eb7b6ef Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Fri, 22 Sep 2017 08:11:02 -0400 Subject: [PATCH 010/172] EV3-G API: motors (#360) * EV3-G API: motors * EV3-G API: motors - fix things from code review * Make MoveSteering a child class of MoveTank * rename power to speed, is a % of max_speed, must be between -100 and 100 * Remove _set_position_rotations call in _set_position_degrees * rename max_speed_pct to speed_pct * Comment out logging in core.py --- ev3dev/core.py | 433 ++++++++++++++++++++++++++++++++++++++++++++++- ev3dev/helper.py | 247 --------------------------- 2 files changed, 428 insertions(+), 252 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index c2a1205..4b87680 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -33,7 +33,6 @@ import fnmatch import numbers import array -import logging import mmap import ctypes import re @@ -46,8 +45,6 @@ from struct import pack, unpack from subprocess import Popen, check_output, PIPE -log = logging.getLogger(__name__) - try: # This is a linux-specific module. # It is required by the Button() class, but failure to import it may be @@ -59,6 +56,10 @@ INPUT_AUTO = '' OUTPUT_AUTO = '' +# The number of milliseconds we wait for the state of a motor to +# update to 'running' in the "on_for_XYZ" methods of the Motor class +WAIT_RUNNING_TIMEOUT = 100 + # ----------------------------------------------------------------------------- def list_device_names(class_path, name_pattern, **kwargs): """ @@ -190,7 +191,7 @@ def _get_attribute(self, attribute, name): attribute.seek(0) return attribute, attribute.read().strip().decode() else: - log.info("%s: path %s, attribute %s" % (self, self._path, name)) + #log.info("%s: path %s, attribute %s" % (self, self._path, name)) raise Exception("%s is not connected" % self) def _set_attribute(self, attribute, name, value): @@ -208,7 +209,7 @@ def _set_attribute(self, attribute, name, value): self._raise_friendly_access_error(ex, name) return attribute else: - log.info("%s: path %s, attribute %s" % (self, self._path, name)) + #log.info("%s: path %s, attribute %s" % (self, self._path, name)) raise Exception("%s is not connected" % self) def _raise_friendly_access_error(self, driver_error, attribute): @@ -946,6 +947,91 @@ def wait_while(self, s, timeout=None): """ return self.wait(lambda state: s not in state, timeout) + def _set_position_rotations(self, speed_pct, rotations): + if speed_pct > 0: + self.position_sp = self.position + int(rotations * self.count_per_rot) + else: + self.position_sp = self.position - int(rotations * self.count_per_rot) + + def _set_position_degrees(self, speed_pct, degrees): + if speed_pct > 0: + self.position_sp = self.position + int((degrees * self.count_per_rot)/360) + else: + self.position_sp = self.position - int((degrees * self.count_per_rot)/360) + + def _set_brake(self, brake): + if brake: + self.stop_action = self.STOP_ACTION_HOLD + else: + self.stop_action = self.STOP_ACTION_COAST + + def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): + """ + Rotate the motor at 'speed' for 'rotations' + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self._set_position_rotations(speed_pct, rotations) + self._set_brake(brake) + self.run_to_abs_pos() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + + def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): + """ + Rotate the motor at 'speed' for 'degrees' + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self._set_position_degrees(speed_pct, degrees) + self._set_brake(brake) + self.run_to_abs_pos() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + + def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): + """ + Rotate the motor at 'speed' for 'seconds' + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.time_sp = int(seconds * 1000) + self._set_brake(brake) + self.run_timed() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + + def on(self, speed_pct): + """ + Rotate the motor at 'speed' for forever + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.run_forever() + + def off(self, brake=True): + self._set_brake(brake) + self.stop() + + @property + def rotations(self): + return float(self.position / self.count_per_rot) + + @property + def degrees(self): + return self.rotations * 360 + + def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): """ This is a generator function that enumerates all tacho motors that match @@ -1485,6 +1571,343 @@ def float(self, **kwargs): self.command = self.COMMAND_FLOAT +class MotorSet(object): + + def __init__(self, motor_specs, desc=None): + """ + motor_specs is a dictionary such as + { + OUTPUT_A : LargeMotor, + OUTPUT_C : LargeMotor, + } + """ + self.motors = {} + for motor_port in sorted(motor_specs.keys()): + motor_class = motor_specs[motor_port] + self.motors[motor_port] = motor_class(motor_port) + + self.desc = desc + self.verify_connected() + + def __str__(self): + + if self.desc: + return self.desc + else: + return self.__class__.__name__ + + def verify_connected(self): + for motor in self.motors.values(): + if not motor.connected: + #log.error("%s: %s is not connected" % (self, motor)) + sys.exit(1) + + def set_args(self, **kwargs): + motors = kwargs.get('motors', self.motors.values()) + + for motor in motors: + for key in kwargs: + if key != 'motors': + try: + setattr(motor, key, kwargs[key]) + except AttributeError as e: + #log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) + raise e + + def set_polarity(self, polarity, motors=None): + valid_choices = (LargeMotor.POLARITY_NORMAL, LargeMotor.POLARITY_INVERSED) + + assert polarity in valid_choices,\ + "%s is an invalid polarity choice, must be %s" % (polarity, ', '.join(valid_choices)) + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.polarity = polarity + + def _run_command(self, **kwargs): + motors = kwargs.get('motors', self.motors.values()) + + for motor in motors: + for key in kwargs: + if key not in ('motors', 'commands'): + #log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) + setattr(motor, key, kwargs[key]) + + for motor in motors: + motor.command = kwargs['command'] + #log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) + + def run_forever(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_FOREVER + self._run_command(**kwargs) + + def run_to_abs_pos(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_TO_ABS_POS + self._run_command(**kwargs) + + def run_to_rel_pos(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_TO_REL_POS + self._run_command(**kwargs) + + def run_timed(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_TIMED + self._run_command(**kwargs) + + def run_direct(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_DIRECT + self._run_command(**kwargs) + + def reset(self, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.reset() + + def stop(self, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.stop() + + def _is_state(self, motors, state): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + if state not in motor.state: + return False + + return True + + @property + def is_running(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_RUNNING) + + @property + def is_ramping(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_RAMPING) + + @property + def is_holding(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_HOLDING) + + @property + def is_overloaded(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_OVERLOADED) + + @property + def is_stalled(self): + return self._is_state(motors, LargeMotor.STATE_STALLED) + + def wait(self, cond, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait(cond, timeout) + + def wait_until_not_moving(self, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_until_not_moving(timeout) + + def wait_until(self, s, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_until(s, timeout) + + def wait_while(self, s, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_while(s, timeout) + + +class MoveTank(MotorSet): + + def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=LargeMotor): + motor_specs = { + left_motor_port : motor_class, + right_motor_port : motor_class, + } + + MotorSet.__init__(self, motor_specs, desc) + self.left_motor = self.motors[left_motor_port] + self.right_motor = self.motors[right_motor_port] + self.max_speed = self.left_motor.max_speed + + def _block(self): + self.left_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.right_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.left_motor.wait_until_not_moving() + self.right_motor.wait_until_not_moving() + + def _validate_speed_pct(self, left_speed_pct, right_speed_pct): + assert left_speed_pct >= -100 and left_speed_pct <= 100,\ + "%s is an invalid left_speed_pct, must be between -100 and 100 (inclusive)" % left_speed_pct + assert right_speed_pct >= -100 and right_speed_pct <= 100,\ + "%s is an invalid right_speed_pct, must be between -100 and 100 (inclusive)" % right_speed_pct + assert left_speed_pct or right_speed_pct,\ + "Either left_speed_pct or right_speed_pct must be non-zero" + + def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=True, block=True): + """ + Rotate the motor at 'left_speed & right_speed' for 'rotations' + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + left_speed = int((left_speed_pct * self.max_speed) / 100) + right_speed = int((right_speed_pct * self.max_speed) / 100) + + if left_speed > right_speed: + left_rotations = rotations + right_rotations = float(right_speed / left_speed) * rotations + else: + left_rotations = float(left_speed / right_speed) * rotations + right_rotations = rotations + + # Set all parameters + self.left_motor.speed_sp = left_speed + self.left_motor._set_position_rotations(left_speed, left_rotations) + self.left_motor._set_brake(brake) + self.right_motor.speed_sp = right_speed + self.right_motor._set_position_rotations(right_speed, right_rotations) + self.right_motor._set_brake(brake) + + # Start the motors + self.left_motor.run_to_abs_pos() + self.right_motor.run_to_abs_pos() + + if block: + self._block() + + def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, block=True): + """ + Rotate the motor at 'left_speed & right_speed' for 'degrees' + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + left_speed = int((left_speed_pct * self.max_speed) / 100) + right_speed = int((right_speed_pct * self.max_speed) / 100) + + if left_speed > right_speed: + left_degrees = degrees + right_degrees = float(right_speed / left_speed) * degrees + else: + left_degrees = float(left_speed / right_speed) * degrees + right_degrees = degrees + + # Set all parameters + self.left_motor.speed_sp = left_speed + self.left_motor._set_position_degrees(left_speed, left_degrees) + self.left_motor._set_brake(brake) + self.right_motor.speed_sp = right_speed + self.right_motor._set_position_degrees(right_speed, right_degrees) + self.right_motor._set_brake(brake) + + # Start the motors + self.left_motor.run_to_abs_pos() + self.right_motor.run_to_abs_pos() + + if block: + self._block() + + def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, block=True): + """ + Rotate the motor at 'left_speed & right_speed' for 'seconds' + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + + # Set all parameters + self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) + self.left_motor.time_sp = int(seconds * 1000) + self.left_motor._set_brake(brake) + self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) + self.right_motor.time_sp = int(seconds * 1000) + self.right_motor._set_brake(brake) + + # Start the motors + self.left_motor.run_timed() + self.right_motor.run_timed() + + if block: + self._block() + + def on(self, left_speed_pct, right_speed_pct): + """ + Rotate the motor at 'left_speed & right_speed' for forever + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) + self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) + + # Start the motors + self.left_motor.run_forever() + self.right_motor.run_forever() + + def off(self, brake=True): + self.left_motor._set_brake(brake) + self.right_motor._set_brake(brake) + self.left_motor.stop() + self.right_motor.stop() + + +class MoveSteering(MoveTank): + + def get_speed_steering(self, steering, speed_pct): + """ + Calculate the speed_sp for each motor in a pair to achieve the specified + steering. Note that calling this function alone will not make the + motors move, it only sets the speed. A run_* function must be called + afterwards to make the motors move. + + steering [-100, 100]: + * -100 means turn left as fast as possible, + * 0 means drive in a straight line, and + * 100 means turn right as fast as possible. + + speed_pct: + The speed that should be applied to the outmost motor (the one + rotating faster). The speed of the other motor will be computed + automatically. + """ + + assert steering >= -100 and steering <= 100,\ + "%s is an invalid steering, must be between -100 and 100 (inclusive)" % steering + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + + left_speed = int((speed_pct * self.max_speed) / 100) + right_speed = left_speed + speed = (50 - abs(float(steering))) / 50 + + if steering >= 0: + right_speed *= speed + else: + left_speed *= speed + + left_speed_pct = int((left_speed * 100) / self.left_motor.max_speed) + right_speed_pct = int((right_speed * 100) / self.right_motor.max_speed) + #log.debug("%s: steering %d, %s speed %d, %s speed %d" % + # (self, steering, self.left_motor, left_speed_pct, self.right_motor, right_speed_pct)) + + return (left_speed_pct, right_speed_pct) + + def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake, block) + + def on_for_degrees(self, steering, speed_pct, degrees, brake=True, block=True): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake, block) + + def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake, block) + + def on(self, steering, speed_pct): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on(self, left_speed_pct, right_speed_pct) + + class Sensor(Device): """ diff --git a/ev3dev/helper.py b/ev3dev/helper.py index 7de2fde..957715c 100644 --- a/ev3dev/helper.py +++ b/ev3dev/helper.py @@ -147,253 +147,6 @@ class MediumMotor(ev3dev.auto.MediumMotor, MotorMixin): pass -class MotorSet(object): - """ - motor_specs is a dictionary such as - { - OUTPUT_A : LargeMotor, - OUTPUT_B : MediumMotor, - } - """ - - def __init__(self, motor_specs, desc=None): - - for motor_port in motor_specs.keys(): - if motor_port not in OUTPUTS: - log.error("%s in an invalid motor, choices are %s" % (motor_port, ', '.join(OUTPUTS))) - sys.exit(1) - - self.motors = OrderedDict() - for motor_port in sorted(motor_specs.keys()): - motor_class = motor_specs[motor_port] - self.motors[motor_port] = motor_class(motor_port) - - self.desc = desc - self.verify_connected() - - def __str__(self): - - if self.desc: - return self.desc - else: - return self.__class__.__name__ - - def verify_connected(self): - for motor in self.motors.values(): - if not motor.connected: - log.error("%s: %s is not connected" % (self, motor)) - sys.exit(1) - - def set_args(self, **kwargs): - motors = kwargs.get('motors', self.motors.values()) - - for motor in motors: - for key in kwargs: - if key != 'motors': - try: - setattr(motor, key, kwargs[key]) - except AttributeError as e: - log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) - raise e - - def set_polarity(self, polarity, motors=None): - valid_choices = ('normal', 'inversed') - assert polarity in valid_choices,\ - "%s is an invalid polarity choice, must be %s" % (polarity, ', '.join(valid_choices)) - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.polarity = polarity - - def _run_command(self, **kwargs): - motors = kwargs.get('motors', self.motors.values()) - - for motor in motors: - for key in kwargs: - if key not in ('motors', 'commands'): - log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) - setattr(motor, key, kwargs[key]) - - for motor in motors: - motor.command = kwargs['command'] - log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) - - def run_forever(self, **kwargs): - kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_FOREVER - self._run_command(**kwargs) - - def run_to_abs_pos(self, **kwargs): - kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_TO_ABS_POS - self._run_command(**kwargs) - - def run_to_rel_pos(self, **kwargs): - kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_TO_REL_POS - self._run_command(**kwargs) - - def run_timed(self, **kwargs): - kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_TIMED - self._run_command(**kwargs) - - def run_direct(self, **kwargs): - kwargs['command'] = ev3dev.auto.LargeMotor.COMMAND_RUN_DIRECT - self._run_command(**kwargs) - - def reset(self, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.reset() - - def stop(self, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.stop() - - def _is_state(self, motors, state): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - if state not in motor.state: - return False - - return True - - @property - def is_running(self, motors=None): - return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_RUNNING) - - @property - def is_ramping(self, motors=None): - return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_RAMPING) - - @property - def is_holding(self, motors=None): - return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_HOLDING) - - @property - def is_overloaded(self, motors=None): - return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_OVERLOADED) - - @property - def is_stalled(self): - return self._is_state(motors, ev3dev.auto.LargeMotor.STATE_STALLED) - - def wait(self, cond, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait(cond, timeout) - - def wait_until_not_moving(self, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait_until_not_moving(timeout) - - def wait_until(self, s, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait_until(s, timeout) - - def wait_while(self, s, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait_while(s, timeout) - - -class MotorPair(MotorSet): - - def __init__(self, motor_specs, desc=None): - MotorSet.__init__(self, motor_specs, desc) - (self.left_motor, self.right_motor) = self.motors.values() - self.max_speed = self.left_motor.max_speed - - def set_speed_steering(self, direction, speed_outer_motor=100): - """ - Set the speed_sp for each motor in a pair to achieve the specified - steering. Note that calling this function alone will not make the - motors move, it only sets the speed. A run_* function must be called - afterwards to make the motors move. - - direction [-100, 100]: - * -100 means turn left as fast as possible, - * 0 means drive in a straight line, and - * 100 means turn right as fast as possible. - - speed_outer_motor: - The speed that should be applied to the outmost motor (the one - rotating faster). The speed of the other motor will be computed - automatically. - """ - - assert direction >= -100 and direction <= 100,\ - "%s is an invalid direction, must be between -100 and 100 (inclusive)" % direction - - left_speed = speed_outer_motor - right_speed = speed_outer_motor - speed = (50 - abs(float(direction))) / 50 - - if direction >= 0: - right_speed *= speed - else: - left_speed *= speed - - left_speed = int(left_speed) - right_speed = int(right_speed) - self.left_motor.speed_sp = left_speed - self.right_motor.speed_sp = right_speed - - log.debug("%s: direction %d, %s speed %d, %s speed %d" % - (self, direction, self.left_motor, left_speed, self.right_motor, right_speed)) - - def set_speed_percentage(self, left_motor_percentage, right_motor_percentage): - """ - Set the speeds of the left_motor vs right_motor by percentage of - their maximum speed. The minimum valid percentage is -100, the - maximum is 100. - - Note that calling this function alone will not make the motors move, it - only sets the speed. A run_* function must be called afterwards to make - the motors move. - """ - - assert left_motor_percentage >= -100 and left_motor_percentage <= 100,\ - "%s is an invalid percentage, must be between -100 and 100 (inclusive)" % left_motor_percentage - - assert right_motor_percentage >= -100 and right_motor_percentage <= 100,\ - "%s is an invalid percentage, must be between -100 and 100 (inclusive)" % right_motor_percentage - - # Convert left_motor_percentage and right_motor_percentage to fractions - left_motor_percentage = left_motor_percentage / 100.0 - right_motor_percentage = right_motor_percentage / 100.0 - - self.left_motor.speed_sp = int(self.max_speed * left_motor_percentage) - self.right_motor.speed_sp = int(self.max_speed * right_motor_percentage) - - -class LargeMotorPair(MotorPair): - - def __init__(self, left_motor, right_motor, desc=None): - motor_specs = { - left_motor : LargeMotor, - right_motor : LargeMotor, - } - MotorPair.__init__(self, motor_specs, desc) - - -class MediumMotorPair(MotorPair): - - def __init__(self, left_motor, right_motor, desc=None): - motor_specs = { - left_motor : MediumMotor, - right_motor : MediumMotor, - } - MotorPair.__init__(self, motor_specs, desc) - - class ColorSensorMixin(object): def rgb(self): From 4974253d66b469aeff5897efb3137541c3183e06 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sat, 23 Sep 2017 08:26:47 -0400 Subject: [PATCH 011/172] EV3-G API: touch sensor add is_released (#367) * EV3-G API: touch sensor add is_released * Replace TABs with spaces * TouchSensor: add wait_for_pressed, etc * MODES to use MODE_TOUCH constant * Only decrement timeout_ms when it is not None * Simplify wait_for_press logic --- ev3dev/core.py | 120 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 41 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 4b87680..d91a710 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -67,15 +67,15 @@ def list_device_names(class_path, name_pattern, **kwargs): provided parameters. Parameters: - class_path: class path of the device, a subdirectory of /sys/class. - For example, '/sys/class/tacho-motor'. - name_pattern: pattern that device name should match. - For example, 'sensor*' or 'motor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, address='outA', or - driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value - is a list, then a match against any entry of the list is - enough. + class_path: class path of the device, a subdirectory of /sys/class. + For example, '/sys/class/tacho-motor'. + name_pattern: pattern that device name should match. + For example, 'sensor*' or 'motor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, address='outA', or + driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value + is a list, then a match against any entry of the list is + enough. """ if not os.path.isdir(class_path): @@ -266,15 +266,15 @@ def list_devices(class_name, name_pattern, **kwargs): arguments. Parameters: - class_name: class name of the device, a subdirectory of /sys/class. - For example, 'tacho-motor'. - name_pattern: pattern that device name should match. - For example, 'sensor*' or 'motor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, address='outA', or - driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value - is a list, then a match against any entry of the list is - enough. + class_name: class name of the device, a subdirectory of /sys/class. + For example, 'tacho-motor'. + name_pattern: pattern that device name should match. + For example, 'sensor*' or 'motor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, address='outA', or + driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value + is a list, then a match against any entry of the list is + enough. """ classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) @@ -1038,13 +1038,13 @@ def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): the provided arguments. Parameters: - name_pattern: pattern that device name should match. - For example, 'motor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, driver_name='lego-ev3-l-motor', or - address=['outB', 'outC']. When argument value - is a list, then a match against any entry of the list is - enough. + name_pattern: pattern that device name should match. + For example, 'motor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, driver_name='lego-ev3-l-motor', or + address=['outB', 'outC']. When argument value + is a list, then a match against any entry of the list is + enough. """ class_path = abspath(Device.DEVICE_ROOT_PATH + '/' + Motor.SYSTEM_CLASS_NAME) @@ -2143,11 +2143,11 @@ def list_sensors(name_pattern=Sensor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): provided arguments. Parameters: - name_pattern: pattern that device name should match. - For example, 'sensor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, driver_name='lego-ev3-touch', or - address=['in1', 'in3']. When argument value is a list, + name_pattern: pattern that device name should match. + For example, 'sensor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, driver_name='lego-ev3-touch', or + address=['in1', 'in3']. When argument value is a list, then a match against any entry of the list is enough. """ class_path = abspath(Device.DEVICE_ROOT_PATH + '/' + Sensor.SYSTEM_CLASS_NAME) @@ -2202,24 +2202,20 @@ class TouchSensor(Sensor): Touch Sensor """ - __slots__ = ['auto_mode'] + __slots__ = ['auto_mode', '_poll', '_value0'] SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(TouchSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-touch', 'lego-nxt-touch'], **kwargs) - self.auto_mode = True - - #: Button state MODE_TOUCH = 'TOUCH' + MODES = (MODE_TOUCH,) - - MODES = ( - 'TOUCH', - ) - + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(TouchSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-touch', 'lego-nxt-touch'], **kwargs) + self.auto_mode = True + self._poll = None + self._value0 = None @property def is_pressed(self): @@ -2233,6 +2229,48 @@ def is_pressed(self): return self.value(0) + @property + def is_released(self): + return not self.is_pressed + + def _wait(self, wait_for_press, timeout_ms): + tic = time.time() + + if self._poll is None: + self._value0 = self._attribute_file_open('value0') + self._poll = select.poll() + self._poll.register(self._value0, select.POLLPRI) + + while True: + self._poll.poll(timeout_ms) + + if self.is_pressed == wait_for_press: + return True + + if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: + return False + + def wait_for_pressed(self, timeout_ms=None): + return self._wait(True, timeout_ms) + + def wait_for_released(self, timeout_ms=None): + return self._wait(False, timeout_ms) + + def wait_for_bump(self, timeout_ms=None): + """ + Wait for the touch sensor to be pressed down and then released. + Both actions must happen within timeout_ms. + """ + start_time = time.time() + + if self.wait_for_pressed(timeout_ms): + if timeout_ms is not None: + timeout_ms -= int((time.time() - start_time) * 1000) + return self.wait_for_released(timeout_ms) + + return False + + class ColorSensor(Sensor): """ From b33533e0b2550cd336c04d13854d35a0046e8602 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Mon, 25 Sep 2017 17:32:15 -0700 Subject: [PATCH 012/172] Use index instead of name for device ID regex group (#376) --- ev3dev/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index d91a710..961c735 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -109,7 +109,7 @@ class Device(object): DEVICE_ROOT_PATH = '/sys/class' - _DEVICE_INDEX = re.compile(r'^.*(?P\d+)$') + _DEVICE_INDEX = re.compile(r'^.*(\d+)$') def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs): """Spin through the Linux sysfs class for the device type and find @@ -142,7 +142,7 @@ def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs): def get_index(file): match = Device._DEVICE_INDEX.match(file) if match: - return int(match.group('idx')) + return int(match.group(1)) else: return None From 390da1871e4dc0e4b13d662ba5f8defe048700c6 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 26 Sep 2017 11:05:30 -0400 Subject: [PATCH 013/172] EV3-G API infrared sensor (#374) EV3-G API infrared sensor --- ev3dev/core.py | 377 ++++++++++++++++++++++------------------------- ev3dev/helper.py | 32 +--- 2 files changed, 184 insertions(+), 225 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 961c735..0fe1b61 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -1599,7 +1599,7 @@ def __str__(self): def verify_connected(self): for motor in self.motors.values(): if not motor.connected: - #log.error("%s: %s is not connected" % (self, motor)) + print("%s: %s is not connected" % (self, motor)) sys.exit(1) def set_args(self, **kwargs): @@ -2584,8 +2584,62 @@ def rate_and_angle(self): return self.value(0), self.value(1) -class InfraredSensor(Sensor): +class ButtonBase(object): + """ + Abstract button interface. + """ + + def __str__(self): + return self.__class__.__name__ + + @staticmethod + def on_change(changed_buttons): + """ + This handler is called by `process()` whenever state of any button has + changed since last `process()` call. `changed_buttons` is a list of + tuples of changed button names and their states. + """ + pass + + _state = set([]) + + def any(self): + """ + Checks if any button is pressed. + """ + return bool(self.buttons_pressed) + + def check_buttons(self, buttons=[]): + """ + Check if currently pressed buttons exactly match the given list. + """ + return set(self.buttons_pressed) == set(buttons) + + def process(self, new_state=None): + """ + Check for currenly pressed buttons. If the new state differs from the + old state, call the appropriate button event handlers. + """ + if new_state is None: + new_state = set(self.buttons_pressed) + old_state = self._state + self._state = new_state + + state_diff = new_state.symmetric_difference(old_state) + for button in state_diff: + handler = getattr(self, 'on_' + button) + if handler is not None: handler(button in new_state) + + if self.on_change is not None and state_diff: + self.on_change([(button, button in new_state) for button in state_diff]) + + @property + def buttons_pressed(self): + raise NotImplementedError() + + +class InfraredSensor(Sensor, ButtonBase): """ LEGO EV3 infrared sensor. """ @@ -2595,11 +2649,6 @@ class InfraredSensor(Sensor): SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-ir'], **kwargs) - self.auto_mode = True - - #: Proximity MODE_IR_PROX = 'IR-PROX' @@ -2615,15 +2664,55 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam #: Calibration ??? MODE_IR_CAL = 'IR-CAL' - MODES = ( - 'IR-PROX', - 'IR-SEEK', - 'IR-REMOTE', - 'IR-REM-A', - 'IR-CAL', + MODE_IR_PROX, + MODE_IR_SEEK, + MODE_IR_REMOTE, + MODE_IR_REM_A, + MODE_IR_CAL ) + # The following are all of the various combinations of button presses for + # the remote control. The key/index is the number that will be written in + # the attribute file to indicate what combination of buttons are currently + # pressed. + _BUTTON_VALUES = { + 0: [], + 1: ['red_up'], + 2: ['red_down'], + 3: ['blue_up'], + 4: ['blue_down'], + 5: ['red_up', 'blue_up'], + 6: ['red_up', 'blue_down'], + 7: ['red_down', 'blue_up'], + 8: ['red_down', 'blue_down'], + 9: ['beacon'], + 10: ['red_up', 'red_down'], + 11: ['blue_up', 'blue_down'] + } + + #: Handles ``Red Up`` events. + on_red_up = None + + #: Handles ``Red Down`` events. + on_red_down = None + + #: Handles ``Blue Up`` events. + on_blue_up = None + + #: Handles ``Blue Down`` events. + on_blue_down = None + + #: Handles ``Beacon`` events. + on_beacon = None + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-ir'], **kwargs) + + def _normalize_channel(self, channel): + assert channel >= 1 and channel <= 4, "channel is %s, it must be 1, 2, 3, or 4" % channel + channel = max(1, min(4, channel)) - 1 + return channel @property def proximity(self): @@ -2631,11 +2720,83 @@ def proximity(self): A measurement of the distance between the sensor and the remote, as a percentage. 100% is approximately 70cm/27in. """ + self.mode = self.MODE_IR_PROX + return self.value(0) - if self.auto_mode: - self.mode = self.MODE_IR_PROX + def heading(self, channel=1): + """ + Returns heading (-25, 25) to the beacon on the given channel. + """ + self.mode = self.MODE_IR_SEEK + channel = self._normalize_channel(channel) + return self.value(channel * 2) + + def distance(self, channel=1): + """ + Returns distance (0, 100) to the beacon on the given channel. + Returns None when beacon is not found. + """ + self.mode = self.MODE_IR_SEEK + channel = self._normalize_channel(channel) + ret_value = self.value((channel * 2) + 1) + + # The value will be -128 if no beacon is found, return None instead + return None if ret_value == -128 else ret_value + + def heading_and_distance(self, channel=1): + """ + Returns heading and distance to the beacon on the given channel as a + tuple. + """ + return (self.heading(channel), self.distance(channel)) + + def red_up(self, channel=1): + """ + Checks if `red_up` button is pressed. + """ + return 'red_up' in self.buttons_pressed(channel) + + def red_down(self, channel=1): + """ + Checks if `red_down` button is pressed. + """ + return 'red_down' in self.buttons_pressed(channel) + + def blue_up(self, channel=1): + """ + Checks if `blue_up` button is pressed. + """ + return 'blue_up' in self.buttons_pressed(channel) + + def blue_down(self, channel=1): + """ + Checks if `blue_down` button is pressed. + """ + return 'blue_down' in self.buttons_pressed(channel) + + def beacon(self, channel=1): + """ + Checks if `beacon` button is pressed. + """ + return 'beacon' in self.buttons_pressed(channel) + + def buttons_pressed(self, channel=1): + """ + Returns list of currently pressed buttons. + """ + self.mode = self.MODE_IR_REMOTE + channel = self._normalize_channel(channel) + return self._BUTTON_VALUES.get(self.value(channel), []) + + def process(self, channel=1): + """ + ButtonBase expects buttons_pressed to be a @property but we need to + pass 'channel' to our buttons_pressed. Get the new_state and pass + that to ButtonBase.process(). + """ + new_state = set(self.buttons_pressed(channel)) + ButtonBase.process(self, new_state) - return self.value(0) class SoundSensor(Sensor): @@ -2944,59 +3105,6 @@ def brightness_pct(self, value): self.brightness = value * self.max_brightness -class ButtonBase(object): - """ - Abstract button interface. - """ - - def __str__(self): - return self.__class__.__name__ - - @staticmethod - def on_change(changed_buttons): - """ - This handler is called by `process()` whenever state of any button has - changed since last `process()` call. `changed_buttons` is a list of - tuples of changed button names and their states. - """ - pass - - _state = set([]) - - def any(self): - """ - Checks if any button is pressed. - """ - return bool(self.buttons_pressed) - - def check_buttons(self, buttons=[]): - """ - Check if currently pressed buttons exactly match the given list. - """ - return set(self.buttons_pressed) == set(buttons) - - def process(self): - """ - Check for currenly pressed buttons. If the new state differs from the - old state, call the appropriate button event handlers. - """ - new_state = set(self.buttons_pressed) - old_state = self._state - self._state = new_state - - state_diff = new_state.symmetric_difference(old_state) - for button in state_diff: - handler = getattr(self, 'on_' + button) - if handler is not None: handler(button in new_state) - - if self.on_change is not None and state_diff: - self.on_change([(button, button in new_state) for button in state_diff]) - - @property - def buttons_pressed(self): - raise NotImplementedError() - - class ButtonEVIO(ButtonBase): """ @@ -3046,137 +3154,6 @@ def buttons_pressed(self): return pressed -class RemoteControl(ButtonBase): - """ - EV3 Remote Controller - """ - - _BUTTON_VALUES = { - 0: [], - 1: ['red_up'], - 2: ['red_down'], - 3: ['blue_up'], - 4: ['blue_down'], - 5: ['red_up', 'blue_up'], - 6: ['red_up', 'blue_down'], - 7: ['red_down', 'blue_up'], - 8: ['red_down', 'blue_down'], - 9: ['beacon'], - 10: ['red_up', 'red_down'], - 11: ['blue_up', 'blue_down'] - } - - #: Handles ``Red Up`` events. - on_red_up = None - - #: Handles ``Red Down`` events. - on_red_down = None - - #: Handles ``Blue Up`` events. - on_blue_up = None - - #: Handles ``Blue Down`` events. - on_blue_down = None - - #: Handles ``Beacon`` events. - on_beacon = None - - - @property - def red_up(self): - """ - Checks if `red_up` button is pressed. - """ - return 'red_up' in self.buttons_pressed - - @property - def red_down(self): - """ - Checks if `red_down` button is pressed. - """ - return 'red_down' in self.buttons_pressed - - @property - def blue_up(self): - """ - Checks if `blue_up` button is pressed. - """ - return 'blue_up' in self.buttons_pressed - - @property - def blue_down(self): - """ - Checks if `blue_down` button is pressed. - """ - return 'blue_down' in self.buttons_pressed - - @property - def beacon(self): - """ - Checks if `beacon` button is pressed. - """ - return 'beacon' in self.buttons_pressed - - def __init__(self, sensor=None, channel=1): - if sensor is None: - self._sensor = InfraredSensor() - else: - self._sensor = sensor - - self._channel = max(1, min(4, channel)) - 1 - self._state = set([]) - - if self._sensor.connected: - self._sensor.mode = 'IR-REMOTE' - - @property - def connected(self): - return self._sensor.connected - - @property - def buttons_pressed(self): - """ - Returns list of currently pressed buttons. - """ - return RemoteControl._BUTTON_VALUES.get(self._sensor.value(self._channel), []) - - -class BeaconSeeker(object): - """ - Seeks EV3 Remote Controller in beacon mode. - """ - - def __init__(self, sensor=None, channel=1): - self._sensor = InfraredSensor() if sensor is None else sensor - self._channel = max(1, min(4, channel)) - 1 - - if self._sensor.connected: - self._sensor.mode = 'IR-SEEK' - - @property - def heading(self): - """ - Returns heading (-25, 25) to the beacon on the given channel. - """ - return self._sensor.value(self._channel * 2) - - @property - def distance(self): - """ - Returns distance (0, 100) to the beacon on the given channel. - Returns -128 when beacon is not found. - """ - return self._sensor.value(self._channel * 2 + 1) - - @property - def heading_and_distance(self): - """ - Returns heading and distance to the beacon on the given channel as a - tuple. - """ - return self._sensor.value(self._channel * 2), self._sensor.value(self._channel * 2 + 1) - - class PowerSupply(Device): """ diff --git a/ev3dev/helper.py b/ev3dev/helper.py index 957715c..a892d4e 100644 --- a/ev3dev/helper.py +++ b/ev3dev/helper.py @@ -8,16 +8,11 @@ import time import ev3dev.auto from collections import OrderedDict -from ev3dev.auto import (RemoteControl, list_motors, - INPUT_1, INPUT_2, INPUT_3, INPUT_4, - OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D) +from ev3dev.auto import InfraredSensor, MoveTank from time import sleep log = logging.getLogger(__name__) -INPUTS = (INPUT_1, INPUT_2, INPUT_3, INPUT_4) -OUTPUTS = (OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D) - # ============= # Motor classes @@ -168,34 +163,21 @@ class ColorSensor(ev3dev.auto.ColorSensor, ColorSensorMixin): # ============ # Tank classes # ============ -class Tank(LargeMotorPair): - """ - This class is here for backwards compatibility for anyone who was using - this library before the days of LargeMotorPair. We wrote the Tank class - first, then LargeMotorPair. All future work will be in the MotorSet, - MotorPair, etc classes - """ - - def __init__(self, left_motor_port, right_motor_port, polarity='normal', name='Tank'): - LargeMotorPair.__init__(self, left_motor_port, right_motor_port, name) - self.set_polarity(polarity) - self.speed_sp = 400 - - -class RemoteControlledTank(LargeMotorPair): +class RemoteControlledTank(MoveTank): - def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed=400): - LargeMotorPair.__init__(self, left_motor_port, right_motor_port) + def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed=400, channel=1): + MoveTank.__init__(self, left_motor_port, right_motor_port) self.set_polarity(polarity) left_motor = self.motors[left_motor_port] right_motor = self.motors[right_motor_port] self.speed_sp = speed - self.remote = RemoteControl(channel=1) + self.remote = InfraredSensor() self.remote.on_red_up = self.make_move(left_motor, self.speed_sp) self.remote.on_red_down = self.make_move(left_motor, self.speed_sp* -1) self.remote.on_blue_up = self.make_move(right_motor, self.speed_sp) self.remote.on_blue_down = self.make_move(right_motor, self.speed_sp * -1) + self.channel = channel def make_move(self, motor, dc_sp): def move(state): @@ -209,7 +191,7 @@ def main(self): try: while True: - self.remote.process() + self.remote.process(self.channel) time.sleep(0.01) # Exit cleanly so that all motors are stopped From 28b97f49b51d2f3ea22c354f51405dc8481cd451 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 27 Sep 2017 07:47:52 -0400 Subject: [PATCH 014/172] ColorSensor: Move rgb() from helper.py to core.py (#371) --- ev3dev/core.py | 72 +++++++++++++++++++++++++++++++++++++++--------- ev3dev/helper.py | 18 ------------ 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 0fe1b61..4b701e5 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -2277,16 +2277,11 @@ class ColorSensor(Sensor): LEGO EV3 color sensor. """ - __slots__ = ['auto_mode'] + __slots__ = ['auto_mode', 'red_max', 'green_max', 'blue_max'] SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-color'], **kwargs) - self.auto_mode = True - - #: Reflected light. Red LED on. MODE_COL_REFLECT = 'COL-REFLECT' @@ -2326,13 +2321,12 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam #: Brown color. COLOR_BROWN = 7 - MODES = ( - 'COL-REFLECT', - 'COL-AMBIENT', - 'COL-COLOR', - 'REF-RAW', - 'RGB-RAW', + MODE_COL_REFLECT, + MODE_COL_AMBIENT, + MODE_COL_COLOR, + MODE_REF_RAW, + MODE_RGB_RAW ) COLORS = ( @@ -2346,6 +2340,14 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam 'Brown', ) + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-color'], **kwargs) + self.auto_mode = True + + # See calibrate_white() for more details + self.red_max = 300 + self.green_max = 300 + self.blue_max = 300 @property def reflected_light_intensity(self): @@ -2388,10 +2390,22 @@ def color(self): return self.value(0) + @property + def color_name(self): + """ + Returns NoColor, Black, Blue, etc + """ + return self.COLORS[self.color] + @property def raw(self): """ - Red, green, and blue components of the detected color, in the range 0-1020. + Red, green, and blue components of the detected color, officially in the + range 0-1020 but the values returned will never be that high. We do not + yet know why the values returned are low, but pointing the color sensor + at a well lit sheet of white paper will return values in the 250-400 range. + + If this is an issue, check out the rgb() and calibrate_white() methods. """ if self.auto_mode: @@ -2399,6 +2413,38 @@ def raw(self): return self.value(0), self.value(1), self.value(2) + def calibrate_white(self): + """ + The RGB raw values are on a scale of 0-1020 but you never see a value + anywhere close to 1020. This function is designed to be called when + the sensor is placed over a white object in order to figure out what + are the maximum RGB values the robot can expect to see. We will use + these maximum values to scale future raw values to a 0-255 range in + rgb(). + + If you never call this function red_max, green_max, and blue_max will + use a default value of 300. This default was selected by measuring + the RGB values of a white sheet of paper in a well lit room. + + Note that there are several variables that influence the maximum RGB + values detected by the color sensor + - the distance of the color sensor to the white object + - the amount of light in the room + - shadows that the robot casts on the sensor + """ + (self.red_max, self.green_max, self.blue_max) = self.raw + + @property + def rgb(self): + """ + Same as raw() but RGB values are scaled to 0-255 + """ + (red, green, blue) = self.raw + + return (min(int((red * 255) / self.red_max), 255), + min(int((green * 255) / self.green_max), 255), + min(int((blue * 255) / self.blue_max), 255)) + @property def red(self): """ diff --git a/ev3dev/helper.py b/ev3dev/helper.py index a892d4e..bdfec54 100644 --- a/ev3dev/helper.py +++ b/ev3dev/helper.py @@ -142,24 +142,6 @@ class MediumMotor(ev3dev.auto.MediumMotor, MotorMixin): pass -class ColorSensorMixin(object): - - def rgb(self): - """ - Note that the mode for the ColorSensor must be set to MODE_RGB_RAW - """ - # These values are on a scale of 0-1020, convert them to a normal 0-255 scale - red = int((self.value(0) * 255) / 1020) - green = int((self.value(1) * 255) / 1020) - blue = int((self.value(2) * 255) / 1020) - - return (red, green, blue) - - -class ColorSensor(ev3dev.auto.ColorSensor, ColorSensorMixin): - pass - - # ============ # Tank classes # ============ From 1350cd5a1e1f6996b60566f661bfd8aed5c8bc05 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 28 Sep 2017 20:26:49 -0400 Subject: [PATCH 015/172] Use /sys/class/board-info/ to detect platform type (#383) * Use /sys/class/board-info/ to detect platform type * Move get_current_platform() to core.py * PiStorms support * Update docstrings --- ev3dev/auto.py | 40 +++----------- ev3dev/core.py | 38 ++++++++++++++ ev3dev/evb.py | 128 +++++++++++++++++++++++++++++++++++++++++++++ ev3dev/pistorms.py | 16 ++++++ 4 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 ev3dev/evb.py create mode 100644 ev3dev/pistorms.py diff --git a/ev3dev/auto.py b/ev3dev/auto.py index ff16c71..b819e51 100644 --- a/ev3dev/auto.py +++ b/ev3dev/auto.py @@ -1,41 +1,17 @@ -""" -Use platform.machine() to determine the platform type, cache the -results in /tmp/current_platform so that we do not have to import -platform and run platform.machine() each time someone imports ev3dev.auto -""" -import os +from ev3dev.core import get_current_platform -filename = '/tmp/current_platform' -current_platform = None +current_platform = get_current_platform() -if os.path.exists(filename): - with open(filename, 'r') as fh: - current_platform = fh.read().strip() - -if not current_platform: - import platform - - def get_current_platform(): - """ - Guess platform we are running on - """ - machine = platform.machine() - - if machine == 'armv5tejl': - return 'ev3' - elif machine == 'armv6l': - return 'brickpi' - else: - return 'unsupported' +if current_platform == 'brickpi': + from .brickpi import * - current_platform = get_current_platform() +elif current_platform == 'evb': + from .evb import * - with open(filename, 'w') as fh: - fh.write(current_platform + '\n') +elif current_platform == 'pistorms': + from .pistorms import * -if current_platform == 'brickpi': - from .brickpi import * else: # Import ev3 by default, so that it is covered by documentation. from .ev3 import * diff --git a/ev3dev/core.py b/ev3dev/core.py index 4b701e5..a0241c4 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -60,6 +60,44 @@ # update to 'running' in the "on_for_XYZ" methods of the Motor class WAIT_RUNNING_TIMEOUT = 100 + +def get_current_platform(): + """ + Look in /sys/class/board-info/ to determine the platform type. + + This can return 'ev3', 'evb', 'pistorms', 'brickpi' or 'brickpi3'. + """ + board_info_dir = '/sys/class/board-info/' + + for board in os.listdir(board_info_dir): + uevent_filename = os.path.join(board_info_dir, board, 'uevent') + + if os.path.exists(uevent_filename): + with open(uevent_filename, 'r') as fh: + for line in fh.readlines(): + (key, value) = line.strip().split('=') + + if key == 'BOARD_INFO_MODEL': + + if value == 'LEGO MINDSTORMS EV3': + return 'ev3' + + elif value in ('FatcatLab EVB', 'QuestCape'): + return 'evb' + + elif value == 'PiStorms': + return 'pistorms' + + # This is the same for both BrickPi and BrickPi+. + # There is not a way to tell the difference. + elif value == 'Dexter Industries BrickPi': + return 'brickpi' + + elif value == 'Dexter Industries BrickPi3': + return 'brickpi3' + return None + + # ----------------------------------------------------------------------------- def list_device_names(class_path, name_pattern, **kwargs): """ diff --git a/ev3dev/evb.py b/ev3dev/evb.py new file mode 100644 index 0000000..a5ad8b8 --- /dev/null +++ b/ev3dev/evb.py @@ -0,0 +1,128 @@ + +""" +An assortment of classes modeling specific features of the EVB. +""" + +from .core import * + + +OUTPUT_A = 'outA' +OUTPUT_B = 'outB' +OUTPUT_C = 'outC' +OUTPUT_D = 'outD' + +INPUT_1 = 'in1' +INPUT_2 = 'in2' +INPUT_3 = 'in3' +INPUT_4 = 'in4' + + +class Button(ButtonEVIO): + """ + EVB Buttons + """ + + _buttons = { + 'up': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 103}, + 'down': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 108}, + 'left': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 105}, + 'right': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 106}, + 'enter': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 28}, + 'backspace': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 14}, + } + + @property + @staticmethod + def on_up(state): + """ + This handler is called by `process()` whenever state of 'up' button + has changed since last `process()` call. `state` parameter is the new + state of the button. + """ + pass + + @staticmethod + def on_down(state): + """ + This handler is called by `process()` whenever state of 'down' button + has changed since last `process()` call. `state` parameter is the new + state of the button. + """ + pass + + @staticmethod + def on_left(state): + """ + This handler is called by `process()` whenever state of 'left' button + has changed since last `process()` call. `state` parameter is the new + state of the button. + """ + pass + + @staticmethod + def on_right(state): + """ + This handler is called by `process()` whenever state of 'right' button + has changed since last `process()` call. `state` parameter is the new + state of the button. + """ + pass + + @staticmethod + def on_enter(state): + """ + This handler is called by `process()` whenever state of 'enter' button + has changed since last `process()` call. `state` parameter is the new + state of the button. + """ + pass + + @staticmethod + def on_backspace(state): + """ + This handler is called by `process()` whenever state of 'backspace' button + has changed since last `process()` call. `state` parameter is the new + state of the button. + """ + pass + + def up(self): + """ + Check if 'up' button is pressed. + """ + return 'up' in self.buttons_pressed + + @property + def down(self): + """ + Check if 'down' button is pressed. + """ + return 'down' in self.buttons_pressed + + @property + def left(self): + """ + Check if 'left' button is pressed. + """ + return 'left' in self.buttons_pressed + + @property + def right(self): + """ + Check if 'right' button is pressed. + """ + return 'right' in self.buttons_pressed + + @property + def enter(self): + """ + Check if 'enter' button is pressed. + """ + return 'enter' in self.buttons_pressed + + @property + def backspace(self): + """ + Check if 'backspace' button is pressed. + """ + return 'backspace' in self.buttons_pressed diff --git a/ev3dev/pistorms.py b/ev3dev/pistorms.py new file mode 100644 index 0000000..e7aa78c --- /dev/null +++ b/ev3dev/pistorms.py @@ -0,0 +1,16 @@ + +""" +An assortment of classes modeling specific features of the PiStorms. +""" + +from .core import * + +OUTPUT_A = 'pistorms:BBM1' +OUTPUT_B = 'pistorms:BBM2' +OUTPUT_C = 'pistorms:BAM2' +OUTPUT_D = 'pistorms:BAM1' + +INPUT_1 = 'pistorms:BBS1' +INPUT_2 = 'pistorms:BBS2' +INPUT_3 = 'pistorms:BAS2' +INPUT_4 = 'pistorms:BAS1' From 3171043baedf5c2c983bd7698e36e8f8906fe84a Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Thu, 28 Sep 2017 18:52:51 -0700 Subject: [PATCH 016/172] Throw friendly error when device is disconnected (#373) * Throw friendly error when device is disconnected * Update error code --- ev3dev/core.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index a0241c4..7c5bbc9 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -223,7 +223,7 @@ def _attribute_file_open( self, name ): def _get_attribute(self, attribute, name): """Device attribute getter""" if self.connected: - if None == attribute: + if attribute is None: attribute = self._attribute_file_open( name ) else: attribute.seek(0) @@ -235,12 +235,12 @@ def _get_attribute(self, attribute, name): def _set_attribute(self, attribute, name, value): """Device attribute setter""" if self.connected: - if None == attribute: - attribute = self._attribute_file_open( name ) - else: - attribute.seek(0) - try: + if attribute is None: + attribute = self._attribute_file_open( name ) + else: + attribute.seek(0) + attribute.write(value.encode()) attribute.flush() except Exception as ex: @@ -263,6 +263,11 @@ def _raise_friendly_access_error(self, driver_error, attribute): else: raise ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)) from driver_error raise ValueError("One or more arguments were out of range or invalid") from driver_error + elif driver_error.errno == errno.ENODEV or driver_error.errno == errno.ENOENT: + # We will assume that a file-not-found error is the result of a disconnected device + # rather than a library error. If that isn't the case, at a minimum the underlying + # error info will be printed for debugging. + raise Exception("%s is no longer connected" % self) from driver_error raise driver_error def get_attr_int(self, attribute, name): From 2fa6cb0bad6257dc3699b739b92b52b6cd033c04 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 28 Sep 2017 21:54:23 -0400 Subject: [PATCH 017/172] Deprecate auto_mode (#382) * Deprecate auto_mode * Remove _mode_value --- ev3dev/core.py | 117 ++++++++++--------------------------------------- 1 file changed, 23 insertions(+), 94 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 7c5bbc9..b6b9efc 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -2245,7 +2245,7 @@ class TouchSensor(Sensor): Touch Sensor """ - __slots__ = ['auto_mode', '_poll', '_value0'] + __slots__ = ['_poll', '_value0'] SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION @@ -2256,7 +2256,6 @@ class TouchSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(TouchSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-touch', 'lego-nxt-touch'], **kwargs) - self.auto_mode = True self._poll = None self._value0 = None @@ -2266,10 +2265,7 @@ def is_pressed(self): A boolean indicating whether the current touch sensor is being pressed. """ - - if self.auto_mode: - self.mode = self.MODE_TOUCH - + self.mode = self.MODE_TOUCH return self.value(0) @property @@ -2320,7 +2316,7 @@ class ColorSensor(Sensor): LEGO EV3 color sensor. """ - __slots__ = ['auto_mode', 'red_max', 'green_max', 'blue_max'] + __slots__ = ['red_max', 'green_max', 'blue_max'] SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION @@ -2385,7 +2381,6 @@ class ColorSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-color'], **kwargs) - self.auto_mode = True # See calibrate_white() for more details self.red_max = 300 @@ -2397,10 +2392,7 @@ def reflected_light_intensity(self): """ Reflected light intensity as a percentage. Light on sensor is red. """ - - if self.auto_mode: - self.mode = self.MODE_COL_REFLECT - + self.mode = self.MODE_COL_REFLECT return self.value(0) @property @@ -2408,10 +2400,7 @@ def ambient_light_intensity(self): """ Ambient light intensity. Light on sensor is dimly lit blue. """ - - if self.auto_mode: - self.mode = self.MODE_COL_AMBIENT - + self.mode = self.MODE_COL_AMBIENT return self.value(0) @property @@ -2427,10 +2416,7 @@ def color(self): - 6: White - 7: Brown """ - - if self.auto_mode: - self.mode = self.MODE_COL_COLOR - + self.mode = self.MODE_COL_COLOR return self.value(0) @property @@ -2450,10 +2436,7 @@ def raw(self): If this is an issue, check out the rgb() and calibrate_white() methods. """ - - if self.auto_mode: - self.mode = self.MODE_RGB_RAW - + self.mode = self.MODE_RGB_RAW return self.value(0), self.value(1), self.value(2) def calibrate_white(self): @@ -2493,10 +2476,7 @@ def red(self): """ Red component of the detected color, in the range 0-1020. """ - - if self.auto_mode: - self.mode = self.MODE_RGB_RAW - + self.mode = self.MODE_RGB_RAW return self.value(0) @property @@ -2504,10 +2484,7 @@ def green(self): """ Green component of the detected color, in the range 0-1020. """ - - if self.auto_mode: - self.mode = self.MODE_RGB_RAW - + self.mode = self.MODE_RGB_RAW return self.value(1) @property @@ -2515,27 +2492,21 @@ def blue(self): """ Blue component of the detected color, in the range 0-1020. """ - - if self.auto_mode: - self.mode = self.MODE_RGB_RAW - + self.mode = self.MODE_RGB_RAW return self.value(2) + class UltrasonicSensor(Sensor): """ LEGO EV3 ultrasonic sensor. """ - __slots__ = ['auto_mode'] - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(UltrasonicSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-us', 'lego-nxt-us'], **kwargs) - self.auto_mode = True - #: Continuous measurement in centimeters. MODE_US_DIST_CM = 'US-DIST-CM' @@ -2568,10 +2539,7 @@ def distance_centimeters(self): Measurement of the distance detected by the sensor, in centimeters. """ - - if self.auto_mode: - self.mode = self.MODE_US_DIST_CM - + self.mode = self.MODE_US_DIST_CM return self.value(0) * self._scale('US_DIST_CM') @property @@ -2580,10 +2548,7 @@ def distance_inches(self): Measurement of the distance detected by the sensor, in inches. """ - - if self.auto_mode: - self.mode = self.MODE_US_DIST_IN - + self.mode = self.MODE_US_DIST_IN return self.value(0) * self._scale('US_DIST_IN') @property @@ -2592,27 +2557,21 @@ def other_sensor_present(self): Value indicating whether another ultrasonic sensor could be heard nearby. """ - - if self.auto_mode: - self.mode = self.MODE_US_LISTEN - + self.mode = self.MODE_US_LISTEN return self.value(0) + class GyroSensor(Sensor): """ LEGO EV3 gyro sensor. """ - __slots__ = ['auto_mode'] - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-gyro'], **kwargs) - self.auto_mode = True - #: Angle MODE_GYRO_ANG = 'GYRO-ANG' @@ -2645,10 +2604,7 @@ def angle(self): The number of degrees that the sensor has been rotated since it was put into this mode. """ - - if self.auto_mode: - self.mode = self.MODE_GYRO_ANG - + self.mode = self.MODE_GYRO_ANG return self.value(0) @property @@ -2656,10 +2612,7 @@ def rate(self): """ The rate at which the sensor is rotating, in degrees/second. """ - - if self.auto_mode: - self.mode = self.MODE_GYRO_RATE - + self.mode = self.MODE_GYRO_RATE return self.value(0) @property @@ -2667,10 +2620,7 @@ def rate_and_angle(self): """ Angle (degrees) and Rotational Speed (degrees/second). """ - - if self.auto_mode: - self.mode = self.MODE_GYRO_G_A - + self.mode = self.MODE_GYRO_G_A return self.value(0), self.value(1) @@ -2733,8 +2683,6 @@ class InfraredSensor(Sensor, ButtonBase): LEGO EV3 infrared sensor. """ - __slots__ = ['auto_mode'] - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION @@ -2893,15 +2841,11 @@ class SoundSensor(Sensor): LEGO NXT Sound Sensor """ - __slots__ = ['auto_mode'] - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(SoundSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-sound'], **kwargs) - self.auto_mode = True - #: Sound pressure level. Flat weighting MODE_DB = 'DB' @@ -2922,10 +2866,7 @@ def sound_pressure(self): A measurement of the measured sound pressure level, as a percent. Uses a flat weighting. """ - - if self.auto_mode: - self.mode = self.MODE_DB - + self.mode = self.MODE_DB return self.value(0) * self._scale('DB') @property @@ -2934,27 +2875,21 @@ def sound_pressure_low(self): A measurement of the measured sound pressure level, as a percent. Uses A-weighting, which focuses on levels up to 55 dB. """ - - if self.auto_mode: - self.mode = self.MODE_DBA - + self.mode = self.MODE_DBA return self.value(0) * self._scale('DBA') + class LightSensor(Sensor): """ LEGO NXT Light Sensor """ - __slots__ = ['auto_mode'] - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(LightSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-light'], **kwargs) - self.auto_mode = True - #: Reflected light. LED on MODE_REFLECT = 'REFLECT' @@ -2974,10 +2909,7 @@ def reflected_light_intensity(self): """ A measurement of the reflected light intensity, as a percentage. """ - - if self.auto_mode: - self.mode = self.MODE_REFLECT - + self.mode = self.MODE_REFLECT return self.value(0) * self._scale('REFLECT') @property @@ -2985,10 +2917,7 @@ def ambient_light_intensity(self): """ A measurement of the ambient light intensity, as a percentage. """ - - if self.auto_mode: - self.mode = self.MODE_AMBIENT - + self.mode = self.MODE_AMBIENT return self.value(0) * self._scale('AMBIENT') From 6315c83fc044ab44ea10948a173bbdf35e8d0885 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 1 Oct 2017 08:54:01 -0400 Subject: [PATCH 018/172] EV3-G API for Button (#386) * EV3-G API for Button * EV3-G API for Button - use evdev for event driven reads * Add python-evdev as required package * Revert "Add python-evdev as required package" This reverts commit 2985e93bb91002e7530b0f97a062a8831f1a3ae9. * Undo file_open * Update .travis.yml to install python3-evdev * Update .travis.yml to install python3-evdev * Update .travis.yml to 'pip install evdev' * Option to specifiy sleep_ms in TouchSensor _wait() --- .travis.yml | 1 + ev3dev/core.py | 120 ++++++++++++++++++++++++++++++++++++++++--------- ev3dev/ev3.py | 80 ++++++++------------------------- ev3dev/evb.py | 79 ++++++++------------------------ 4 files changed, 137 insertions(+), 143 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1be1447..33f7310 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: sudo: false install: - pip install Pillow +- pip install evdev script: - ./tests/api_tests.py deploy: diff --git a/ev3dev/core.py b/ev3dev/core.py index b6b9efc..048644f 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -41,6 +41,7 @@ import stat import time import errno +import evdev from os.path import abspath from struct import pack, unpack from subprocess import Popen, check_output, PIPE @@ -137,6 +138,7 @@ def matches(attribute, pattern): if all([matches(path + '/' + k, kwargs[k]) for k in kwargs]): yield f + # ----------------------------------------------------------------------------- # Define the base class from which all other ev3dev classes are defined. @@ -205,8 +207,8 @@ def __str__(self): else: return self.__class__.__name__ - def _attribute_file_open( self, name ): - path = self._path + '/' + name + def _attribute_file_open(self, name): + path = os.path.join(self._path, name) mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE]) r_ok = mode & stat.S_IRGRP w_ok = mode & stat.S_IWGRP @@ -2245,8 +2247,6 @@ class TouchSensor(Sensor): Touch Sensor """ - __slots__ = ['_poll', '_value0'] - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION @@ -2256,8 +2256,6 @@ class TouchSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(TouchSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-touch', 'lego-nxt-touch'], **kwargs) - self._poll = None - self._value0 = None @property def is_pressed(self): @@ -2272,16 +2270,15 @@ def is_pressed(self): def is_released(self): return not self.is_pressed - def _wait(self, wait_for_press, timeout_ms): + def _wait(self, wait_for_press, timeout_ms, sleep_ms): tic = time.time() - if self._poll is None: - self._value0 = self._attribute_file_open('value0') - self._poll = select.poll() - self._poll.register(self._value0, select.POLLPRI) + if sleep_ms: + sleep_ms = float(sleep_ms/1000) + # The kernel does not supoort POLLPRI or POLLIN for sensors so we have + # to drop into a loop and check often while True: - self._poll.poll(timeout_ms) if self.is_pressed == wait_for_press: return True @@ -2289,23 +2286,26 @@ def _wait(self, wait_for_press, timeout_ms): if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: return False - def wait_for_pressed(self, timeout_ms=None): - return self._wait(True, timeout_ms) + if sleep_ms: + time.sleep(sleep_ms) + + def wait_for_pressed(self, timeout_ms=None, sleep_ms=10): + return self._wait(True, timeout_ms, sleep_ms) - def wait_for_released(self, timeout_ms=None): - return self._wait(False, timeout_ms) + def wait_for_released(self, timeout_ms=None, sleep_ms=10): + return self._wait(False, timeout_ms, sleep_ms) - def wait_for_bump(self, timeout_ms=None): + def wait_for_bump(self, timeout_ms=None, sleep_ms=10): """ Wait for the touch sensor to be pressed down and then released. Both actions must happen within timeout_ms. """ start_time = time.time() - if self.wait_for_pressed(timeout_ms): + if self.wait_for_pressed(timeout_ms, sleep_ms): if timeout_ms is not None: timeout_ms -= int((time.time() - start_time) * 1000) - return self.wait_for_released(timeout_ms) + return self.wait_for_released(timeout_ms, sleep_ms) return False @@ -2629,6 +2629,8 @@ class ButtonBase(object): Abstract button interface. """ + _state = set([]) + def __str__(self): return self.__class__.__name__ @@ -2641,8 +2643,6 @@ def on_change(changed_buttons): """ pass - _state = set([]) - def any(self): """ Checks if any button is pressed. @@ -2655,6 +2655,19 @@ def check_buttons(self, buttons=[]): """ return set(self.buttons_pressed) == set(buttons) + @property + def evdev_device(self): + """ + Return our corresponding evdev device object + """ + devices = [evdev.InputDevice(fn) for fn in evdev.list_devices()] + + for device in devices: + if device.name == self.evdev_device_name: + return device + + raise Exception("%s: could not find evdev device '%s'" % (self, self.evdev_device_name)) + def process(self, new_state=None): """ Check for currenly pressed buttons. If the new state differs from the @@ -2673,10 +2686,69 @@ def process(self, new_state=None): if self.on_change is not None and state_diff: self.on_change([(button, button in new_state) for button in state_diff]) + def process_forever(self): + for event in self.evdev_device.read_loop(): + if event.type == evdev.ecodes.EV_KEY: + self.process() + @property def buttons_pressed(self): raise NotImplementedError() + def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): + tic = time.time() + + # wait_for_button_press/release can be a list of buttons or a string + # with the name of a single button. If it is a string of a single + # button convert that to a list. + if isinstance(wait_for_button_press, str): + wait_for_button_press = [wait_for_button_press, ] + + if isinstance(wait_for_button_release, str): + wait_for_button_release = [wait_for_button_release, ] + + for event in self.evdev_device.read_loop(): + if event.type == evdev.ecodes.EV_KEY: + all_pressed = True + all_released = True + pressed = self.buttons_pressed + + for button in wait_for_button_press: + if button not in pressed: + all_pressed = False + break + + for button in wait_for_button_release: + if button in pressed: + all_released = False + break + + if all_pressed and all_released: + return True + + if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: + return False + + def wait_for_pressed(self, buttons, timeout_ms=None): + return self._wait(buttons, [], timeout_ms) + + def wait_for_released(self, buttons, timeout_ms=None): + return self._wait([], buttons, timeout_ms) + + def wait_for_bump(self, buttons, timeout_ms=None): + """ + Wait for the button to be pressed down and then released. + Both actions must happen within timeout_ms. + """ + start_time = time.time() + + if self.wait_for_pressed(buttons, timeout_ms): + if timeout_ms is not None: + timeout_ms -= int((time.time() - start_time) * 1000) + return self.wait_for_released(buttons, timeout_ms) + + return False + class InfraredSensor(Sensor, ButtonBase): """ @@ -3141,8 +3213,10 @@ class ButtonEVIO(ButtonBase): _buttons = {} def __init__(self): + ButtonBase.__init__(self) self._file_cache = {} self._buffer_cache = {} + for b in self._buttons: name = self._buttons[b]['name'] if name not in self._file_cache: @@ -3167,8 +3241,10 @@ def buttons_pressed(self): for k, v in self._buttons.items(): buf = self._buffer_cache[v['name']] bit = v['value'] + if bool(buf[int(bit / 8)] & 1 << bit % 8): - pressed += [k] + pressed.append(k) + return pressed diff --git a/ev3dev/ev3.py b/ev3dev/ev3.py index 5919b73..85fd864 100644 --- a/ev3dev/ev3.py +++ b/ev3dev/ev3.py @@ -102,69 +102,27 @@ class Button(ButtonEVIO): EV3 Buttons """ - @staticmethod - def on_up(state): - """ - This handler is called by `process()` whenever state of 'up' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_down(state): - """ - This handler is called by `process()` whenever state of 'down' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_left(state): - """ - This handler is called by `process()` whenever state of 'left' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_right(state): - """ - This handler is called by `process()` whenever state of 'right' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_enter(state): - """ - This handler is called by `process()` whenever state of 'enter' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_backspace(state): - """ - This handler is called by `process()` whenever state of 'backspace' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - + _buttons_filename = '/dev/input/by-path/platform-gpio_keys-event' _buttons = { - 'up': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 103}, - 'down': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 108}, - 'left': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 105}, - 'right': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 106}, - 'enter': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 28}, - 'backspace': {'name': '/dev/input/by-path/platform-gpio_keys-event', 'value': 14}, + 'up': {'name': _buttons_filename, 'value': 103}, + 'down': {'name': _buttons_filename, 'value': 108}, + 'left': {'name': _buttons_filename, 'value': 105}, + 'right': {'name': _buttons_filename, 'value': 106}, + 'enter': {'name': _buttons_filename, 'value': 28}, + 'backspace': {'name': _buttons_filename, 'value': 14}, } + evdev_device_name = 'EV3 brick buttons' + + ''' + These handlers are called by `process()` whenever state of 'up', 'down', + etc buttons have changed since last `process()` call + ''' + on_up = None + on_down = None + on_left = None + on_right = None + on_enter = None + on_backspace = None @property def up(self): diff --git a/ev3dev/evb.py b/ev3dev/evb.py index a5ad8b8..2c056cd 100644 --- a/ev3dev/evb.py +++ b/ev3dev/evb.py @@ -22,70 +22,29 @@ class Button(ButtonEVIO): EVB Buttons """ + _buttons_filename = '/dev/input/by-path/platform-evb-buttons-event' _buttons = { - 'up': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 103}, - 'down': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 108}, - 'left': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 105}, - 'right': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 106}, - 'enter': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 28}, - 'backspace': {'name': '/dev/input/by-path/platform-evb-buttons-event', 'value': 14}, + 'up': {'name': _buttons_filename, 'value': 103}, + 'down': {'name': _buttons_filename, 'value': 108}, + 'left': {'name': _buttons_filename, 'value': 105}, + 'right': {'name': _buttons_filename, 'value': 106}, + 'enter': {'name': _buttons_filename, 'value': 28}, + 'backspace': {'name': _buttons_filename, 'value': 14}, } + evdev_device_name = 'evb-input' + + ''' + These handlers are called by `process()` whenever state of 'up', 'down', + etc buttons have changed since last `process()` call + ''' + on_up = None + on_down = None + on_left = None + on_right = None + on_enter = None + on_backspace = None @property - @staticmethod - def on_up(state): - """ - This handler is called by `process()` whenever state of 'up' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_down(state): - """ - This handler is called by `process()` whenever state of 'down' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_left(state): - """ - This handler is called by `process()` whenever state of 'left' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_right(state): - """ - This handler is called by `process()` whenever state of 'right' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_enter(state): - """ - This handler is called by `process()` whenever state of 'enter' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - - @staticmethod - def on_backspace(state): - """ - This handler is called by `process()` whenever state of 'backspace' button - has changed since last `process()` call. `state` parameter is the new - state of the button. - """ - pass - def up(self): """ Check if 'up' button is pressed. From 1d61fee0dbc3ffd0cd37da6db907aeae7291060d Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 1 Oct 2017 15:11:52 -0400 Subject: [PATCH 019/172] EV3-G API GyroSensor (#387) * EV3-G API GyroSensor * Drop 'raw' variable, minor cleanup --- ev3dev/core.py | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 048644f..3d76795 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -243,7 +243,9 @@ def _set_attribute(self, attribute, name, value): else: attribute.seek(0) - attribute.write(value.encode()) + if isinstance(value, str): + value = value.encode() + attribute.write(value) attribute.flush() except Exception as ex: self._raise_friendly_access_error(ex, name) @@ -279,6 +281,9 @@ def get_attr_int(self, attribute, name): def set_attr_int(self, attribute, name, value): return self._set_attribute(attribute, name, str(int(value))) + def set_attr_raw(self, attribute, name, value): + return self._set_attribute(attribute, name, value) + def get_attr_string(self, attribute, name): return self._get_attribute(attribute, name) @@ -2566,13 +2571,9 @@ class GyroSensor(Sensor): """ LEGO EV3 gyro sensor. """ - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-gyro'], **kwargs) - #: Angle MODE_GYRO_ANG = 'GYRO-ANG' @@ -2588,15 +2589,25 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam #: Calibration ??? MODE_GYRO_CAL = 'GYRO-CAL' + # Newer versions of the Gyro sensor also have an additional second axis + # accessible via the TILT-ANGLE and TILT-RATE modes that is not usable + # using the official EV3-G blocks + MODE_TILT_ANG = 'TILT-ANGLE' + MODE_TILT_RATE = 'TILT-RATE' MODES = ( - 'GYRO-ANG', - 'GYRO-RATE', - 'GYRO-FAS', - 'GYRO-G&A', - 'GYRO-CAL', + MODE_GYRO_ANG, + MODE_GYRO_RATE, + MODE_GYRO_FAS, + MODE_GYRO_G_A, + MODE_GYRO_CAL, + MODE_TILT_ANG, + MODE_TILT_RATE, ) + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-gyro'], **kwargs) + self._direct = None @property def angle(self): @@ -2623,6 +2634,20 @@ def rate_and_angle(self): self.mode = self.MODE_GYRO_G_A return self.value(0), self.value(1) + @property + def tilt_angle(self): + self.mode = self.MODE_TILT_ANG + return self.value(0) + + @property + def tilt_rate(self): + self.mode = self.MODE_TILT_RATE + return self.value(0) + + def reset(self): + self.mode = self.MODE_GYRO_ANG + self._direct = self.set_attr_raw(self._direct, 'direct', 17) + class ButtonBase(object): """ From 799920eef3d8f34528243a40ccbb9d234782e9f6 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 1 Oct 2017 15:13:19 -0400 Subject: [PATCH 020/172] InfraredSensor on_red_up, etc support for multiple channels (#388) * InfraredSensor on_red_up, etc support for multiple channels * Rename buttons red_up -> top_left, etc * Rename buttons red_up -> top_left, etc * Add on_ to the channel1_top_left, etc handlers --- ev3dev/core.py | 145 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 43 deletions(-) diff --git a/ev3dev/core.py b/ev3dev/core.py index 3d76795..1ad7fc5 100644 --- a/ev3dev/core.py +++ b/ev3dev/core.py @@ -2706,7 +2706,9 @@ def process(self, new_state=None): state_diff = new_state.symmetric_difference(old_state) for button in state_diff: handler = getattr(self, 'on_' + button) - if handler is not None: handler(button in new_state) + + if handler is not None: + handler(button in new_state) if self.on_change is not None and state_diff: self.on_change([(button, button in new_state) for button in state_diff]) @@ -2812,33 +2814,49 @@ class InfraredSensor(Sensor, ButtonBase): # pressed. _BUTTON_VALUES = { 0: [], - 1: ['red_up'], - 2: ['red_down'], - 3: ['blue_up'], - 4: ['blue_down'], - 5: ['red_up', 'blue_up'], - 6: ['red_up', 'blue_down'], - 7: ['red_down', 'blue_up'], - 8: ['red_down', 'blue_down'], + 1: ['top_left'], + 2: ['bottom_left'], + 3: ['top_right'], + 4: ['bottom_right'], + 5: ['top_left', 'top_right'], + 6: ['top_left', 'bottom_right'], + 7: ['bottom_left', 'top_right'], + 8: ['bottom_left', 'bottom_right'], 9: ['beacon'], - 10: ['red_up', 'red_down'], - 11: ['blue_up', 'blue_down'] + 10: ['top_left', 'bottom_left'], + 11: ['top_right', 'bottom_right'] } - #: Handles ``Red Up`` events. - on_red_up = None - - #: Handles ``Red Down`` events. - on_red_down = None - - #: Handles ``Blue Up`` events. - on_blue_up = None - - #: Handles ``Blue Down`` events. - on_blue_down = None - - #: Handles ``Beacon`` events. - on_beacon = None + _BUTTONS = ('top_left', 'bottom_left', 'top_right', 'bottom_right', 'beacon') + + # See process() for an explanation on how to use these + #: Handles ``Red Up``, etc events on channel 1 + on_channel1_top_left = None + on_channel1_bottom_left = None + on_channel1_top_right = None + on_channel1_bottom_right = None + on_channel1_beacon = None + + #: Handles ``Red Up``, etc events on channel 2 + on_channel2_top_left = None + on_channel2_bottom_left = None + on_channel2_top_right = None + on_channel2_bottom_right = None + on_channel2_beacon = None + + #: Handles ``Red Up``, etc events on channel 3 + on_channel3_top_left = None + on_channel3_bottom_left = None + on_channel3_top_right = None + on_channel3_bottom_right = None + on_channel3_beacon = None + + #: Handles ``Red Up``, etc events on channel 4 + on_channel4_top_left = None + on_channel4_bottom_left = None + on_channel4_top_right = None + on_channel4_bottom_right = None + on_channel4_beacon = None def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-ir'], **kwargs) @@ -2884,29 +2902,29 @@ def heading_and_distance(self, channel=1): """ return (self.heading(channel), self.distance(channel)) - def red_up(self, channel=1): + def top_left(self, channel=1): """ - Checks if `red_up` button is pressed. + Checks if `top_left` button is pressed. """ - return 'red_up' in self.buttons_pressed(channel) + return 'top_left' in self.buttons_pressed(channel) - def red_down(self, channel=1): + def bottom_left(self, channel=1): """ - Checks if `red_down` button is pressed. + Checks if `bottom_left` button is pressed. """ - return 'red_down' in self.buttons_pressed(channel) + return 'bottom_left' in self.buttons_pressed(channel) - def blue_up(self, channel=1): + def top_right(self, channel=1): """ - Checks if `blue_up` button is pressed. + Checks if `top_right` button is pressed. """ - return 'blue_up' in self.buttons_pressed(channel) + return 'top_right' in self.buttons_pressed(channel) - def blue_down(self, channel=1): + def bottom_right(self, channel=1): """ - Checks if `blue_down` button is pressed. + Checks if `bottom_right` button is pressed. """ - return 'blue_down' in self.buttons_pressed(channel) + return 'bottom_right' in self.buttons_pressed(channel) def beacon(self, channel=1): """ @@ -2922,14 +2940,55 @@ def buttons_pressed(self, channel=1): channel = self._normalize_channel(channel) return self._BUTTON_VALUES.get(self.value(channel), []) - def process(self, channel=1): + def process(self): """ - ButtonBase expects buttons_pressed to be a @property but we need to - pass 'channel' to our buttons_pressed. Get the new_state and pass - that to ButtonBase.process(). + Check for currenly pressed buttons. If the new state differs from the + old state, call the appropriate button event handlers. + + To use the on_channel1_top_left, etc handlers your program would do something like: + + def top_left_channel_1_action(state): + print("top left on channel 1: %s" % state) + + def bottom_right_channel_4_action(state): + print("bottom right on channel 4: %s" % state) + + ir = InfraredSensor() + ir.on_channel1_top_left = top_left_channel_1_action + ir.on_channel4_bottom_right = bottom_right_channel_4_action + + while True: + ir.process() + time.sleep(0.01) """ - new_state = set(self.buttons_pressed(channel)) - ButtonBase.process(self, new_state) + new_state = [] + state_diff = [] + + for channel in range(1,5): + + for button in self.buttons_pressed(channel): + new_state.append((button, channel)) + + # Key was not pressed before but now is pressed + if (button, channel) not in self._state: + state_diff.append((button, channel)) + + # Key was pressed but is no longer pressed + for button in self._BUTTONS: + if (button, channel) not in new_state and (button, channel) in self._state: + state_diff.append((button, channel)) + + old_state = self._state + self._state = new_state + + for (button, channel) in state_diff: + handler = getattr(self, 'on_channel' + str(channel) + '_' + button ) + + if handler is not None: + handler((button, channel) in new_state) + + if self.on_change is not None and state_diff: + self.on_change([(button, channel, button in new_state) for (button, channel) in state_diff]) class SoundSensor(Sensor): From 5e33dff26159c9588940cb093b43f36b1b53afd8 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 07:31:53 -0400 Subject: [PATCH 021/172] split core.py (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First phase of spliting core.py Here is what we have robot@evb[ev3dev-lang-python]# tree ev3dev/ ev3dev/ ├── button.py ├── control │   ├── GyroBalancer.py │   ├── __init__.py │   └── rc_tank.py ├── display.py ├── fonts // // chopped most of fonts to limit the output here // │   ├── timR18.pbm │   ├── timR18.pil │   ├── timR24.pbm │   └── timR24.pil ├── helper.py ├── __init__.py ├── led.py ├── motor.py ├── _platform │   ├── brickpi.py │   ├── ev3.py │   ├── evb.py │   ├── __init__.py │   └── pistorms.py ├── port.py ├── sensor │   ├── __init__.py │   └── lego.py ├── sound.py ├── version.py └── webserver.py 4 directories, 421 files robot@evb[ev3dev-lang-python]# * Fix Button(s) * Fix Button(s) * Fix motor(s) * rm autogen-config.json * remove bad imports * Fix LEDs * Move webserver.py to control/ * Fix tests/api_tests.py * rm helper.py * Unite _button and button * Move PowerSupply from __init__.py to power.py * sensor.py input platform specific INPUT_1, etc * Uppercase buttons_filename and evdev_device_name constants * Unite _led.py and led.py * groundwork for pistorms LED support * Do not barf if platform does not have LEDs * raise MissingButton if platform does not have button --- autogen-config.json | 10 - ev3dev/__init__.py | 311 ++ ev3dev/_platform/__init__.py | 0 ev3dev/{ => _platform}/brickpi.py | 59 +- ev3dev/_platform/brickpi3.py | 3 + ev3dev/_platform/ev3.py | 60 + ev3dev/_platform/evb.py | 22 + ev3dev/_platform/pistorms.py | 38 + ev3dev/auto.py | 17 - ev3dev/button.py | 320 ++ ev3dev/{ => control}/GyroBalancer.py | 0 ev3dev/control/__init__.py | 0 ev3dev/control/rc_tank.py | 45 + ev3dev/{ => control}/webserver.py | 0 ev3dev/core.py | 4210 -------------------------- ev3dev/display.py | 269 ++ ev3dev/ev3.py | 167 - ev3dev/evb.py | 87 - ev3dev/helper.py | 205 -- ev3dev/led.py | 331 ++ ev3dev/motor.py | 1686 +++++++++++ ev3dev/pistorms.py | 16 - ev3dev/port.py | 150 + ev3dev/power.py | 120 + ev3dev/sensor/__init__.py | 341 +++ ev3dev/sensor/lego.py | 725 +++++ ev3dev/sound.py | 464 +++ setup.py | 6 +- tests/api_tests.py | 10 +- 29 files changed, 4908 insertions(+), 4764 deletions(-) delete mode 100644 autogen-config.json create mode 100644 ev3dev/_platform/__init__.py rename ev3dev/{ => _platform}/brickpi.py (57%) create mode 100644 ev3dev/_platform/brickpi3.py create mode 100644 ev3dev/_platform/ev3.py create mode 100644 ev3dev/_platform/evb.py create mode 100644 ev3dev/_platform/pistorms.py delete mode 100644 ev3dev/auto.py create mode 100644 ev3dev/button.py rename ev3dev/{ => control}/GyroBalancer.py (100%) create mode 100644 ev3dev/control/__init__.py create mode 100644 ev3dev/control/rc_tank.py rename ev3dev/{ => control}/webserver.py (100%) delete mode 100644 ev3dev/core.py create mode 100644 ev3dev/display.py delete mode 100644 ev3dev/ev3.py delete mode 100644 ev3dev/evb.py delete mode 100644 ev3dev/helper.py create mode 100644 ev3dev/led.py create mode 100644 ev3dev/motor.py delete mode 100644 ev3dev/pistorms.py create mode 100644 ev3dev/port.py create mode 100644 ev3dev/power.py create mode 100644 ev3dev/sensor/__init__.py create mode 100644 ev3dev/sensor/lego.py create mode 100644 ev3dev/sound.py diff --git a/autogen-config.json b/autogen-config.json deleted file mode 100644 index a6e14a1..0000000 --- a/autogen-config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "files": [ - "ev3dev/core.py", - "ev3dev/ev3.py", - "ev3dev/brickpi.py", - "spec_version.py", - "docs/sensors.rst" - ], - "templateDir": "templates/" -} diff --git a/ev3dev/__init__.py b/ev3dev/__init__.py index e69de29..5b992f3 100644 --- a/ev3dev/__init__.py +++ b/ev3dev/__init__.py @@ -0,0 +1,311 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import os +import io +import fnmatch +import re +import stat +import errno +from os.path import abspath + +INPUT_AUTO = '' +OUTPUT_AUTO = '' + + +def get_current_platform(): + """ + Look in /sys/class/board-info/ to determine the platform type. + + This can return 'ev3', 'evb', 'pistorms', 'brickpi' or 'brickpi3'. + """ + board_info_dir = '/sys/class/board-info/' + + for board in os.listdir(board_info_dir): + uevent_filename = os.path.join(board_info_dir, board, 'uevent') + + if os.path.exists(uevent_filename): + with open(uevent_filename, 'r') as fh: + for line in fh.readlines(): + (key, value) = line.strip().split('=') + + if key == 'BOARD_INFO_MODEL': + + if value == 'LEGO MINDSTORMS EV3': + return 'ev3' + + elif value in ('FatcatLab EVB', 'QuestCape'): + return 'evb' + + elif value == 'PiStorms': + return 'pistorms' + + # This is the same for both BrickPi and BrickPi+. + # There is not a way to tell the difference. + elif value == 'Dexter Industries BrickPi': + return 'brickpi' + + elif value == 'Dexter Industries BrickPi3': + return 'brickpi3' + return None + + +# ----------------------------------------------------------------------------- +def list_device_names(class_path, name_pattern, **kwargs): + """ + This is a generator function that lists names of all devices matching the + provided parameters. + + Parameters: + class_path: class path of the device, a subdirectory of /sys/class. + For example, '/sys/class/tacho-motor'. + name_pattern: pattern that device name should match. + For example, 'sensor*' or 'motor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, address='outA', or + driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value + is a list, then a match against any entry of the list is + enough. + """ + + if not os.path.isdir(class_path): + return + + def matches(attribute, pattern): + try: + with io.FileIO(attribute) as f: + value = f.read().strip().decode() + except: + return False + + if isinstance(pattern, list): + return any([value.find(p) >= 0 for p in pattern]) + else: + return value.find(pattern) >= 0 + + for f in os.listdir(class_path): + if fnmatch.fnmatch(f, name_pattern): + path = class_path + '/' + f + if all([matches(path + '/' + k, kwargs[k]) for k in kwargs]): + yield f + + +# ----------------------------------------------------------------------------- +# Define the base class from which all other ev3dev classes are defined. + +class Device(object): + """The ev3dev device base class""" + + __slots__ = ['_path', 'connected', '_device_index', 'kwargs'] + + DEVICE_ROOT_PATH = '/sys/class' + + _DEVICE_INDEX = re.compile(r'^.*(\d+)$') + + def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs): + """Spin through the Linux sysfs class for the device type and find + a device that matches the provided name pattern and attributes (if any). + + Parameters: + class_name: class name of the device, a subdirectory of /sys/class. + For example, 'tacho-motor'. + name_pattern: pattern that device name should match. + For example, 'sensor*' or 'motor*'. Default value: '*'. + name_exact: when True, assume that the name_pattern provided is the + exact device name and use it directly. + keyword arguments: used for matching the corresponding device + attributes. For example, address='outA', or + driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value + is a list, then a match against any entry of the list is + enough. + + Example:: + + d = ev3dev.Device('tacho-motor', address='outA') + s = ev3dev.Device('lego-sensor', driver_name=['lego-ev3-us', 'lego-nxt-us']) + + When connected succesfully, the `connected` attribute is set to True. + """ + + classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) + self.kwargs = kwargs + + def get_index(file): + match = Device._DEVICE_INDEX.match(file) + if match: + return int(match.group(1)) + else: + return None + + if name_exact: + self._path = classpath + '/' + name_pattern + self._device_index = get_index(name_pattern) + self.connected = True + else: + try: + name = next(list_device_names(classpath, name_pattern, **kwargs)) + self._path = classpath + '/' + name + self._device_index = get_index(name) + self.connected = True + except StopIteration: + self._path = None + self._device_index = None + self.connected = False + + def __str__(self): + if 'address' in self.kwargs: + return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address')) + else: + return self.__class__.__name__ + + def _attribute_file_open(self, name): + path = os.path.join(self._path, name) + mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE]) + r_ok = mode & stat.S_IRGRP + w_ok = mode & stat.S_IWGRP + + if r_ok and w_ok: + mode = 'r+' + elif w_ok: + mode = 'w' + else: + mode = 'r' + + return io.FileIO(path, mode) + + def _get_attribute(self, attribute, name): + """Device attribute getter""" + if self.connected: + if attribute is None: + attribute = self._attribute_file_open( name ) + else: + attribute.seek(0) + return attribute, attribute.read().strip().decode() + else: + #log.info("%s: path %s, attribute %s" % (self, self._path, name)) + raise Exception("%s is not connected" % self) + + def _set_attribute(self, attribute, name, value): + """Device attribute setter""" + if self.connected: + try: + if attribute is None: + attribute = self._attribute_file_open( name ) + else: + attribute.seek(0) + + if isinstance(value, str): + value = value.encode() + attribute.write(value) + attribute.flush() + except Exception as ex: + self._raise_friendly_access_error(ex, name) + return attribute + else: + #log.info("%s: path %s, attribute %s" % (self, self._path, name)) + raise Exception("%s is not connected" % self) + + def _raise_friendly_access_error(self, driver_error, attribute): + if not isinstance(driver_error, OSError): + raise driver_error + + if driver_error.errno == errno.EINVAL: + if attribute == "speed_sp": + try: + max_speed = self.max_speed + except (AttributeError, Exception): + raise ValueError("The given speed value was out of range") from driver_error + else: + raise ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)) from driver_error + raise ValueError("One or more arguments were out of range or invalid") from driver_error + elif driver_error.errno == errno.ENODEV or driver_error.errno == errno.ENOENT: + # We will assume that a file-not-found error is the result of a disconnected device + # rather than a library error. If that isn't the case, at a minimum the underlying + # error info will be printed for debugging. + raise Exception("%s is no longer connected" % self) from driver_error + raise driver_error + + def get_attr_int(self, attribute, name): + attribute, value = self._get_attribute(attribute, name) + return attribute, int(value) + + def set_attr_int(self, attribute, name, value): + return self._set_attribute(attribute, name, str(int(value))) + + def set_attr_raw(self, attribute, name, value): + return self._set_attribute(attribute, name, value) + + def get_attr_string(self, attribute, name): + return self._get_attribute(attribute, name) + + def set_attr_string(self, attribute, name, value): + return self._set_attribute(attribute, name, value) + + def get_attr_line(self, attribute, name): + return self._get_attribute(attribute, name) + + def get_attr_set(self, attribute, name): + attribute, value = self.get_attr_line(attribute, name) + return attribute, [v.strip('[]') for v in value.split()] + + def get_attr_from_set(self, attribute, name): + attribute, value = self.get_attr_line(attribute, name) + for a in value.split(): + v = a.strip('[]') + if v != a: + return v + return "" + + @property + def device_index(self): + return self._device_index + + +def list_devices(class_name, name_pattern, **kwargs): + """ + This is a generator function that takes same arguments as `Device` class + and enumerates all devices present in the system that match the provided + arguments. + + Parameters: + class_name: class name of the device, a subdirectory of /sys/class. + For example, 'tacho-motor'. + name_pattern: pattern that device name should match. + For example, 'sensor*' or 'motor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, address='outA', or + driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value + is a list, then a match against any entry of the list is + enough. + """ + classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) + + return (Device(class_name, name, name_exact=True) + for name in list_device_names(classpath, name_pattern, **kwargs)) diff --git a/ev3dev/_platform/__init__.py b/ev3dev/_platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ev3dev/brickpi.py b/ev3dev/_platform/brickpi.py similarity index 57% rename from ev3dev/brickpi.py rename to ev3dev/_platform/brickpi.py index a86c568..c5f79e2 100644 --- a/ev3dev/brickpi.py +++ b/ev3dev/_platform/brickpi.py @@ -26,8 +26,7 @@ An assortment of classes modeling specific features of the BrickPi. """ -from .core import * - +from collections import OrderedDict OUTPUT_A = 'ttyAMA0:MA' OUTPUT_B = 'ttyAMA0:MB' @@ -39,51 +38,17 @@ INPUT_3 = 'ttyAMA0:S3' INPUT_4 = 'ttyAMA0:S4' +BUTTONS_FILENAME = None +EVDEV_DEVICE_NAME = None -class Leds(object): - """ - The BrickPi LEDs. - """ - blue_led1 = Led(name_pattern='brickpi:led1:blue:ev3dev') - blue_led2 = Led(name_pattern='brickpi:led2:blue:ev3dev') - - LED1 = ( blue_led1, ) - LED2 = ( blue_led2, ) - - BLACK = ( 0, ) - BLUE = ( 1, ) - - @staticmethod - def set_color(group, color, pct=1): - """ - Sets brigthness of leds in the given group to the values specified in - color tuple. When percentage is specified, brightness of each led is - reduced proportionally. - - Example:: - - Leds.set_color(LEFT, AMBER) - """ - for l, v in zip(group, color): - l.brightness_pct = v * pct - - @staticmethod - def set(group, **kwargs): - """ - Set attributes for each led in group. - - Example:: +LEDS = OrderedDict() +LEDS['blue_led1'] = 'brickpi:led1:blue:ev3dev' +LEDS['blue_led2'] = 'brickpi:led2:blue:ev3dev' - Leds.set(LEFT, brightness_pct=0.5, trigger='timer') - """ - for led in group: - for k in kwargs: - setattr(led, k, kwargs[k]) +LED_GROUPS = OrderedDict() +LED_GROUPS['LED1'] = ('blue_led1',) +LED_GROUPS['LED2'] = ('blue_led2',) - @staticmethod - def all_off(): - """ - Turn all leds off - """ - Leds.blue_led1.brightness = 0 - Leds.blue_led2.brightness = 0 +LED_COLORS = OrderedDict() +LED_COLORS['BLACK'] = (0,) +LED_COLORS['BLUE'] = (1,) diff --git a/ev3dev/_platform/brickpi3.py b/ev3dev/_platform/brickpi3.py new file mode 100644 index 0000000..9054fc6 --- /dev/null +++ b/ev3dev/_platform/brickpi3.py @@ -0,0 +1,3 @@ + +BUTTONS_FILENAME = None +EVDEV_DEVICE_NAME = None diff --git a/ev3dev/_platform/ev3.py b/ev3dev/_platform/ev3.py new file mode 100644 index 0000000..573d3df --- /dev/null +++ b/ev3dev/_platform/ev3.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# ------------------------------------------------------------------------------ +# Copyright (c) 2015 Eric Pascual +# +# 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 NON INFRINGEMENT. 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. +# ----------------------------------------------------------------------------- + +""" +An assortment of classes modeling specific features of the EV3 brick. +""" + +from collections import OrderedDict + +OUTPUT_A = 'outA' +OUTPUT_B = 'outB' +OUTPUT_C = 'outC' +OUTPUT_D = 'outD' + +INPUT_1 = 'in1' +INPUT_2 = 'in2' +INPUT_3 = 'in3' +INPUT_4 = 'in4' + +BUTTONS_FILENAME = '/dev/input/by-path/platform-gpio_keys-event' +EVDEV_DEVICE_NAME = 'EV3 brick buttons' + +LEDS = OrderedDict() +LEDS['red_left'] = 'led0:red:brick-status' +LEDS['red_right'] = 'led1:red:brick-status' +LEDS['green_left'] = 'led0:green:brick-status' +LEDS['green_right'] = 'led1:green:brick-status' + +LED_GROUPS = OrderedDict() +LED_GROUPS['LEFT'] = ('red_left', 'green_left') +LED_GROUPS['RIGHT'] = ('red_right', 'green_right') + +LED_COLORS = OrderedDict() +LED_COLORS['BLACK'] = (0, 0) +LED_COLORS['RED'] = (1, 0) +LED_COLORS['GREEN'] = (0, 1) +LED_COLORS['AMBER'] = (1, 1) +LED_COLORS['ORANGE'] = (1, 0.5) +LED_COLORS['YELLOW'] = (0.1, 1) diff --git a/ev3dev/_platform/evb.py b/ev3dev/_platform/evb.py new file mode 100644 index 0000000..62297cf --- /dev/null +++ b/ev3dev/_platform/evb.py @@ -0,0 +1,22 @@ + +""" +An assortment of classes modeling specific features of the EVB. +""" + +OUTPUT_A = 'outA' +OUTPUT_B = 'outB' +OUTPUT_C = 'outC' +OUTPUT_D = 'outD' + +INPUT_1 = 'in1' +INPUT_2 = 'in2' +INPUT_3 = 'in3' +INPUT_4 = 'in4' + +BUTTONS_FILENAME = '/dev/input/by-path/platform-evb-buttons-event' +EVDEV_DEVICE_NAME = 'evb-input' + +# EVB does not have LEDs +LEDS = {} +LED_GROUPS = {} +LED_COLORS = {} diff --git a/ev3dev/_platform/pistorms.py b/ev3dev/_platform/pistorms.py new file mode 100644 index 0000000..3e09ff3 --- /dev/null +++ b/ev3dev/_platform/pistorms.py @@ -0,0 +1,38 @@ + +""" +An assortment of classes modeling specific features of the PiStorms. +""" +from collections import OrderedDict + +OUTPUT_A = 'pistorms:BBM1' +OUTPUT_B = 'pistorms:BBM2' +OUTPUT_C = 'pistorms:BAM2' +OUTPUT_D = 'pistorms:BAM1' + +INPUT_1 = 'pistorms:BBS1' +INPUT_2 = 'pistorms:BBS2' +INPUT_3 = 'pistorms:BAS2' +INPUT_4 = 'pistorms:BAS1' + + +BUTTONS_FILENAME = None +EVDEV_DEVICE_NAME = None + + +LEDS = OrderedDict() +LEDS['red_left'] = 'pistorms:BA:red:brick-status' +LEDS['green_left'] = 'pistorms:BA:green:brick-statu' +LEDS['blue_left'] = 'pistorms:BA:blue:brick-status' +LEDS['red_right'] = 'pistorms:BB:red:brick-status' +LEDS['green_right'] = 'pistorms:BB:green:brick-statu' +LEDS['blue_right'] = 'pistorms:BB:blue:brick-status' + +LED_GROUPS = OrderedDict() +LED_GROUPS['LEFT'] = ('red_left', 'green_left', 'blue_left') +LED_GROUPS['RIGHT'] = ('red_right', 'green_right', 'blue_right') + +LED_COLORS = OrderedDict() +LED_COLORS['BLACK'] = (0, 0, 0) +LED_COLORS['RED'] = (1, 0, 0) +LED_COLORS['GREEN'] = (0, 1, 0) +LED_COLORS['BLUE'] = (0, 1, 1) diff --git a/ev3dev/auto.py b/ev3dev/auto.py deleted file mode 100644 index b819e51..0000000 --- a/ev3dev/auto.py +++ /dev/null @@ -1,17 +0,0 @@ - -from ev3dev.core import get_current_platform - -current_platform = get_current_platform() - -if current_platform == 'brickpi': - from .brickpi import * - -elif current_platform == 'evb': - from .evb import * - -elif current_platform == 'pistorms': - from .pistorms import * - -else: - # Import ev3 by default, so that it is covered by documentation. - from .ev3 import * diff --git a/ev3dev/button.py b/ev3dev/button.py new file mode 100644 index 0000000..45fb1fc --- /dev/null +++ b/ev3dev/button.py @@ -0,0 +1,320 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import array +import time +import evdev +from ev3dev import get_current_platform + +try: + # This is a linux-specific module. + # It is required by the Button() class, but failure to import it may be + # safely ignored if one just needs to run API tests on Windows. + import fcntl +except ImportError: + print("WARNING: Failed to import fcntl. Button class will be unuseable!") + + +# Import the button filenames, this is platform specific +platform = get_current_platform() + +if platform == 'ev3': + from ev3dev._platform.ev3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + +elif platform == 'evb': + from ev3dev._platform.evb import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + +elif platform == 'pistorms': + from ev3dev._platform.pistorms import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + +elif platform == 'brickpi': + from ev3dev._platform.brickpi import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + +elif platform == 'brickpi3': + from ev3dev._platform.brickpi3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + +else: + raise Exception("Unsupported platform '%s'" % platform) + + +class MissingButton(Exception): + pass + + +class ButtonBase(object): + """ + Abstract button interface. + """ + _state = set([]) + + def __str__(self): + return self.__class__.__name__ + + @staticmethod + def on_change(changed_buttons): + """ + This handler is called by `process()` whenever state of any button has + changed since last `process()` call. `changed_buttons` is a list of + tuples of changed button names and their states. + """ + pass + + def any(self): + """ + Checks if any button is pressed. + """ + return bool(self.buttons_pressed) + + def check_buttons(self, buttons=[]): + """ + Check if currently pressed buttons exactly match the given list. + """ + return set(self.buttons_pressed) == set(buttons) + + @property + def evdev_device(self): + """ + Return our corresponding evdev device object + """ + devices = [evdev.InputDevice(fn) for fn in evdev.list_devices()] + + for device in devices: + if device.name == self.evdev_device_name: + return device + + raise Exception("%s: could not find evdev device '%s'" % (self, self.evdev_device_name)) + + def process(self, new_state=None): + """ + Check for currenly pressed buttons. If the new state differs from the + old state, call the appropriate button event handlers. + """ + if new_state is None: + new_state = set(self.buttons_pressed) + old_state = self._state + self._state = new_state + + state_diff = new_state.symmetric_difference(old_state) + for button in state_diff: + handler = getattr(self, 'on_' + button) + + if handler is not None: + handler(button in new_state) + + if self.on_change is not None and state_diff: + self.on_change([(button, button in new_state) for button in state_diff]) + + def process_forever(self): + for event in self.evdev_device.read_loop(): + if event.type == evdev.ecodes.EV_KEY: + self.process() + + @property + def buttons_pressed(self): + raise NotImplementedError() + + def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): + tic = time.time() + + # wait_for_button_press/release can be a list of buttons or a string + # with the name of a single button. If it is a string of a single + # button convert that to a list. + if isinstance(wait_for_button_press, str): + wait_for_button_press = [wait_for_button_press, ] + + if isinstance(wait_for_button_release, str): + wait_for_button_release = [wait_for_button_release, ] + + for event in self.evdev_device.read_loop(): + if event.type == evdev.ecodes.EV_KEY: + all_pressed = True + all_released = True + pressed = self.buttons_pressed + + for button in wait_for_button_press: + if button not in pressed: + all_pressed = False + break + + for button in wait_for_button_release: + if button in pressed: + all_released = False + break + + if all_pressed and all_released: + return True + + if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: + return False + + def wait_for_pressed(self, buttons, timeout_ms=None): + return self._wait(buttons, [], timeout_ms) + + def wait_for_released(self, buttons, timeout_ms=None): + return self._wait([], buttons, timeout_ms) + + def wait_for_bump(self, buttons, timeout_ms=None): + """ + Wait for the button to be pressed down and then released. + Both actions must happen within timeout_ms. + """ + start_time = time.time() + + if self.wait_for_pressed(buttons, timeout_ms): + if timeout_ms is not None: + timeout_ms -= int((time.time() - start_time) * 1000) + return self.wait_for_released(buttons, timeout_ms) + + return False + + +class ButtonEVIO(ButtonBase): + """ + Provides a generic button reading mechanism that works with event interface + and may be adapted to platform specific implementations. + + This implementation depends on the availability of the EVIOCGKEY ioctl + to be able to read the button state buffer. See Linux kernel source + in /include/uapi/linux/input.h for details. + """ + + KEY_MAX = 0x2FF + KEY_BUF_LEN = int((KEY_MAX + 7) / 8) + EVIOCGKEY = (2 << (14 + 8 + 8) | KEY_BUF_LEN << (8 + 8) | ord('E') << 8 | 0x18) + + _buttons = {} + + def __init__(self): + ButtonBase.__init__(self) + self._file_cache = {} + self._buffer_cache = {} + + for b in self._buttons: + name = self._buttons[b]['name'] + + if name is None: + raise MissingButton("Button '%s' is not available on this platform" % b) + + if name not in self._file_cache: + self._file_cache[name] = open(name, 'rb', 0) + self._buffer_cache[name] = array.array('B', [0] * self.KEY_BUF_LEN) + + def _button_file(self, name): + return self._file_cache[name] + + def _button_buffer(self, name): + return self._buffer_cache[name] + + @property + def buttons_pressed(self): + """ + Returns list of names of pressed buttons. + """ + for b in self._buffer_cache: + fcntl.ioctl(self._button_file(b), self.EVIOCGKEY, self._buffer_cache[b]) + + pressed = [] + for k, v in self._buttons.items(): + buf = self._buffer_cache[v['name']] + bit = v['value'] + + if bool(buf[int(bit / 8)] & 1 << bit % 8): + pressed.append(k) + + return pressed + + +class Button(ButtonEVIO): + """ + EVB Buttons + """ + + _buttons = { + 'up': {'name': BUTTONS_FILENAME, 'value': 103}, + 'down': {'name': BUTTONS_FILENAME, 'value': 108}, + 'left': {'name': BUTTONS_FILENAME, 'value': 105}, + 'right': {'name': BUTTONS_FILENAME, 'value': 106}, + 'enter': {'name': BUTTONS_FILENAME, 'value': 28}, + 'backspace': {'name': BUTTONS_FILENAME, 'value': 14}, + } + evdev_device_name = EVDEV_DEVICE_NAME + + ''' + These handlers are called by `process()` whenever state of 'up', 'down', + etc buttons have changed since last `process()` call + ''' + on_up = None + on_down = None + on_left = None + on_right = None + on_enter = None + on_backspace = None + + @property + def up(self): + """ + Check if 'up' button is pressed. + """ + return 'up' in self.buttons_pressed + + @property + def down(self): + """ + Check if 'down' button is pressed. + """ + return 'down' in self.buttons_pressed + + @property + def left(self): + """ + Check if 'left' button is pressed. + """ + return 'left' in self.buttons_pressed + + @property + def right(self): + """ + Check if 'right' button is pressed. + """ + return 'right' in self.buttons_pressed + + @property + def enter(self): + """ + Check if 'enter' button is pressed. + """ + return 'enter' in self.buttons_pressed + + @property + def backspace(self): + """ + Check if 'backspace' button is pressed. + """ + return 'backspace' in self.buttons_pressed diff --git a/ev3dev/GyroBalancer.py b/ev3dev/control/GyroBalancer.py similarity index 100% rename from ev3dev/GyroBalancer.py rename to ev3dev/control/GyroBalancer.py diff --git a/ev3dev/control/__init__.py b/ev3dev/control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ev3dev/control/rc_tank.py b/ev3dev/control/rc_tank.py new file mode 100644 index 0000000..7f6fec6 --- /dev/null +++ b/ev3dev/control/rc_tank.py @@ -0,0 +1,45 @@ + +import logging +from ev3dev.auto import InfraredSensor, MoveTank +from time import sleep + +log = logging.getLogger(__name__) + +# ============ +# Tank classes +# ============ +class RemoteControlledTank(MoveTank): + + def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed=400, channel=1): + MoveTank.__init__(self, left_motor_port, right_motor_port) + self.set_polarity(polarity) + + left_motor = self.motors[left_motor_port] + right_motor = self.motors[right_motor_port] + self.speed_sp = speed + self.remote = InfraredSensor() + self.remote.on_channel1_top_left = self.make_move(left_motor, self.speed_sp) + self.remote.on_channel1_bottom_left = self.make_move(left_motor, self.speed_sp* -1) + self.remote.on_channel1_top_right = self.make_move(right_motor, self.speed_sp) + self.remote.on_channel1_bottom_right = self.make_move(right_motor, self.speed_sp * -1) + self.channel = channel + + def make_move(self, motor, dc_sp): + def move(state): + if state: + motor.run_forever(speed_sp=dc_sp) + else: + motor.stop() + return move + + def main(self): + + try: + while True: + self.remote.process(self.channel) + sleep(0.01) + + # Exit cleanly so that all motors are stopped + except (KeyboardInterrupt, Exception) as e: + log.exception(e) + self.stop() diff --git a/ev3dev/webserver.py b/ev3dev/control/webserver.py similarity index 100% rename from ev3dev/webserver.py rename to ev3dev/control/webserver.py diff --git a/ev3dev/core.py b/ev3dev/core.py deleted file mode 100644 index 1ad7fc5..0000000 --- a/ev3dev/core.py +++ /dev/null @@ -1,4210 +0,0 @@ -# ----------------------------------------------------------------------------- -# Copyright (c) 2015 Ralph Hempel -# Copyright (c) 2015 Anton Vanhoucke -# Copyright (c) 2015 Denis Demidov -# Copyright (c) 2015 Eric Pascual -# -# 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 sys - -if sys.version_info < (3,4): - raise SystemError('Must be using Python 3.4 or higher') - -import os -import io -import fnmatch -import numbers -import array -import mmap -import ctypes -import re -import select -import shlex -import stat -import time -import errno -import evdev -from os.path import abspath -from struct import pack, unpack -from subprocess import Popen, check_output, PIPE - -try: - # This is a linux-specific module. - # It is required by the Button() class, but failure to import it may be - # safely ignored if one just needs to run API tests on Windows. - import fcntl -except ImportError: - print("WARNING: Failed to import fcntl. Button class will be unuseable!") - -INPUT_AUTO = '' -OUTPUT_AUTO = '' - -# The number of milliseconds we wait for the state of a motor to -# update to 'running' in the "on_for_XYZ" methods of the Motor class -WAIT_RUNNING_TIMEOUT = 100 - - -def get_current_platform(): - """ - Look in /sys/class/board-info/ to determine the platform type. - - This can return 'ev3', 'evb', 'pistorms', 'brickpi' or 'brickpi3'. - """ - board_info_dir = '/sys/class/board-info/' - - for board in os.listdir(board_info_dir): - uevent_filename = os.path.join(board_info_dir, board, 'uevent') - - if os.path.exists(uevent_filename): - with open(uevent_filename, 'r') as fh: - for line in fh.readlines(): - (key, value) = line.strip().split('=') - - if key == 'BOARD_INFO_MODEL': - - if value == 'LEGO MINDSTORMS EV3': - return 'ev3' - - elif value in ('FatcatLab EVB', 'QuestCape'): - return 'evb' - - elif value == 'PiStorms': - return 'pistorms' - - # This is the same for both BrickPi and BrickPi+. - # There is not a way to tell the difference. - elif value == 'Dexter Industries BrickPi': - return 'brickpi' - - elif value == 'Dexter Industries BrickPi3': - return 'brickpi3' - return None - - -# ----------------------------------------------------------------------------- -def list_device_names(class_path, name_pattern, **kwargs): - """ - This is a generator function that lists names of all devices matching the - provided parameters. - - Parameters: - class_path: class path of the device, a subdirectory of /sys/class. - For example, '/sys/class/tacho-motor'. - name_pattern: pattern that device name should match. - For example, 'sensor*' or 'motor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, address='outA', or - driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value - is a list, then a match against any entry of the list is - enough. - """ - - if not os.path.isdir(class_path): - return - - def matches(attribute, pattern): - try: - with io.FileIO(attribute) as f: - value = f.read().strip().decode() - except: - return False - - if isinstance(pattern, list): - return any([value.find(p) >= 0 for p in pattern]) - else: - return value.find(pattern) >= 0 - - for f in os.listdir(class_path): - if fnmatch.fnmatch(f, name_pattern): - path = class_path + '/' + f - if all([matches(path + '/' + k, kwargs[k]) for k in kwargs]): - yield f - - -# ----------------------------------------------------------------------------- -# Define the base class from which all other ev3dev classes are defined. - -class Device(object): - """The ev3dev device base class""" - - __slots__ = ['_path', 'connected', '_device_index', 'kwargs'] - - DEVICE_ROOT_PATH = '/sys/class' - - _DEVICE_INDEX = re.compile(r'^.*(\d+)$') - - def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs): - """Spin through the Linux sysfs class for the device type and find - a device that matches the provided name pattern and attributes (if any). - - Parameters: - class_name: class name of the device, a subdirectory of /sys/class. - For example, 'tacho-motor'. - name_pattern: pattern that device name should match. - For example, 'sensor*' or 'motor*'. Default value: '*'. - name_exact: when True, assume that the name_pattern provided is the - exact device name and use it directly. - keyword arguments: used for matching the corresponding device - attributes. For example, address='outA', or - driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value - is a list, then a match against any entry of the list is - enough. - - Example:: - - d = ev3dev.Device('tacho-motor', address='outA') - s = ev3dev.Device('lego-sensor', driver_name=['lego-ev3-us', 'lego-nxt-us']) - - When connected succesfully, the `connected` attribute is set to True. - """ - - classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) - self.kwargs = kwargs - - def get_index(file): - match = Device._DEVICE_INDEX.match(file) - if match: - return int(match.group(1)) - else: - return None - - if name_exact: - self._path = classpath + '/' + name_pattern - self._device_index = get_index(name_pattern) - self.connected = True - else: - try: - name = next(list_device_names(classpath, name_pattern, **kwargs)) - self._path = classpath + '/' + name - self._device_index = get_index(name) - self.connected = True - except StopIteration: - self._path = None - self._device_index = None - self.connected = False - - def __str__(self): - if 'address' in self.kwargs: - return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address')) - else: - return self.__class__.__name__ - - def _attribute_file_open(self, name): - path = os.path.join(self._path, name) - mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE]) - r_ok = mode & stat.S_IRGRP - w_ok = mode & stat.S_IWGRP - - if r_ok and w_ok: - mode = 'r+' - elif w_ok: - mode = 'w' - else: - mode = 'r' - - return io.FileIO(path, mode) - - def _get_attribute(self, attribute, name): - """Device attribute getter""" - if self.connected: - if attribute is None: - attribute = self._attribute_file_open( name ) - else: - attribute.seek(0) - return attribute, attribute.read().strip().decode() - else: - #log.info("%s: path %s, attribute %s" % (self, self._path, name)) - raise Exception("%s is not connected" % self) - - def _set_attribute(self, attribute, name, value): - """Device attribute setter""" - if self.connected: - try: - if attribute is None: - attribute = self._attribute_file_open( name ) - else: - attribute.seek(0) - - if isinstance(value, str): - value = value.encode() - attribute.write(value) - attribute.flush() - except Exception as ex: - self._raise_friendly_access_error(ex, name) - return attribute - else: - #log.info("%s: path %s, attribute %s" % (self, self._path, name)) - raise Exception("%s is not connected" % self) - - def _raise_friendly_access_error(self, driver_error, attribute): - if not isinstance(driver_error, OSError): - raise driver_error - - if driver_error.errno == errno.EINVAL: - if attribute == "speed_sp": - try: - max_speed = self.max_speed - except (AttributeError, Exception): - raise ValueError("The given speed value was out of range") from driver_error - else: - raise ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)) from driver_error - raise ValueError("One or more arguments were out of range or invalid") from driver_error - elif driver_error.errno == errno.ENODEV or driver_error.errno == errno.ENOENT: - # We will assume that a file-not-found error is the result of a disconnected device - # rather than a library error. If that isn't the case, at a minimum the underlying - # error info will be printed for debugging. - raise Exception("%s is no longer connected" % self) from driver_error - raise driver_error - - def get_attr_int(self, attribute, name): - attribute, value = self._get_attribute(attribute, name) - return attribute, int(value) - - def set_attr_int(self, attribute, name, value): - return self._set_attribute(attribute, name, str(int(value))) - - def set_attr_raw(self, attribute, name, value): - return self._set_attribute(attribute, name, value) - - def get_attr_string(self, attribute, name): - return self._get_attribute(attribute, name) - - def set_attr_string(self, attribute, name, value): - return self._set_attribute(attribute, name, value) - - def get_attr_line(self, attribute, name): - return self._get_attribute(attribute, name) - - def get_attr_set(self, attribute, name): - attribute, value = self.get_attr_line(attribute, name) - return attribute, [v.strip('[]') for v in value.split()] - - def get_attr_from_set(self, attribute, name): - attribute, value = self.get_attr_line(attribute, name) - for a in value.split(): - v = a.strip('[]') - if v != a: - return v - return "" - - @property - def device_index(self): - return self._device_index - -def list_devices(class_name, name_pattern, **kwargs): - """ - This is a generator function that takes same arguments as `Device` class - and enumerates all devices present in the system that match the provided - arguments. - - Parameters: - class_name: class name of the device, a subdirectory of /sys/class. - For example, 'tacho-motor'. - name_pattern: pattern that device name should match. - For example, 'sensor*' or 'motor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, address='outA', or - driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value - is a list, then a match against any entry of the list is - enough. - """ - classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) - - return (Device(class_name, name, name_exact=True) - for name in list_device_names(classpath, name_pattern, **kwargs)) - - -class Motor(Device): - - """ - The motor class provides a uniform interface for using motors with - positional and directional feedback such as the EV3 and NXT motors. - This feedback allows for precise control of the motors. This is the - most common type of motor, so we just call it `motor`. - - The way to configure a motor is to set the '_sp' attributes when - calling a command or before. Only in 'run_direct' mode attribute - changes are processed immediately, in the other modes they only - take place when a new command is issued. - """ - - SYSTEM_CLASS_NAME = 'tacho-motor' - SYSTEM_DEVICE_NAME_CONVENTION = '*' - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(Motor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._address = None - self._command = None - self._commands = None - self._count_per_rot = None - self._count_per_m = None - self._driver_name = None - self._duty_cycle = None - self._duty_cycle_sp = None - self._full_travel_count = None - self._polarity = None - self._position = None - self._position_p = None - self._position_i = None - self._position_d = None - self._position_sp = None - self._max_speed = None - self._speed = None - self._speed_sp = None - self._ramp_up_sp = None - self._ramp_down_sp = None - self._speed_p = None - self._speed_i = None - self._speed_d = None - self._state = None - self._stop_action = None - self._stop_actions = None - self._time_sp = None - self._poll = None - - __slots__ = [ - '_address', - '_command', - '_commands', - '_count_per_rot', - '_count_per_m', - '_driver_name', - '_duty_cycle', - '_duty_cycle_sp', - '_full_travel_count', - '_polarity', - '_position', - '_position_p', - '_position_i', - '_position_d', - '_position_sp', - '_max_speed', - '_speed', - '_speed_sp', - '_ramp_up_sp', - '_ramp_down_sp', - '_speed_p', - '_speed_i', - '_speed_d', - '_state', - '_stop_action', - '_stop_actions', - '_time_sp', - '_poll', - ] - - @property - def address(self): - """ - Returns the name of the port that this motor is connected to. - """ - self._address, value = self.get_attr_string(self._address, 'address') - return value - - @property - def command(self): - """ - Sends a command to the motor controller. See `commands` for a list of - possible values. - """ - raise Exception("command is a write-only property!") - - @command.setter - def command(self, value): - self._command = self.set_attr_string(self._command, 'command', value) - - @property - def commands(self): - """ - Returns a list of commands that are supported by the motor - controller. Possible values are `run-forever`, `run-to-abs-pos`, `run-to-rel-pos`, - `run-timed`, `run-direct`, `stop` and `reset`. Not all commands may be supported. - - - `run-forever` will cause the motor to run until another command is sent. - - `run-to-abs-pos` will run to an absolute position specified by `position_sp` - and then stop using the action specified in `stop_action`. - - `run-to-rel-pos` will run to a position relative to the current `position` value. - The new position will be current `position` + `position_sp`. When the new - position is reached, the motor will stop using the action specified by `stop_action`. - - `run-timed` will run the motor for the amount of time specified in `time_sp` - and then stop the motor using the action specified by `stop_action`. - - `run-direct` will run the motor at the duty cycle specified by `duty_cycle_sp`. - Unlike other run commands, changing `duty_cycle_sp` while running *will* - take effect immediately. - - `stop` will stop any of the run commands before they are complete using the - action specified by `stop_action`. - - `reset` will reset all of the motor parameter attributes to their default value. - This will also have the effect of stopping the motor. - """ - self._commands, value = self.get_attr_set(self._commands, 'commands') - return value - - @property - def count_per_rot(self): - """ - Returns the number of tacho counts in one rotation of the motor. Tacho counts - are used by the position and speed attributes, so you can use this value - to convert rotations or degrees to tacho counts. (rotation motors only) - """ - self._count_per_rot, value = self.get_attr_int(self._count_per_rot, 'count_per_rot') - return value - - @property - def count_per_m(self): - """ - Returns the number of tacho counts in one meter of travel of the motor. Tacho - counts are used by the position and speed attributes, so you can use this - value to convert from distance to tacho counts. (linear motors only) - """ - self._count_per_m, value = self.get_attr_int(self._count_per_m, 'count_per_m') - return value - - @property - def driver_name(self): - """ - Returns the name of the driver that provides this tacho motor device. - """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') - return value - - @property - def duty_cycle(self): - """ - Returns the current duty cycle of the motor. Units are percent. Values - are -100 to 100. - """ - self._duty_cycle, value = self.get_attr_int(self._duty_cycle, 'duty_cycle') - return value - - @property - def duty_cycle_sp(self): - """ - Writing sets the duty cycle setpoint. Reading returns the current value. - Units are in percent. Valid values are -100 to 100. A negative value causes - the motor to rotate in reverse. - """ - self._duty_cycle_sp, value = self.get_attr_int(self._duty_cycle_sp, 'duty_cycle_sp') - return value - - @duty_cycle_sp.setter - def duty_cycle_sp(self, value): - self._duty_cycle_sp = self.set_attr_int(self._duty_cycle_sp, 'duty_cycle_sp', value) - - @property - def full_travel_count(self): - """ - Returns the number of tacho counts in the full travel of the motor. When - combined with the `count_per_m` atribute, you can use this value to - calculate the maximum travel distance of the motor. (linear motors only) - """ - self._full_travel_count, value = self.get_attr_int(self._full_travel_count, 'full_travel_count') - return value - - @property - def polarity(self): - """ - Sets the polarity of the motor. With `normal` polarity, a positive duty - cycle will cause the motor to rotate clockwise. With `inversed` polarity, - a positive duty cycle will cause the motor to rotate counter-clockwise. - Valid values are `normal` and `inversed`. - """ - self._polarity, value = self.get_attr_string(self._polarity, 'polarity') - return value - - @polarity.setter - def polarity(self, value): - self._polarity = self.set_attr_string(self._polarity, 'polarity', value) - - @property - def position(self): - """ - Returns the current position of the motor in pulses of the rotary - encoder. When the motor rotates clockwise, the position will increase. - Likewise, rotating counter-clockwise causes the position to decrease. - Writing will set the position to that value. - """ - self._position, value = self.get_attr_int(self._position, 'position') - return value - - @position.setter - def position(self, value): - self._position = self.set_attr_int(self._position, 'position', value) - - @property - def position_p(self): - """ - The proportional constant for the position PID. - """ - self._position_p, value = self.get_attr_int(self._position_p, 'hold_pid/Kp') - return value - - @position_p.setter - def position_p(self, value): - self._position_p = self.set_attr_int(self._position_p, 'hold_pid/Kp', value) - - @property - def position_i(self): - """ - The integral constant for the position PID. - """ - self._position_i, value = self.get_attr_int(self._position_i, 'hold_pid/Ki') - return value - - @position_i.setter - def position_i(self, value): - self._position_i = self.set_attr_int(self._position_i, 'hold_pid/Ki', value) - - @property - def position_d(self): - """ - The derivative constant for the position PID. - """ - self._position_d, value = self.get_attr_int(self._position_d, 'hold_pid/Kd') - return value - - @position_d.setter - def position_d(self, value): - self._position_d = self.set_attr_int(self._position_d, 'hold_pid/Kd', value) - - @property - def position_sp(self): - """ - Writing specifies the target position for the `run-to-abs-pos` and `run-to-rel-pos` - commands. Reading returns the current value. Units are in tacho counts. You - can use the value returned by `counts_per_rot` to convert tacho counts to/from - rotations or degrees. - """ - self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') - return value - - @position_sp.setter - def position_sp(self, value): - self._position_sp = self.set_attr_int(self._position_sp, 'position_sp', value) - - @property - def max_speed(self): - """ - Returns the maximum value that is accepted by the `speed_sp` attribute. This - may be slightly different than the maximum speed that a particular motor can - reach - it's the maximum theoretical speed. - """ - self._max_speed, value = self.get_attr_int(self._max_speed, 'max_speed') - return value - - @property - def speed(self): - """ - Returns the current motor speed in tacho counts per second. Note, this is - not necessarily degrees (although it is for LEGO motors). Use the `count_per_rot` - attribute to convert this value to RPM or deg/sec. - """ - self._speed, value = self.get_attr_int(self._speed, 'speed') - return value - - @property - def speed_sp(self): - """ - Writing sets the target speed in tacho counts per second used for all `run-*` - commands except `run-direct`. Reading returns the current value. A negative - value causes the motor to rotate in reverse with the exception of `run-to-*-pos` - commands where the sign is ignored. Use the `count_per_rot` attribute to convert - RPM or deg/sec to tacho counts per second. Use the `count_per_m` attribute to - convert m/s to tacho counts per second. - """ - self._speed_sp, value = self.get_attr_int(self._speed_sp, 'speed_sp') - return value - - @speed_sp.setter - def speed_sp(self, value): - self._speed_sp = self.set_attr_int(self._speed_sp, 'speed_sp', value) - - @property - def ramp_up_sp(self): - """ - Writing sets the ramp up setpoint. Reading returns the current value. Units - are in milliseconds and must be positive. When set to a non-zero value, the - motor speed will increase from 0 to 100% of `max_speed` over the span of this - setpoint. The actual ramp time is the ratio of the difference between the - `speed_sp` and the current `speed` and max_speed multiplied by `ramp_up_sp`. - """ - self._ramp_up_sp, value = self.get_attr_int(self._ramp_up_sp, 'ramp_up_sp') - return value - - @ramp_up_sp.setter - def ramp_up_sp(self, value): - self._ramp_up_sp = self.set_attr_int(self._ramp_up_sp, 'ramp_up_sp', value) - - @property - def ramp_down_sp(self): - """ - Writing sets the ramp down setpoint. Reading returns the current value. Units - are in milliseconds and must be positive. When set to a non-zero value, the - motor speed will decrease from 0 to 100% of `max_speed` over the span of this - setpoint. The actual ramp time is the ratio of the difference between the - `speed_sp` and the current `speed` and max_speed multiplied by `ramp_down_sp`. - """ - self._ramp_down_sp, value = self.get_attr_int(self._ramp_down_sp, 'ramp_down_sp') - return value - - @ramp_down_sp.setter - def ramp_down_sp(self, value): - self._ramp_down_sp = self.set_attr_int(self._ramp_down_sp, 'ramp_down_sp', value) - - @property - def speed_p(self): - """ - The proportional constant for the speed regulation PID. - """ - self._speed_p, value = self.get_attr_int(self._speed_p, 'speed_pid/Kp') - return value - - @speed_p.setter - def speed_p(self, value): - self._speed_p = self.set_attr_int(self._speed_p, 'speed_pid/Kp', value) - - @property - def speed_i(self): - """ - The integral constant for the speed regulation PID. - """ - self._speed_i, value = self.get_attr_int(self._speed_i, 'speed_pid/Ki') - return value - - @speed_i.setter - def speed_i(self, value): - self._speed_i = self.set_attr_int(self._speed_i, 'speed_pid/Ki', value) - - @property - def speed_d(self): - """ - The derivative constant for the speed regulation PID. - """ - self._speed_d, value = self.get_attr_int(self._speed_d, 'speed_pid/Kd') - return value - - @speed_d.setter - def speed_d(self, value): - self._speed_d = self.set_attr_int(self._speed_d, 'speed_pid/Kd', value) - - @property - def state(self): - """ - Reading returns a list of state flags. Possible flags are - `running`, `ramping`, `holding`, `overloaded` and `stalled`. - """ - self._state, value = self.get_attr_set(self._state, 'state') - return value - - @property - def stop_action(self): - """ - Reading returns the current stop action. Writing sets the stop action. - The value determines the motors behavior when `command` is set to `stop`. - Also, it determines the motors behavior when a run command completes. See - `stop_actions` for a list of possible values. - """ - self._stop_action, value = self.get_attr_string(self._stop_action, 'stop_action') - return value - - @stop_action.setter - def stop_action(self, value): - self._stop_action = self.set_attr_string(self._stop_action, 'stop_action', value) - - @property - def stop_actions(self): - """ - Returns a list of stop actions supported by the motor controller. - Possible values are `coast`, `brake` and `hold`. `coast` means that power will - be removed from the motor and it will freely coast to a stop. `brake` means - that power will be removed from the motor and a passive electrical load will - be placed on the motor. This is usually done by shorting the motor terminals - together. This load will absorb the energy from the rotation of the motors and - cause the motor to stop more quickly than coasting. `hold` does not remove - power from the motor. Instead it actively tries to hold the motor at the current - position. If an external force tries to turn the motor, the motor will 'push - back' to maintain its position. - """ - self._stop_actions, value = self.get_attr_set(self._stop_actions, 'stop_actions') - return value - - @property - def time_sp(self): - """ - Writing specifies the amount of time the motor will run when using the - `run-timed` command. Reading returns the current value. Units are in - milliseconds. - """ - self._time_sp, value = self.get_attr_int(self._time_sp, 'time_sp') - return value - - @time_sp.setter - def time_sp(self, value): - self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) - - #: Run the motor until another command is sent. - COMMAND_RUN_FOREVER = 'run-forever' - - #: Run to an absolute position specified by `position_sp` and then - #: stop using the action specified in `stop_action`. - COMMAND_RUN_TO_ABS_POS = 'run-to-abs-pos' - - #: Run to a position relative to the current `position` value. - #: The new position will be current `position` + `position_sp`. - #: When the new position is reached, the motor will stop using - #: the action specified by `stop_action`. - COMMAND_RUN_TO_REL_POS = 'run-to-rel-pos' - - #: Run the motor for the amount of time specified in `time_sp` - #: and then stop the motor using the action specified by `stop_action`. - COMMAND_RUN_TIMED = 'run-timed' - - #: Run the motor at the duty cycle specified by `duty_cycle_sp`. - #: Unlike other run commands, changing `duty_cycle_sp` while running *will* - #: take effect immediately. - COMMAND_RUN_DIRECT = 'run-direct' - - #: Stop any of the run commands before they are complete using the - #: action specified by `stop_action`. - COMMAND_STOP = 'stop' - - #: Reset all of the motor parameter attributes to their default value. - #: This will also have the effect of stopping the motor. - COMMAND_RESET = 'reset' - - #: Sets the normal polarity of the rotary encoder. - ENCODER_POLARITY_NORMAL = 'normal' - - #: Sets the inversed polarity of the rotary encoder. - ENCODER_POLARITY_INVERSED = 'inversed' - - #: With `normal` polarity, a positive duty cycle will - #: cause the motor to rotate clockwise. - POLARITY_NORMAL = 'normal' - - #: With `inversed` polarity, a positive duty cycle will - #: cause the motor to rotate counter-clockwise. - POLARITY_INVERSED = 'inversed' - - #: Power is being sent to the motor. - STATE_RUNNING = 'running' - - #: The motor is ramping up or down and has not yet reached a constant output level. - STATE_RAMPING = 'ramping' - - #: The motor is not turning, but rather attempting to hold a fixed position. - STATE_HOLDING = 'holding' - - #: The motor is turning, but cannot reach its `speed_sp`. - STATE_OVERLOADED = 'overloaded' - - #: The motor is not turning when it should be. - STATE_STALLED = 'stalled' - - #: Power will be removed from the motor and it will freely coast to a stop. - STOP_ACTION_COAST = 'coast' - - #: Power will be removed from the motor and a passive electrical load will - #: be placed on the motor. This is usually done by shorting the motor terminals - #: together. This load will absorb the energy from the rotation of the motors and - #: cause the motor to stop more quickly than coasting. - STOP_ACTION_BRAKE = 'brake' - - #: Does not remove power from the motor. Instead it actively try to hold the motor - #: at the current position. If an external force tries to turn the motor, the motor - #: will `push back` to maintain its position. - STOP_ACTION_HOLD = 'hold' - - def run_forever(self, **kwargs): - """Run the motor until another command is sent. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_FOREVER - - def run_to_abs_pos(self, **kwargs): - """Run to an absolute position specified by `position_sp` and then - stop using the action specified in `stop_action`. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_TO_ABS_POS - - def run_to_rel_pos(self, **kwargs): - """Run to a position relative to the current `position` value. - The new position will be current `position` + `position_sp`. - When the new position is reached, the motor will stop using - the action specified by `stop_action`. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_TO_REL_POS - - def run_timed(self, **kwargs): - """Run the motor for the amount of time specified in `time_sp` - and then stop the motor using the action specified by `stop_action`. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_TIMED - - def run_direct(self, **kwargs): - """Run the motor at the duty cycle specified by `duty_cycle_sp`. - Unlike other run commands, changing `duty_cycle_sp` while running *will* - take effect immediately. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_DIRECT - - def stop(self, **kwargs): - """Stop any of the run commands before they are complete using the - action specified by `stop_action`. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_STOP - - def reset(self, **kwargs): - """Reset all of the motor parameter attributes to their default value. - This will also have the effect of stopping the motor. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RESET - - @property - def is_running(self): - """Power is being sent to the motor. - """ - return self.STATE_RUNNING in self.state - - @property - def is_ramping(self): - """The motor is ramping up or down and has not yet reached a constant output level. - """ - return self.STATE_RAMPING in self.state - - @property - def is_holding(self): - """The motor is not turning, but rather attempting to hold a fixed position. - """ - return self.STATE_HOLDING in self.state - - @property - def is_overloaded(self): - """The motor is turning, but cannot reach its `speed_sp`. - """ - return self.STATE_OVERLOADED in self.state - - @property - def is_stalled(self): - """The motor is not turning when it should be. - """ - return self.STATE_STALLED in self.state - - def wait(self, cond, timeout=None): - """ - Blocks until ``cond(self.state)`` is ``True``. The condition is - checked when there is an I/O event related to the ``state`` attribute. - Exits early when ``timeout`` (in milliseconds) is reached. - - Returns ``True`` if the condition is met, and ``False`` if the timeout - is reached. - """ - - tic = time.time() - - if self._poll is None: - if self._state is None: - self._state = self._attribute_file_open('state') - self._poll = select.poll() - self._poll.register(self._state, select.POLLPRI) - - while True: - self._poll.poll(None if timeout is None else timeout) - - if timeout is not None and time.time() >= tic + timeout / 1000: - return False - - if cond(self.state): - return True - - def wait_until_not_moving(self, timeout=None): - """ - Blocks until ``running`` is not in ``self.state`` or ``stalled`` is in - ``self.state``. The condition is checked when there is an I/O event - related to the ``state`` attribute. Exits early when ``timeout`` - (in milliseconds) is reached. - - Returns ``True`` if the condition is met, and ``False`` if the timeout - is reached. - - Example:: - - m.wait_until_not_moving() - """ - return self.wait(lambda state: self.STATE_RUNNING not in state or self.STATE_STALLED in state, timeout) - - def wait_until(self, s, timeout=None): - """ - Blocks until ``s`` is in ``self.state``. The condition is checked when - there is an I/O event related to the ``state`` attribute. Exits early - when ``timeout`` (in milliseconds) is reached. - - Returns ``True`` if the condition is met, and ``False`` if the timeout - is reached. - - Example:: - - m.wait_until('stalled') - """ - return self.wait(lambda state: s in state, timeout) - - def wait_while(self, s, timeout=None): - """ - Blocks until ``s`` is not in ``self.state``. The condition is checked - when there is an I/O event related to the ``state`` attribute. Exits - early when ``timeout`` (in milliseconds) is reached. - - Returns ``True`` if the condition is met, and ``False`` if the timeout - is reached. - - Example:: - - m.wait_while('running') - """ - return self.wait(lambda state: s not in state, timeout) - - def _set_position_rotations(self, speed_pct, rotations): - if speed_pct > 0: - self.position_sp = self.position + int(rotations * self.count_per_rot) - else: - self.position_sp = self.position - int(rotations * self.count_per_rot) - - def _set_position_degrees(self, speed_pct, degrees): - if speed_pct > 0: - self.position_sp = self.position + int((degrees * self.count_per_rot)/360) - else: - self.position_sp = self.position - int((degrees * self.count_per_rot)/360) - - def _set_brake(self, brake): - if brake: - self.stop_action = self.STOP_ACTION_HOLD - else: - self.stop_action = self.STOP_ACTION_COAST - - def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): - """ - Rotate the motor at 'speed' for 'rotations' - """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct - self.speed_sp = int((speed_pct * self.max_speed) / 100) - self._set_position_rotations(speed_pct, rotations) - self._set_brake(brake) - self.run_to_abs_pos() - - if block: - self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.wait_until_not_moving() - - def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): - """ - Rotate the motor at 'speed' for 'degrees' - """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct - self.speed_sp = int((speed_pct * self.max_speed) / 100) - self._set_position_degrees(speed_pct, degrees) - self._set_brake(brake) - self.run_to_abs_pos() - - if block: - self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.wait_until_not_moving() - - def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): - """ - Rotate the motor at 'speed' for 'seconds' - """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct - self.speed_sp = int((speed_pct * self.max_speed) / 100) - self.time_sp = int(seconds * 1000) - self._set_brake(brake) - self.run_timed() - - if block: - self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.wait_until_not_moving() - - def on(self, speed_pct): - """ - Rotate the motor at 'speed' for forever - """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct - self.speed_sp = int((speed_pct * self.max_speed) / 100) - self.run_forever() - - def off(self, brake=True): - self._set_brake(brake) - self.stop() - - @property - def rotations(self): - return float(self.position / self.count_per_rot) - - @property - def degrees(self): - return self.rotations * 360 - - -def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): - """ - This is a generator function that enumerates all tacho motors that match - the provided arguments. - - Parameters: - name_pattern: pattern that device name should match. - For example, 'motor*'. Default value: '*'. - keyword arguments: used for matching the corresponding device - attributes. For example, driver_name='lego-ev3-l-motor', or - address=['outB', 'outC']. When argument value - is a list, then a match against any entry of the list is - enough. - """ - class_path = abspath(Device.DEVICE_ROOT_PATH + '/' + Motor.SYSTEM_CLASS_NAME) - - return (Motor(name_pattern=name, name_exact=True) - for name in list_device_names(class_path, name_pattern, **kwargs)) - -class LargeMotor(Motor): - - """ - EV3/NXT large servo motor - """ - - SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = '*' - __slots__ = [] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - super(LargeMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-l-motor', 'lego-nxt-motor'], **kwargs) - - -class MediumMotor(Motor): - - """ - EV3 medium servo motor - """ - - SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = '*' - __slots__ = [] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - super(MediumMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-m-motor'], **kwargs) - - -class ActuonixL1250Motor(Motor): - - """ - Actuonix L12 50 linear servo motor - """ - - SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = 'linear*' - __slots__ = [] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - super(ActuonixL1250Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-50'], **kwargs) - - -class ActuonixL12100Motor(Motor): - - """ - Actuonix L12 100 linear servo motor - """ - - SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = 'linear*' - __slots__ = [] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - super(ActuonixL12100Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-100'], **kwargs) - - -class DcMotor(Device): - - """ - The DC motor class provides a uniform interface for using regular DC motors - with no fancy controls or feedback. This includes LEGO MINDSTORMS RCX motors - and LEGO Power Functions motors. - """ - - SYSTEM_CLASS_NAME = 'dc-motor' - SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' - __slots__ = [ - '_address', - '_command', - '_commands', - '_driver_name', - '_duty_cycle', - '_duty_cycle_sp', - '_polarity', - '_ramp_down_sp', - '_ramp_up_sp', - '_state', - '_stop_action', - '_stop_actions', - '_time_sp', - ] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(DcMotor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._address = None - self._command = None - self._commands = None - self._driver_name = None - self._duty_cycle = None - self._duty_cycle_sp = None - self._polarity = None - self._ramp_down_sp = None - self._ramp_up_sp = None - self._state = None - self._stop_action = None - self._stop_actions = None - self._time_sp = None - - @property - def address(self): - """ - Returns the name of the port that this motor is connected to. - """ - self._address, value = self.get_attr_string(self._address, 'address') - return value - - @property - def command(self): - """ - Sets the command for the motor. Possible values are `run-forever`, `run-timed` and - `stop`. Not all commands may be supported, so be sure to check the contents - of the `commands` attribute. - """ - raise Exception("command is a write-only property!") - - @command.setter - def command(self, value): - self._command = self.set_attr_string(self._command, 'command', value) - - @property - def commands(self): - """ - Returns a list of commands supported by the motor - controller. - """ - self._commands, value = self.get_attr_set(self._commands, 'commands') - return value - - @property - def driver_name(self): - """ - Returns the name of the motor driver that loaded this device. See the list - of [supported devices] for a list of drivers. - """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') - return value - - @property - def duty_cycle(self): - """ - Shows the current duty cycle of the PWM signal sent to the motor. Values - are -100 to 100 (-100% to 100%). - """ - self._duty_cycle, value = self.get_attr_int(self._duty_cycle, 'duty_cycle') - return value - - @property - def duty_cycle_sp(self): - """ - Writing sets the duty cycle setpoint of the PWM signal sent to the motor. - Valid values are -100 to 100 (-100% to 100%). Reading returns the current - setpoint. - """ - self._duty_cycle_sp, value = self.get_attr_int(self._duty_cycle_sp, 'duty_cycle_sp') - return value - - @duty_cycle_sp.setter - def duty_cycle_sp(self, value): - self._duty_cycle_sp = self.set_attr_int(self._duty_cycle_sp, 'duty_cycle_sp', value) - - @property - def polarity(self): - """ - Sets the polarity of the motor. Valid values are `normal` and `inversed`. - """ - self._polarity, value = self.get_attr_string(self._polarity, 'polarity') - return value - - @polarity.setter - def polarity(self, value): - self._polarity = self.set_attr_string(self._polarity, 'polarity', value) - - @property - def ramp_down_sp(self): - """ - Sets the time in milliseconds that it take the motor to ramp down from 100% - to 0%. Valid values are 0 to 10000 (10 seconds). Default is 0. - """ - self._ramp_down_sp, value = self.get_attr_int(self._ramp_down_sp, 'ramp_down_sp') - return value - - @ramp_down_sp.setter - def ramp_down_sp(self, value): - self._ramp_down_sp = self.set_attr_int(self._ramp_down_sp, 'ramp_down_sp', value) - - @property - def ramp_up_sp(self): - """ - Sets the time in milliseconds that it take the motor to up ramp from 0% to - 100%. Valid values are 0 to 10000 (10 seconds). Default is 0. - """ - self._ramp_up_sp, value = self.get_attr_int(self._ramp_up_sp, 'ramp_up_sp') - return value - - @ramp_up_sp.setter - def ramp_up_sp(self, value): - self._ramp_up_sp = self.set_attr_int(self._ramp_up_sp, 'ramp_up_sp', value) - - @property - def state(self): - """ - Gets a list of flags indicating the motor status. Possible - flags are `running` and `ramping`. `running` indicates that the motor is - powered. `ramping` indicates that the motor has not yet reached the - `duty_cycle_sp`. - """ - self._state, value = self.get_attr_set(self._state, 'state') - return value - - @property - def stop_action(self): - """ - Sets the stop action that will be used when the motor stops. Read - `stop_actions` to get the list of valid values. - """ - raise Exception("stop_action is a write-only property!") - - @stop_action.setter - def stop_action(self, value): - self._stop_action = self.set_attr_string(self._stop_action, 'stop_action', value) - - @property - def stop_actions(self): - """ - Gets a list of stop actions. Valid values are `coast` - and `brake`. - """ - self._stop_actions, value = self.get_attr_set(self._stop_actions, 'stop_actions') - return value - - @property - def time_sp(self): - """ - Writing specifies the amount of time the motor will run when using the - `run-timed` command. Reading returns the current value. Units are in - milliseconds. - """ - self._time_sp, value = self.get_attr_int(self._time_sp, 'time_sp') - return value - - @time_sp.setter - def time_sp(self, value): - self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) - - #: Run the motor until another command is sent. - COMMAND_RUN_FOREVER = 'run-forever' - - #: Run the motor for the amount of time specified in `time_sp` - #: and then stop the motor using the action specified by `stop_action`. - COMMAND_RUN_TIMED = 'run-timed' - - #: Run the motor at the duty cycle specified by `duty_cycle_sp`. - #: Unlike other run commands, changing `duty_cycle_sp` while running *will* - #: take effect immediately. - COMMAND_RUN_DIRECT = 'run-direct' - - #: Stop any of the run commands before they are complete using the - #: action specified by `stop_action`. - COMMAND_STOP = 'stop' - - #: With `normal` polarity, a positive duty cycle will - #: cause the motor to rotate clockwise. - POLARITY_NORMAL = 'normal' - - #: With `inversed` polarity, a positive duty cycle will - #: cause the motor to rotate counter-clockwise. - POLARITY_INVERSED = 'inversed' - - #: Power will be removed from the motor and it will freely coast to a stop. - STOP_ACTION_COAST = 'coast' - - #: Power will be removed from the motor and a passive electrical load will - #: be placed on the motor. This is usually done by shorting the motor terminals - #: together. This load will absorb the energy from the rotation of the motors and - #: cause the motor to stop more quickly than coasting. - STOP_ACTION_BRAKE = 'brake' - - def run_forever(self, **kwargs): - """Run the motor until another command is sent. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_FOREVER - - def run_timed(self, **kwargs): - """Run the motor for the amount of time specified in `time_sp` - and then stop the motor using the action specified by `stop_action`. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_TIMED - - def run_direct(self, **kwargs): - """Run the motor at the duty cycle specified by `duty_cycle_sp`. - Unlike other run commands, changing `duty_cycle_sp` while running *will* - take effect immediately. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN_DIRECT - - def stop(self, **kwargs): - """Stop any of the run commands before they are complete using the - action specified by `stop_action`. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_STOP - - -class ServoMotor(Device): - - """ - The servo motor class provides a uniform interface for using hobby type - servo motors. - """ - - SYSTEM_CLASS_NAME = 'servo-motor' - SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' - __slots__ = [ - '_address', - '_command', - '_driver_name', - '_max_pulse_sp', - '_mid_pulse_sp', - '_min_pulse_sp', - '_polarity', - '_position_sp', - '_rate_sp', - '_state', - ] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(ServoMotor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._address = None - self._command = None - self._driver_name = None - self._max_pulse_sp = None - self._mid_pulse_sp = None - self._min_pulse_sp = None - self._polarity = None - self._position_sp = None - self._rate_sp = None - self._state = None - - @property - def address(self): - """ - Returns the name of the port that this motor is connected to. - """ - self._address, value = self.get_attr_string(self._address, 'address') - return value - - @property - def command(self): - """ - Sets the command for the servo. Valid values are `run` and `float`. Setting - to `run` will cause the servo to be driven to the position_sp set in the - `position_sp` attribute. Setting to `float` will remove power from the motor. - """ - raise Exception("command is a write-only property!") - - @command.setter - def command(self, value): - self._command = self.set_attr_string(self._command, 'command', value) - - @property - def driver_name(self): - """ - Returns the name of the motor driver that loaded this device. See the list - of [supported devices] for a list of drivers. - """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') - return value - - @property - def max_pulse_sp(self): - """ - Used to set the pulse size in milliseconds for the signal that tells the - servo to drive to the maximum (clockwise) position_sp. Default value is 2400. - Valid values are 2300 to 2700. You must write to the position_sp attribute for - changes to this attribute to take effect. - """ - self._max_pulse_sp, value = self.get_attr_int(self._max_pulse_sp, 'max_pulse_sp') - return value - - @max_pulse_sp.setter - def max_pulse_sp(self, value): - self._max_pulse_sp = self.set_attr_int(self._max_pulse_sp, 'max_pulse_sp', value) - - @property - def mid_pulse_sp(self): - """ - Used to set the pulse size in milliseconds for the signal that tells the - servo to drive to the mid position_sp. Default value is 1500. Valid - values are 1300 to 1700. For example, on a 180 degree servo, this would be - 90 degrees. On continuous rotation servo, this is the 'neutral' position_sp - where the motor does not turn. You must write to the position_sp attribute for - changes to this attribute to take effect. - """ - self._mid_pulse_sp, value = self.get_attr_int(self._mid_pulse_sp, 'mid_pulse_sp') - return value - - @mid_pulse_sp.setter - def mid_pulse_sp(self, value): - self._mid_pulse_sp = self.set_attr_int(self._mid_pulse_sp, 'mid_pulse_sp', value) - - @property - def min_pulse_sp(self): - """ - Used to set the pulse size in milliseconds for the signal that tells the - servo to drive to the miniumum (counter-clockwise) position_sp. Default value - is 600. Valid values are 300 to 700. You must write to the position_sp - attribute for changes to this attribute to take effect. - """ - self._min_pulse_sp, value = self.get_attr_int(self._min_pulse_sp, 'min_pulse_sp') - return value - - @min_pulse_sp.setter - def min_pulse_sp(self, value): - self._min_pulse_sp = self.set_attr_int(self._min_pulse_sp, 'min_pulse_sp', value) - - @property - def polarity(self): - """ - Sets the polarity of the servo. Valid values are `normal` and `inversed`. - Setting the value to `inversed` will cause the position_sp value to be - inversed. i.e `-100` will correspond to `max_pulse_sp`, and `100` will - correspond to `min_pulse_sp`. - """ - self._polarity, value = self.get_attr_string(self._polarity, 'polarity') - return value - - @polarity.setter - def polarity(self, value): - self._polarity = self.set_attr_string(self._polarity, 'polarity', value) - - @property - def position_sp(self): - """ - Reading returns the current position_sp of the servo. Writing instructs the - servo to move to the specified position_sp. Units are percent. Valid values - are -100 to 100 (-100% to 100%) where `-100` corresponds to `min_pulse_sp`, - `0` corresponds to `mid_pulse_sp` and `100` corresponds to `max_pulse_sp`. - """ - self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') - return value - - @position_sp.setter - def position_sp(self, value): - self._position_sp = self.set_attr_int(self._position_sp, 'position_sp', value) - - @property - def rate_sp(self): - """ - Sets the rate_sp at which the servo travels from 0 to 100.0% (half of the full - range of the servo). Units are in milliseconds. Example: Setting the rate_sp - to 1000 means that it will take a 180 degree servo 2 second to move from 0 - to 180 degrees. Note: Some servo controllers may not support this in which - case reading and writing will fail with `-EOPNOTSUPP`. In continuous rotation - servos, this value will affect the rate_sp at which the speed ramps up or down. - """ - self._rate_sp, value = self.get_attr_int(self._rate_sp, 'rate_sp') - return value - - @rate_sp.setter - def rate_sp(self, value): - self._rate_sp = self.set_attr_int(self._rate_sp, 'rate_sp', value) - - @property - def state(self): - """ - Returns a list of flags indicating the state of the servo. - Possible values are: - * `running`: Indicates that the motor is powered. - """ - self._state, value = self.get_attr_set(self._state, 'state') - return value - - #: Drive servo to the position set in the `position_sp` attribute. - COMMAND_RUN = 'run' - - #: Remove power from the motor. - COMMAND_FLOAT = 'float' - - #: With `normal` polarity, a positive duty cycle will - #: cause the motor to rotate clockwise. - POLARITY_NORMAL = 'normal' - - #: With `inversed` polarity, a positive duty cycle will - #: cause the motor to rotate counter-clockwise. - POLARITY_INVERSED = 'inversed' - - def run(self, **kwargs): - """Drive servo to the position set in the `position_sp` attribute. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_RUN - - def float(self, **kwargs): - """Remove power from the motor. - """ - for key in kwargs: - setattr(self, key, kwargs[key]) - self.command = self.COMMAND_FLOAT - - -class MotorSet(object): - - def __init__(self, motor_specs, desc=None): - """ - motor_specs is a dictionary such as - { - OUTPUT_A : LargeMotor, - OUTPUT_C : LargeMotor, - } - """ - self.motors = {} - for motor_port in sorted(motor_specs.keys()): - motor_class = motor_specs[motor_port] - self.motors[motor_port] = motor_class(motor_port) - - self.desc = desc - self.verify_connected() - - def __str__(self): - - if self.desc: - return self.desc - else: - return self.__class__.__name__ - - def verify_connected(self): - for motor in self.motors.values(): - if not motor.connected: - print("%s: %s is not connected" % (self, motor)) - sys.exit(1) - - def set_args(self, **kwargs): - motors = kwargs.get('motors', self.motors.values()) - - for motor in motors: - for key in kwargs: - if key != 'motors': - try: - setattr(motor, key, kwargs[key]) - except AttributeError as e: - #log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) - raise e - - def set_polarity(self, polarity, motors=None): - valid_choices = (LargeMotor.POLARITY_NORMAL, LargeMotor.POLARITY_INVERSED) - - assert polarity in valid_choices,\ - "%s is an invalid polarity choice, must be %s" % (polarity, ', '.join(valid_choices)) - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.polarity = polarity - - def _run_command(self, **kwargs): - motors = kwargs.get('motors', self.motors.values()) - - for motor in motors: - for key in kwargs: - if key not in ('motors', 'commands'): - #log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) - setattr(motor, key, kwargs[key]) - - for motor in motors: - motor.command = kwargs['command'] - #log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) - - def run_forever(self, **kwargs): - kwargs['command'] = LargeMotor.COMMAND_RUN_FOREVER - self._run_command(**kwargs) - - def run_to_abs_pos(self, **kwargs): - kwargs['command'] = LargeMotor.COMMAND_RUN_TO_ABS_POS - self._run_command(**kwargs) - - def run_to_rel_pos(self, **kwargs): - kwargs['command'] = LargeMotor.COMMAND_RUN_TO_REL_POS - self._run_command(**kwargs) - - def run_timed(self, **kwargs): - kwargs['command'] = LargeMotor.COMMAND_RUN_TIMED - self._run_command(**kwargs) - - def run_direct(self, **kwargs): - kwargs['command'] = LargeMotor.COMMAND_RUN_DIRECT - self._run_command(**kwargs) - - def reset(self, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.reset() - - def stop(self, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.stop() - - def _is_state(self, motors, state): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - if state not in motor.state: - return False - - return True - - @property - def is_running(self, motors=None): - return self._is_state(motors, LargeMotor.STATE_RUNNING) - - @property - def is_ramping(self, motors=None): - return self._is_state(motors, LargeMotor.STATE_RAMPING) - - @property - def is_holding(self, motors=None): - return self._is_state(motors, LargeMotor.STATE_HOLDING) - - @property - def is_overloaded(self, motors=None): - return self._is_state(motors, LargeMotor.STATE_OVERLOADED) - - @property - def is_stalled(self): - return self._is_state(motors, LargeMotor.STATE_STALLED) - - def wait(self, cond, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait(cond, timeout) - - def wait_until_not_moving(self, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait_until_not_moving(timeout) - - def wait_until(self, s, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait_until(s, timeout) - - def wait_while(self, s, timeout=None, motors=None): - motors = motors if motors is not None else self.motors.values() - - for motor in motors: - motor.wait_while(s, timeout) - - -class MoveTank(MotorSet): - - def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=LargeMotor): - motor_specs = { - left_motor_port : motor_class, - right_motor_port : motor_class, - } - - MotorSet.__init__(self, motor_specs, desc) - self.left_motor = self.motors[left_motor_port] - self.right_motor = self.motors[right_motor_port] - self.max_speed = self.left_motor.max_speed - - def _block(self): - self.left_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.right_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.left_motor.wait_until_not_moving() - self.right_motor.wait_until_not_moving() - - def _validate_speed_pct(self, left_speed_pct, right_speed_pct): - assert left_speed_pct >= -100 and left_speed_pct <= 100,\ - "%s is an invalid left_speed_pct, must be between -100 and 100 (inclusive)" % left_speed_pct - assert right_speed_pct >= -100 and right_speed_pct <= 100,\ - "%s is an invalid right_speed_pct, must be between -100 and 100 (inclusive)" % right_speed_pct - assert left_speed_pct or right_speed_pct,\ - "Either left_speed_pct or right_speed_pct must be non-zero" - - def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=True, block=True): - """ - Rotate the motor at 'left_speed & right_speed' for 'rotations' - """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - left_speed = int((left_speed_pct * self.max_speed) / 100) - right_speed = int((right_speed_pct * self.max_speed) / 100) - - if left_speed > right_speed: - left_rotations = rotations - right_rotations = float(right_speed / left_speed) * rotations - else: - left_rotations = float(left_speed / right_speed) * rotations - right_rotations = rotations - - # Set all parameters - self.left_motor.speed_sp = left_speed - self.left_motor._set_position_rotations(left_speed, left_rotations) - self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed - self.right_motor._set_position_rotations(right_speed, right_rotations) - self.right_motor._set_brake(brake) - - # Start the motors - self.left_motor.run_to_abs_pos() - self.right_motor.run_to_abs_pos() - - if block: - self._block() - - def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, block=True): - """ - Rotate the motor at 'left_speed & right_speed' for 'degrees' - """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - left_speed = int((left_speed_pct * self.max_speed) / 100) - right_speed = int((right_speed_pct * self.max_speed) / 100) - - if left_speed > right_speed: - left_degrees = degrees - right_degrees = float(right_speed / left_speed) * degrees - else: - left_degrees = float(left_speed / right_speed) * degrees - right_degrees = degrees - - # Set all parameters - self.left_motor.speed_sp = left_speed - self.left_motor._set_position_degrees(left_speed, left_degrees) - self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed - self.right_motor._set_position_degrees(right_speed, right_degrees) - self.right_motor._set_brake(brake) - - # Start the motors - self.left_motor.run_to_abs_pos() - self.right_motor.run_to_abs_pos() - - if block: - self._block() - - def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, block=True): - """ - Rotate the motor at 'left_speed & right_speed' for 'seconds' - """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - - # Set all parameters - self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) - self.left_motor.time_sp = int(seconds * 1000) - self.left_motor._set_brake(brake) - self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) - self.right_motor.time_sp = int(seconds * 1000) - self.right_motor._set_brake(brake) - - # Start the motors - self.left_motor.run_timed() - self.right_motor.run_timed() - - if block: - self._block() - - def on(self, left_speed_pct, right_speed_pct): - """ - Rotate the motor at 'left_speed & right_speed' for forever - """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) - self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) - - # Start the motors - self.left_motor.run_forever() - self.right_motor.run_forever() - - def off(self, brake=True): - self.left_motor._set_brake(brake) - self.right_motor._set_brake(brake) - self.left_motor.stop() - self.right_motor.stop() - - -class MoveSteering(MoveTank): - - def get_speed_steering(self, steering, speed_pct): - """ - Calculate the speed_sp for each motor in a pair to achieve the specified - steering. Note that calling this function alone will not make the - motors move, it only sets the speed. A run_* function must be called - afterwards to make the motors move. - - steering [-100, 100]: - * -100 means turn left as fast as possible, - * 0 means drive in a straight line, and - * 100 means turn right as fast as possible. - - speed_pct: - The speed that should be applied to the outmost motor (the one - rotating faster). The speed of the other motor will be computed - automatically. - """ - - assert steering >= -100 and steering <= 100,\ - "%s is an invalid steering, must be between -100 and 100 (inclusive)" % steering - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct - - left_speed = int((speed_pct * self.max_speed) / 100) - right_speed = left_speed - speed = (50 - abs(float(steering))) / 50 - - if steering >= 0: - right_speed *= speed - else: - left_speed *= speed - - left_speed_pct = int((left_speed * 100) / self.left_motor.max_speed) - right_speed_pct = int((right_speed * 100) / self.right_motor.max_speed) - #log.debug("%s: steering %d, %s speed %d, %s speed %d" % - # (self, steering, self.left_motor, left_speed_pct, self.right_motor, right_speed_pct)) - - return (left_speed_pct, right_speed_pct) - - def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake, block) - - def on_for_degrees(self, steering, speed_pct, degrees, brake=True, block=True): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake, block) - - def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake, block) - - def on(self, steering, speed_pct): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on(self, left_speed_pct, right_speed_pct) - - -class Sensor(Device): - - """ - The sensor class provides a uniform interface for using most of the - sensors available for the EV3. The various underlying device drivers will - create a `lego-sensor` device for interacting with the sensors. - - Sensors are primarily controlled by setting the `mode` and monitored by - reading the `value` attributes. Values can be converted to floating point - if needed by `value` / 10.0 ^ `decimals`. - - Since the name of the `sensor` device node does not correspond to the port - that a sensor is plugged in to, you must look at the `address` attribute if - you need to know which port a sensor is plugged in to. However, if you don't - have more than one sensor of each type, you can just look for a matching - `driver_name`. Then it will not matter which port a sensor is plugged in to - your - program will still work. - """ - - SYSTEM_CLASS_NAME = 'lego-sensor' - SYSTEM_DEVICE_NAME_CONVENTION = 'sensor*' - __slots__ = [ - '_address', - '_command', - '_commands', - '_decimals', - '_driver_name', - '_mode', - '_modes', - '_num_values', - '_units', - '_value', - '_bin_data_format', - '_bin_data_size', - '_bin_data', - '_mode_scale' - ] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(Sensor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._address = None - self._command = None - self._commands = None - self._decimals = None - self._driver_name = None - self._mode = None - self._modes = None - self._num_values = None - self._units = None - self._value = [None,None,None,None,None,None,None,None] - - self._bin_data_format = None - self._bin_data_size = None - self._bin_data = None - self._mode_scale = {} - - def _scale(self, mode): - """ - Returns value scaling coefficient for the given mode. - """ - if mode in self._mode_scale: - scale = self._mode_scale[mode] - else: - scale = 10**(-self.decimals) - self._mode_scale[mode] = scale - - return scale - - @property - def address(self): - """ - Returns the name of the port that the sensor is connected to, e.g. `ev3:in1`. - I2C sensors also include the I2C address (decimal), e.g. `ev3:in1:i2c8`. - """ - self._address, value = self.get_attr_string(self._address, 'address') - return value - - @property - def command(self): - """ - Sends a command to the sensor. - """ - raise Exception("command is a write-only property!") - - @command.setter - def command(self, value): - self._command = self.set_attr_string(self._command, 'command', value) - - @property - def commands(self): - """ - Returns a list of the valid commands for the sensor. - Returns -EOPNOTSUPP if no commands are supported. - """ - self._commands, value = self.get_attr_set(self._commands, 'commands') - return value - - @property - def decimals(self): - """ - Returns the number of decimal places for the values in the `value` - attributes of the current mode. - """ - self._decimals, value = self.get_attr_int(self._decimals, 'decimals') - return value - - @property - def driver_name(self): - """ - Returns the name of the sensor device/driver. See the list of [supported - sensors] for a complete list of drivers. - """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') - return value - - @property - def mode(self): - """ - Returns the current mode. Writing one of the values returned by `modes` - sets the sensor to that mode. - """ - self._mode, value = self.get_attr_string(self._mode, 'mode') - return value - - @mode.setter - def mode(self, value): - self._mode = self.set_attr_string(self._mode, 'mode', value) - - @property - def modes(self): - """ - Returns a list of the valid modes for the sensor. - """ - self._modes, value = self.get_attr_set(self._modes, 'modes') - return value - - @property - def num_values(self): - """ - Returns the number of `value` attributes that will return a valid value - for the current mode. - """ - self._num_values, value = self.get_attr_int(self._num_values, 'num_values') - return value - - @property - def units(self): - """ - Returns the units of the measured value for the current mode. May return - empty string - """ - self._units, value = self.get_attr_string(self._units, 'units') - return value - - def value(self, n=0): - """ - Returns the value or values measured by the sensor. Check num_values to - see how many values there are. Values with N >= num_values will return - an error. The values are fixed point numbers, so check decimals to see - if you need to divide to get the actual value. - """ - if isinstance(n, numbers.Real): - n = int(n) - elif isinstance(n, str): - n = int(n) - - self._value[n], value = self.get_attr_int(self._value[n], 'value'+str(n)) - return value - - @property - def bin_data_format(self): - """ - Returns the format of the values in `bin_data` for the current mode. - Possible values are: - - - `u8`: Unsigned 8-bit integer (byte) - - `s8`: Signed 8-bit integer (sbyte) - - `u16`: Unsigned 16-bit integer (ushort) - - `s16`: Signed 16-bit integer (short) - - `s16_be`: Signed 16-bit integer, big endian - - `s32`: Signed 32-bit integer (int) - - `float`: IEEE 754 32-bit floating point (float) - """ - self._bin_data_format, value = self.get_attr_string(self._bin_data_format, 'bin_data_format') - return value - - def bin_data(self, fmt=None): - """ - Returns the unscaled raw values in the `value` attributes as raw byte - array. Use `bin_data_format`, `num_values` and the individual sensor - documentation to determine how to interpret the data. - - Use `fmt` to unpack the raw bytes into a struct. - - Example:: - - >>> from ev3dev import * - >>> ir = InfraredSensor() - >>> ir.value() - 28 - >>> ir.bin_data('= tic + timeout_ms / 1000: - return False - - if sleep_ms: - time.sleep(sleep_ms) - - def wait_for_pressed(self, timeout_ms=None, sleep_ms=10): - return self._wait(True, timeout_ms, sleep_ms) - - def wait_for_released(self, timeout_ms=None, sleep_ms=10): - return self._wait(False, timeout_ms, sleep_ms) - - def wait_for_bump(self, timeout_ms=None, sleep_ms=10): - """ - Wait for the touch sensor to be pressed down and then released. - Both actions must happen within timeout_ms. - """ - start_time = time.time() - - if self.wait_for_pressed(timeout_ms, sleep_ms): - if timeout_ms is not None: - timeout_ms -= int((time.time() - start_time) * 1000) - return self.wait_for_released(timeout_ms, sleep_ms) - - return False - - -class ColorSensor(Sensor): - - """ - LEGO EV3 color sensor. - """ - - __slots__ = ['red_max', 'green_max', 'blue_max'] - - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - - #: Reflected light. Red LED on. - MODE_COL_REFLECT = 'COL-REFLECT' - - #: Ambient light. Red LEDs off. - MODE_COL_AMBIENT = 'COL-AMBIENT' - - #: Color. All LEDs rapidly cycling, appears white. - MODE_COL_COLOR = 'COL-COLOR' - - #: Raw reflected. Red LED on - MODE_REF_RAW = 'REF-RAW' - - #: Raw Color Components. All LEDs rapidly cycling, appears white. - MODE_RGB_RAW = 'RGB-RAW' - - #: No color. - COLOR_NOCOLOR = 0 - - #: Black color. - COLOR_BLACK = 1 - - #: Blue color. - COLOR_BLUE = 2 - - #: Green color. - COLOR_GREEN = 3 - - #: Yellow color. - COLOR_YELLOW = 4 - - #: Red color. - COLOR_RED = 5 - - #: White color. - COLOR_WHITE = 6 - - #: Brown color. - COLOR_BROWN = 7 - - MODES = ( - MODE_COL_REFLECT, - MODE_COL_AMBIENT, - MODE_COL_COLOR, - MODE_REF_RAW, - MODE_RGB_RAW - ) - - COLORS = ( - 'NoColor', - 'Black', - 'Blue', - 'Green', - 'Yellow', - 'Red', - 'White', - 'Brown', - ) - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-color'], **kwargs) - - # See calibrate_white() for more details - self.red_max = 300 - self.green_max = 300 - self.blue_max = 300 - - @property - def reflected_light_intensity(self): - """ - Reflected light intensity as a percentage. Light on sensor is red. - """ - self.mode = self.MODE_COL_REFLECT - return self.value(0) - - @property - def ambient_light_intensity(self): - """ - Ambient light intensity. Light on sensor is dimly lit blue. - """ - self.mode = self.MODE_COL_AMBIENT - return self.value(0) - - @property - def color(self): - """ - Color detected by the sensor, categorized by overall value. - - 0: No color - - 1: Black - - 2: Blue - - 3: Green - - 4: Yellow - - 5: Red - - 6: White - - 7: Brown - """ - self.mode = self.MODE_COL_COLOR - return self.value(0) - - @property - def color_name(self): - """ - Returns NoColor, Black, Blue, etc - """ - return self.COLORS[self.color] - - @property - def raw(self): - """ - Red, green, and blue components of the detected color, officially in the - range 0-1020 but the values returned will never be that high. We do not - yet know why the values returned are low, but pointing the color sensor - at a well lit sheet of white paper will return values in the 250-400 range. - - If this is an issue, check out the rgb() and calibrate_white() methods. - """ - self.mode = self.MODE_RGB_RAW - return self.value(0), self.value(1), self.value(2) - - def calibrate_white(self): - """ - The RGB raw values are on a scale of 0-1020 but you never see a value - anywhere close to 1020. This function is designed to be called when - the sensor is placed over a white object in order to figure out what - are the maximum RGB values the robot can expect to see. We will use - these maximum values to scale future raw values to a 0-255 range in - rgb(). - - If you never call this function red_max, green_max, and blue_max will - use a default value of 300. This default was selected by measuring - the RGB values of a white sheet of paper in a well lit room. - - Note that there are several variables that influence the maximum RGB - values detected by the color sensor - - the distance of the color sensor to the white object - - the amount of light in the room - - shadows that the robot casts on the sensor - """ - (self.red_max, self.green_max, self.blue_max) = self.raw - - @property - def rgb(self): - """ - Same as raw() but RGB values are scaled to 0-255 - """ - (red, green, blue) = self.raw - - return (min(int((red * 255) / self.red_max), 255), - min(int((green * 255) / self.green_max), 255), - min(int((blue * 255) / self.blue_max), 255)) - - @property - def red(self): - """ - Red component of the detected color, in the range 0-1020. - """ - self.mode = self.MODE_RGB_RAW - return self.value(0) - - @property - def green(self): - """ - Green component of the detected color, in the range 0-1020. - """ - self.mode = self.MODE_RGB_RAW - return self.value(1) - - @property - def blue(self): - """ - Blue component of the detected color, in the range 0-1020. - """ - self.mode = self.MODE_RGB_RAW - return self.value(2) - - -class UltrasonicSensor(Sensor): - - """ - LEGO EV3 ultrasonic sensor. - """ - - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(UltrasonicSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-us', 'lego-nxt-us'], **kwargs) - - #: Continuous measurement in centimeters. - MODE_US_DIST_CM = 'US-DIST-CM' - - #: Continuous measurement in inches. - MODE_US_DIST_IN = 'US-DIST-IN' - - #: Listen. - MODE_US_LISTEN = 'US-LISTEN' - - #: Single measurement in centimeters. - MODE_US_SI_CM = 'US-SI-CM' - - #: Single measurement in inches. - MODE_US_SI_IN = 'US-SI-IN' - - - MODES = ( - 'US-DIST-CM', - 'US-DIST-IN', - 'US-LISTEN', - 'US-SI-CM', - 'US-SI-IN', - ) - - - @property - def distance_centimeters(self): - """ - Measurement of the distance detected by the sensor, - in centimeters. - """ - self.mode = self.MODE_US_DIST_CM - return self.value(0) * self._scale('US_DIST_CM') - - @property - def distance_inches(self): - """ - Measurement of the distance detected by the sensor, - in inches. - """ - self.mode = self.MODE_US_DIST_IN - return self.value(0) * self._scale('US_DIST_IN') - - @property - def other_sensor_present(self): - """ - Value indicating whether another ultrasonic sensor could - be heard nearby. - """ - self.mode = self.MODE_US_LISTEN - return self.value(0) - - -class GyroSensor(Sensor): - - """ - LEGO EV3 gyro sensor. - """ - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - - #: Angle - MODE_GYRO_ANG = 'GYRO-ANG' - - #: Rotational speed - MODE_GYRO_RATE = 'GYRO-RATE' - - #: Raw sensor value - MODE_GYRO_FAS = 'GYRO-FAS' - - #: Angle and rotational speed - MODE_GYRO_G_A = 'GYRO-G&A' - - #: Calibration ??? - MODE_GYRO_CAL = 'GYRO-CAL' - - # Newer versions of the Gyro sensor also have an additional second axis - # accessible via the TILT-ANGLE and TILT-RATE modes that is not usable - # using the official EV3-G blocks - MODE_TILT_ANG = 'TILT-ANGLE' - MODE_TILT_RATE = 'TILT-RATE' - - MODES = ( - MODE_GYRO_ANG, - MODE_GYRO_RATE, - MODE_GYRO_FAS, - MODE_GYRO_G_A, - MODE_GYRO_CAL, - MODE_TILT_ANG, - MODE_TILT_RATE, - ) - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-gyro'], **kwargs) - self._direct = None - - @property - def angle(self): - """ - The number of degrees that the sensor has been rotated - since it was put into this mode. - """ - self.mode = self.MODE_GYRO_ANG - return self.value(0) - - @property - def rate(self): - """ - The rate at which the sensor is rotating, in degrees/second. - """ - self.mode = self.MODE_GYRO_RATE - return self.value(0) - - @property - def rate_and_angle(self): - """ - Angle (degrees) and Rotational Speed (degrees/second). - """ - self.mode = self.MODE_GYRO_G_A - return self.value(0), self.value(1) - - @property - def tilt_angle(self): - self.mode = self.MODE_TILT_ANG - return self.value(0) - - @property - def tilt_rate(self): - self.mode = self.MODE_TILT_RATE - return self.value(0) - - def reset(self): - self.mode = self.MODE_GYRO_ANG - self._direct = self.set_attr_raw(self._direct, 'direct', 17) - - -class ButtonBase(object): - """ - Abstract button interface. - """ - - _state = set([]) - - def __str__(self): - return self.__class__.__name__ - - @staticmethod - def on_change(changed_buttons): - """ - This handler is called by `process()` whenever state of any button has - changed since last `process()` call. `changed_buttons` is a list of - tuples of changed button names and their states. - """ - pass - - def any(self): - """ - Checks if any button is pressed. - """ - return bool(self.buttons_pressed) - - def check_buttons(self, buttons=[]): - """ - Check if currently pressed buttons exactly match the given list. - """ - return set(self.buttons_pressed) == set(buttons) - - @property - def evdev_device(self): - """ - Return our corresponding evdev device object - """ - devices = [evdev.InputDevice(fn) for fn in evdev.list_devices()] - - for device in devices: - if device.name == self.evdev_device_name: - return device - - raise Exception("%s: could not find evdev device '%s'" % (self, self.evdev_device_name)) - - def process(self, new_state=None): - """ - Check for currenly pressed buttons. If the new state differs from the - old state, call the appropriate button event handlers. - """ - if new_state is None: - new_state = set(self.buttons_pressed) - old_state = self._state - self._state = new_state - - state_diff = new_state.symmetric_difference(old_state) - for button in state_diff: - handler = getattr(self, 'on_' + button) - - if handler is not None: - handler(button in new_state) - - if self.on_change is not None and state_diff: - self.on_change([(button, button in new_state) for button in state_diff]) - - def process_forever(self): - for event in self.evdev_device.read_loop(): - if event.type == evdev.ecodes.EV_KEY: - self.process() - - @property - def buttons_pressed(self): - raise NotImplementedError() - - def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): - tic = time.time() - - # wait_for_button_press/release can be a list of buttons or a string - # with the name of a single button. If it is a string of a single - # button convert that to a list. - if isinstance(wait_for_button_press, str): - wait_for_button_press = [wait_for_button_press, ] - - if isinstance(wait_for_button_release, str): - wait_for_button_release = [wait_for_button_release, ] - - for event in self.evdev_device.read_loop(): - if event.type == evdev.ecodes.EV_KEY: - all_pressed = True - all_released = True - pressed = self.buttons_pressed - - for button in wait_for_button_press: - if button not in pressed: - all_pressed = False - break - - for button in wait_for_button_release: - if button in pressed: - all_released = False - break - - if all_pressed and all_released: - return True - - if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: - return False - - def wait_for_pressed(self, buttons, timeout_ms=None): - return self._wait(buttons, [], timeout_ms) - - def wait_for_released(self, buttons, timeout_ms=None): - return self._wait([], buttons, timeout_ms) - - def wait_for_bump(self, buttons, timeout_ms=None): - """ - Wait for the button to be pressed down and then released. - Both actions must happen within timeout_ms. - """ - start_time = time.time() - - if self.wait_for_pressed(buttons, timeout_ms): - if timeout_ms is not None: - timeout_ms -= int((time.time() - start_time) * 1000) - return self.wait_for_released(buttons, timeout_ms) - - return False - - -class InfraredSensor(Sensor, ButtonBase): - """ - LEGO EV3 infrared sensor. - """ - - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - - #: Proximity - MODE_IR_PROX = 'IR-PROX' - - #: IR Seeker - MODE_IR_SEEK = 'IR-SEEK' - - #: IR Remote Control - MODE_IR_REMOTE = 'IR-REMOTE' - - #: IR Remote Control. State of the buttons is coded in binary - MODE_IR_REM_A = 'IR-REM-A' - - #: Calibration ??? - MODE_IR_CAL = 'IR-CAL' - - MODES = ( - MODE_IR_PROX, - MODE_IR_SEEK, - MODE_IR_REMOTE, - MODE_IR_REM_A, - MODE_IR_CAL - ) - - # The following are all of the various combinations of button presses for - # the remote control. The key/index is the number that will be written in - # the attribute file to indicate what combination of buttons are currently - # pressed. - _BUTTON_VALUES = { - 0: [], - 1: ['top_left'], - 2: ['bottom_left'], - 3: ['top_right'], - 4: ['bottom_right'], - 5: ['top_left', 'top_right'], - 6: ['top_left', 'bottom_right'], - 7: ['bottom_left', 'top_right'], - 8: ['bottom_left', 'bottom_right'], - 9: ['beacon'], - 10: ['top_left', 'bottom_left'], - 11: ['top_right', 'bottom_right'] - } - - _BUTTONS = ('top_left', 'bottom_left', 'top_right', 'bottom_right', 'beacon') - - # See process() for an explanation on how to use these - #: Handles ``Red Up``, etc events on channel 1 - on_channel1_top_left = None - on_channel1_bottom_left = None - on_channel1_top_right = None - on_channel1_bottom_right = None - on_channel1_beacon = None - - #: Handles ``Red Up``, etc events on channel 2 - on_channel2_top_left = None - on_channel2_bottom_left = None - on_channel2_top_right = None - on_channel2_bottom_right = None - on_channel2_beacon = None - - #: Handles ``Red Up``, etc events on channel 3 - on_channel3_top_left = None - on_channel3_bottom_left = None - on_channel3_top_right = None - on_channel3_bottom_right = None - on_channel3_beacon = None - - #: Handles ``Red Up``, etc events on channel 4 - on_channel4_top_left = None - on_channel4_bottom_left = None - on_channel4_top_right = None - on_channel4_bottom_right = None - on_channel4_beacon = None - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-ir'], **kwargs) - - def _normalize_channel(self, channel): - assert channel >= 1 and channel <= 4, "channel is %s, it must be 1, 2, 3, or 4" % channel - channel = max(1, min(4, channel)) - 1 - return channel - - @property - def proximity(self): - """ - A measurement of the distance between the sensor and the remote, - as a percentage. 100% is approximately 70cm/27in. - """ - self.mode = self.MODE_IR_PROX - return self.value(0) - - def heading(self, channel=1): - """ - Returns heading (-25, 25) to the beacon on the given channel. - """ - self.mode = self.MODE_IR_SEEK - channel = self._normalize_channel(channel) - return self.value(channel * 2) - - def distance(self, channel=1): - """ - Returns distance (0, 100) to the beacon on the given channel. - Returns None when beacon is not found. - """ - self.mode = self.MODE_IR_SEEK - channel = self._normalize_channel(channel) - ret_value = self.value((channel * 2) + 1) - - # The value will be -128 if no beacon is found, return None instead - return None if ret_value == -128 else ret_value - - def heading_and_distance(self, channel=1): - """ - Returns heading and distance to the beacon on the given channel as a - tuple. - """ - return (self.heading(channel), self.distance(channel)) - - def top_left(self, channel=1): - """ - Checks if `top_left` button is pressed. - """ - return 'top_left' in self.buttons_pressed(channel) - - def bottom_left(self, channel=1): - """ - Checks if `bottom_left` button is pressed. - """ - return 'bottom_left' in self.buttons_pressed(channel) - - def top_right(self, channel=1): - """ - Checks if `top_right` button is pressed. - """ - return 'top_right' in self.buttons_pressed(channel) - - def bottom_right(self, channel=1): - """ - Checks if `bottom_right` button is pressed. - """ - return 'bottom_right' in self.buttons_pressed(channel) - - def beacon(self, channel=1): - """ - Checks if `beacon` button is pressed. - """ - return 'beacon' in self.buttons_pressed(channel) - - def buttons_pressed(self, channel=1): - """ - Returns list of currently pressed buttons. - """ - self.mode = self.MODE_IR_REMOTE - channel = self._normalize_channel(channel) - return self._BUTTON_VALUES.get(self.value(channel), []) - - def process(self): - """ - Check for currenly pressed buttons. If the new state differs from the - old state, call the appropriate button event handlers. - - To use the on_channel1_top_left, etc handlers your program would do something like: - - def top_left_channel_1_action(state): - print("top left on channel 1: %s" % state) - - def bottom_right_channel_4_action(state): - print("bottom right on channel 4: %s" % state) - - ir = InfraredSensor() - ir.on_channel1_top_left = top_left_channel_1_action - ir.on_channel4_bottom_right = bottom_right_channel_4_action - - while True: - ir.process() - time.sleep(0.01) - """ - new_state = [] - state_diff = [] - - for channel in range(1,5): - - for button in self.buttons_pressed(channel): - new_state.append((button, channel)) - - # Key was not pressed before but now is pressed - if (button, channel) not in self._state: - state_diff.append((button, channel)) - - # Key was pressed but is no longer pressed - for button in self._BUTTONS: - if (button, channel) not in new_state and (button, channel) in self._state: - state_diff.append((button, channel)) - - old_state = self._state - self._state = new_state - - for (button, channel) in state_diff: - handler = getattr(self, 'on_channel' + str(channel) + '_' + button ) - - if handler is not None: - handler((button, channel) in new_state) - - if self.on_change is not None and state_diff: - self.on_change([(button, channel, button in new_state) for (button, channel) in state_diff]) - - -class SoundSensor(Sensor): - - """ - LEGO NXT Sound Sensor - """ - - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(SoundSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-sound'], **kwargs) - - #: Sound pressure level. Flat weighting - MODE_DB = 'DB' - - #: Sound pressure level. A weighting - MODE_DBA = 'DBA' - - - MODES = ( - 'DB', - 'DBA', - ) - - - @property - def sound_pressure(self): - """ - A measurement of the measured sound pressure level, as a - percent. Uses a flat weighting. - """ - self.mode = self.MODE_DB - return self.value(0) * self._scale('DB') - - @property - def sound_pressure_low(self): - """ - A measurement of the measured sound pressure level, as a - percent. Uses A-weighting, which focuses on levels up to 55 dB. - """ - self.mode = self.MODE_DBA - return self.value(0) * self._scale('DBA') - - -class LightSensor(Sensor): - - """ - LEGO NXT Light Sensor - """ - - SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME - SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(LightSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-light'], **kwargs) - - #: Reflected light. LED on - MODE_REFLECT = 'REFLECT' - - #: Ambient light. LED off - MODE_AMBIENT = 'AMBIENT' - - - MODES = ( - 'REFLECT', - 'AMBIENT', - ) - - - @property - def reflected_light_intensity(self): - """ - A measurement of the reflected light intensity, as a percentage. - """ - self.mode = self.MODE_REFLECT - return self.value(0) * self._scale('REFLECT') - - @property - def ambient_light_intensity(self): - """ - A measurement of the ambient light intensity, as a percentage. - """ - self.mode = self.MODE_AMBIENT - return self.value(0) * self._scale('AMBIENT') - - -class Led(Device): - - """ - Any device controlled by the generic LED driver. - See https://www.kernel.org/doc/Documentation/leds/leds-class.txt - for more details. - """ - - SYSTEM_CLASS_NAME = 'leds' - SYSTEM_DEVICE_NAME_CONVENTION = '*' - __slots__ = [ - '_max_brightness', - '_brightness', - '_triggers', - '_trigger', - '_delay_on', - '_delay_off', - ] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(Led, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._max_brightness = None - self._brightness = None - self._triggers = None - self._trigger = None - self._delay_on = None - self._delay_off = None - - @property - def max_brightness(self): - """ - Returns the maximum allowable brightness value. - """ - self._max_brightness, value = self.get_attr_int(self._max_brightness, 'max_brightness') - return value - - @property - def brightness(self): - """ - Sets the brightness level. Possible values are from 0 to `max_brightness`. - """ - self._brightness, value = self.get_attr_int(self._brightness, 'brightness') - return value - - @brightness.setter - def brightness(self, value): - self._brightness = self.set_attr_int(self._brightness, 'brightness', value) - - @property - def triggers(self): - """ - Returns a list of available triggers. - """ - self._triggers, value = self.get_attr_set(self._triggers, 'trigger') - return value - - @property - def trigger(self): - """ - Sets the led trigger. A trigger - is a kernel based source of led events. Triggers can either be simple or - complex. A simple trigger isn't configurable and is designed to slot into - existing subsystems with minimal additional code. Examples are the `ide-disk` and - `nand-disk` triggers. - - Complex triggers whilst available to all LEDs have LED specific - parameters and work on a per LED basis. The `timer` trigger is an example. - The `timer` trigger will periodically change the LED brightness between - 0 and the current brightness setting. The `on` and `off` time can - be specified via `delay_{on,off}` attributes in milliseconds. - You can change the brightness value of a LED independently of the timer - trigger. However, if you set the brightness value to 0 it will - also disable the `timer` trigger. - """ - self._trigger, value = self.get_attr_from_set(self._trigger, 'trigger') - return value - - @trigger.setter - def trigger(self, value): - self._trigger = self.set_attr_string(self._trigger, 'trigger', value) - - # Workaround for ev3dev/ev3dev#225. - # When trigger is set to 'timer', we need to wait for 'delay_on' and - # 'delay_off' attributes to appear with correct permissions. - if value == 'timer': - for attr in ('delay_on', 'delay_off'): - path = self._path + '/' + attr - - # Make sure the file has been created: - for _ in range(5): - if os.path.exists(path): - break - time.sleep(0.2) - else: - raise Exception('"{}" attribute has not been created'.format(attr)) - - # Make sure the file has correct permissions: - for _ in range(5): - mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE]) - if mode & stat.S_IRGRP and mode & stat.S_IWGRP: - break - time.sleep(0.2) - else: - raise Exception('"{}" attribute has wrong permissions'.format(attr)) - - @property - def delay_on(self): - """ - The `timer` trigger will periodically change the LED brightness between - 0 and the current brightness setting. The `on` time can - be specified via `delay_on` attribute in milliseconds. - """ - - # Workaround for ev3dev/ev3dev#225. - # 'delay_on' and 'delay_off' attributes are created when trigger is set - # to 'timer', and destroyed when it is set to anything else. - # This means the file cache may become outdated, and we may have to - # reopen the file. - for retry in (True, False): - try: - self._delay_on, value = self.get_attr_int(self._delay_on, 'delay_on') - return value - except OSError: - if retry: - self._delay_on = None - else: - raise - - @delay_on.setter - def delay_on(self, value): - # Workaround for ev3dev/ev3dev#225. - # 'delay_on' and 'delay_off' attributes are created when trigger is set - # to 'timer', and destroyed when it is set to anything else. - # This means the file cache may become outdated, and we may have to - # reopen the file. - for retry in (True, False): - try: - self._delay_on = self.set_attr_int(self._delay_on, 'delay_on', value) - return - except OSError: - if retry: - self._delay_on = None - else: - raise - - @property - def delay_off(self): - """ - The `timer` trigger will periodically change the LED brightness between - 0 and the current brightness setting. The `off` time can - be specified via `delay_off` attribute in milliseconds. - """ - - # Workaround for ev3dev/ev3dev#225. - # 'delay_on' and 'delay_off' attributes are created when trigger is set - # to 'timer', and destroyed when it is set to anything else. - # This means the file cache may become outdated, and we may have to - # reopen the file. - for retry in (True, False): - try: - self._delay_off, value = self.get_attr_int(self._delay_off, 'delay_off') - return value - except OSError: - if retry: - self._delay_off = None - else: - raise - - @delay_off.setter - def delay_off(self, value): - # Workaround for ev3dev/ev3dev#225. - # 'delay_on' and 'delay_off' attributes are created when trigger is set - # to 'timer', and destroyed when it is set to anything else. - # This means the file cache may become outdated, and we may have to - # reopen the file. - for retry in (True, False): - try: - self._delay_off = self.set_attr_int(self._delay_off, 'delay_off', value) - return - except OSError: - if retry: - self._delay_off = None - else: - raise - - - @property - def brightness_pct(self): - """ - Returns led brightness as a fraction of max_brightness - """ - return float(self.brightness) / self.max_brightness - - @brightness_pct.setter - def brightness_pct(self, value): - self.brightness = value * self.max_brightness - - -class ButtonEVIO(ButtonBase): - - """ - Provides a generic button reading mechanism that works with event interface - and may be adapted to platform specific implementations. - - This implementation depends on the availability of the EVIOCGKEY ioctl - to be able to read the button state buffer. See Linux kernel source - in /include/uapi/linux/input.h for details. - """ - - KEY_MAX = 0x2FF - KEY_BUF_LEN = int((KEY_MAX + 7) / 8) - EVIOCGKEY = (2 << (14 + 8 + 8) | KEY_BUF_LEN << (8 + 8) | ord('E') << 8 | 0x18) - - _buttons = {} - - def __init__(self): - ButtonBase.__init__(self) - self._file_cache = {} - self._buffer_cache = {} - - for b in self._buttons: - name = self._buttons[b]['name'] - if name not in self._file_cache: - self._file_cache[name] = open(name, 'rb', 0) - self._buffer_cache[name] = array.array('B', [0] * self.KEY_BUF_LEN) - - def _button_file(self, name): - return self._file_cache[name] - - def _button_buffer(self, name): - return self._buffer_cache[name] - - @property - def buttons_pressed(self): - """ - Returns list of names of pressed buttons. - """ - for b in self._buffer_cache: - fcntl.ioctl(self._button_file(b), self.EVIOCGKEY, self._buffer_cache[b]) - - pressed = [] - for k, v in self._buttons.items(): - buf = self._buffer_cache[v['name']] - bit = v['value'] - - if bool(buf[int(bit / 8)] & 1 << bit % 8): - pressed.append(k) - - return pressed - - -class PowerSupply(Device): - - """ - A generic interface to read data from the system's power_supply class. - Uses the built-in legoev3-battery if none is specified. - """ - - SYSTEM_CLASS_NAME = 'power_supply' - SYSTEM_DEVICE_NAME_CONVENTION = '*' - __slots__ = [ - '_measured_current', - '_measured_voltage', - '_max_voltage', - '_min_voltage', - '_technology', - '_type', - ] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(PowerSupply, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._measured_current = None - self._measured_voltage = None - self._max_voltage = None - self._min_voltage = None - self._technology = None - self._type = None - - @property - def measured_current(self): - """ - The measured current that the battery is supplying (in microamps) - """ - self._measured_current, value = self.get_attr_int(self._measured_current, 'current_now') - return value - - @property - def measured_voltage(self): - """ - The measured voltage that the battery is supplying (in microvolts) - """ - self._measured_voltage, value = self.get_attr_int(self._measured_voltage, 'voltage_now') - return value - - @property - def max_voltage(self): - """ - """ - self._max_voltage, value = self.get_attr_int(self._max_voltage, 'voltage_max_design') - return value - - @property - def min_voltage(self): - """ - """ - self._min_voltage, value = self.get_attr_int(self._min_voltage, 'voltage_min_design') - return value - - @property - def technology(self): - """ - """ - self._technology, value = self.get_attr_string(self._technology, 'technology') - return value - - @property - def type(self): - """ - """ - self._type, value = self.get_attr_string(self._type, 'type') - return value - - @property - def measured_amps(self): - """ - The measured current that the battery is supplying (in amps) - """ - return self.measured_current / 1e6 - - @property - def measured_volts(self): - """ - The measured voltage that the battery is supplying (in volts) - """ - return self.measured_voltage / 1e6 - - -class LegoPort(Device): - - """ - The `lego-port` class provides an interface for working with input and - output ports that are compatible with LEGO MINDSTORMS RCX/NXT/EV3, LEGO - WeDo and LEGO Power Functions sensors and motors. Supported devices include - the LEGO MINDSTORMS EV3 Intelligent Brick, the LEGO WeDo USB hub and - various sensor multiplexers from 3rd party manufacturers. - - Some types of ports may have multiple modes of operation. For example, the - input ports on the EV3 brick can communicate with sensors using UART, I2C - or analog validate signals - but not all at the same time. Therefore there - are multiple modes available to connect to the different types of sensors. - - In most cases, ports are able to automatically detect what type of sensor - or motor is connected. In some cases though, this must be manually specified - using the `mode` and `set_device` attributes. The `mode` attribute affects - how the port communicates with the connected device. For example the input - ports on the EV3 brick can communicate using UART, I2C or analog voltages, - but not all at the same time, so the mode must be set to the one that is - appropriate for the connected sensor. The `set_device` attribute is used to - specify the exact type of sensor that is connected. Note: the mode must be - correctly set before setting the sensor type. - - Ports can be found at `/sys/class/lego-port/port` where `` is - incremented each time a new port is registered. Note: The number is not - related to the actual port at all - use the `address` attribute to find - a specific port. - """ - - SYSTEM_CLASS_NAME = 'lego-port' - SYSTEM_DEVICE_NAME_CONVENTION = '*' - __slots__ = [ - '_address', - '_driver_name', - '_modes', - '_mode', - '_set_device', - '_status', - ] - - def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - - if address is not None: - kwargs['address'] = address - super(LegoPort, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - - self._address = None - self._driver_name = None - self._modes = None - self._mode = None - self._set_device = None - self._status = None - - @property - def address(self): - """ - Returns the name of the port. See individual driver documentation for - the name that will be returned. - """ - self._address, value = self.get_attr_string(self._address, 'address') - return value - - @property - def driver_name(self): - """ - Returns the name of the driver that loaded this device. You can find the - complete list of drivers in the [list of port drivers]. - """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') - return value - - @property - def modes(self): - """ - Returns a list of the available modes of the port. - """ - self._modes, value = self.get_attr_set(self._modes, 'modes') - return value - - @property - def mode(self): - """ - Reading returns the currently selected mode. Writing sets the mode. - Generally speaking when the mode changes any sensor or motor devices - associated with the port will be removed new ones loaded, however this - this will depend on the individual driver implementing this class. - """ - self._mode, value = self.get_attr_string(self._mode, 'mode') - return value - - @mode.setter - def mode(self, value): - self._mode = self.set_attr_string(self._mode, 'mode', value) - - @property - def set_device(self): - """ - For modes that support it, writing the name of a driver will cause a new - device to be registered for that driver and attached to this port. For - example, since NXT/Analog sensors cannot be auto-detected, you must use - this attribute to load the correct driver. Returns -EOPNOTSUPP if setting a - device is not supported. - """ - raise Exception("set_device is a write-only property!") - - @set_device.setter - def set_device(self, value): - self._set_device = self.set_attr_string(self._set_device, 'set_device', value) - - @property - def status(self): - """ - In most cases, reading status will return the same value as `mode`. In - cases where there is an `auto` mode additional values may be returned, - such as `no-device` or `error`. See individual port driver documentation - for the full list of possible values. - """ - self._status, value = self.get_attr_string(self._status, 'status') - return value - - -class FbMem(object): - - """The framebuffer memory object. - - Made of: - - the framebuffer file descriptor - - the fix screen info struct - - the var screen info struct - - the mapped memory - """ - - # ------------------------------------------------------------------ - # The code is adapted from - # https://github.com/LinkCareServices/cairotft/blob/master/cairotft/linuxfb.py - # - # The original code came with the following license: - # ------------------------------------------------------------------ - # Copyright (c) 2012 Kurichan - # - # This program is free software. It comes without any warranty, to - # the extent permitted by applicable law. You can redistribute it - # and/or modify it under the terms of the Do What The Fuck You Want - # To Public License, Version 2, as published by Sam Hocevar. See - # http://sam.zoy.org/wtfpl/COPYING for more details. - # ------------------------------------------------------------------ - - __slots__ = ('fid', 'fix_info', 'var_info', 'mmap') - - FBIOGET_VSCREENINFO = 0x4600 - FBIOGET_FSCREENINFO = 0x4602 - - FB_VISUAL_MONO01 = 0 - FB_VISUAL_MONO10 = 1 - - class FixScreenInfo(ctypes.Structure): - - """The fb_fix_screeninfo from fb.h.""" - - _fields_ = [ - ('id_name', ctypes.c_char * 16), - ('smem_start', ctypes.c_ulong), - ('smem_len', ctypes.c_uint32), - ('type', ctypes.c_uint32), - ('type_aux', ctypes.c_uint32), - ('visual', ctypes.c_uint32), - ('xpanstep', ctypes.c_uint16), - ('ypanstep', ctypes.c_uint16), - ('ywrapstep', ctypes.c_uint16), - ('line_length', ctypes.c_uint32), - ('mmio_start', ctypes.c_ulong), - ('mmio_len', ctypes.c_uint32), - ('accel', ctypes.c_uint32), - ('reserved', ctypes.c_uint16 * 3), - ] - - class VarScreenInfo(ctypes.Structure): - - class FbBitField(ctypes.Structure): - - """The fb_bitfield struct from fb.h.""" - - _fields_ = [ - ('offset', ctypes.c_uint32), - ('length', ctypes.c_uint32), - ('msb_right', ctypes.c_uint32), - ] - - """The fb_var_screeninfo struct from fb.h.""" - - _fields_ = [ - ('xres', ctypes.c_uint32), - ('yres', ctypes.c_uint32), - ('xres_virtual', ctypes.c_uint32), - ('yres_virtual', ctypes.c_uint32), - ('xoffset', ctypes.c_uint32), - ('yoffset', ctypes.c_uint32), - - ('bits_per_pixel', ctypes.c_uint32), - ('grayscale', ctypes.c_uint32), - - ('red', FbBitField), - ('green', FbBitField), - ('blue', FbBitField), - ('transp', FbBitField), - ] - - def __init__(self, fbdev=None): - """Create the FbMem framebuffer memory object.""" - fid = FbMem._open_fbdev(fbdev) - fix_info = FbMem._get_fix_info(fid) - fbmmap = FbMem._map_fb_memory(fid, fix_info) - self.fid = fid - self.fix_info = fix_info - self.var_info = FbMem._get_var_info(fid) - self.mmap = fbmmap - - def __del__(self): - """Close the FbMem framebuffer memory object.""" - self.mmap.close() - FbMem._close_fbdev(self.fid) - - @staticmethod - def _open_fbdev(fbdev=None): - """Return the framebuffer file descriptor. - - Try to use the FRAMEBUFFER - environment variable if fbdev is not given. Use '/dev/fb0' by - default. - """ - dev = fbdev or os.getenv('FRAMEBUFFER', '/dev/fb0') - fbfid = os.open(dev, os.O_RDWR) - return fbfid - - @staticmethod - def _close_fbdev(fbfid): - """Close the framebuffer file descriptor.""" - os.close(fbfid) - - @staticmethod - def _get_fix_info(fbfid): - """Return the fix screen info from the framebuffer file descriptor.""" - fix_info = FbMem.FixScreenInfo() - fcntl.ioctl(fbfid, FbMem.FBIOGET_FSCREENINFO, fix_info) - return fix_info - - @staticmethod - def _get_var_info(fbfid): - """Return the var screen info from the framebuffer file descriptor.""" - var_info = FbMem.VarScreenInfo() - fcntl.ioctl(fbfid, FbMem.FBIOGET_VSCREENINFO, var_info) - return var_info - - @staticmethod - def _map_fb_memory(fbfid, fix_info): - """Map the framebuffer memory.""" - return mmap.mmap( - fbfid, - fix_info.smem_len, - mmap.MAP_SHARED, - mmap.PROT_READ | mmap.PROT_WRITE, - offset=0 - ) - - -class Screen(FbMem): - """ - A convenience wrapper for the FbMem class. - Provides drawing functions from the python imaging library (PIL). - """ - - def __init__(self): - from PIL import Image, ImageDraw - FbMem.__init__(self) - - self._img = Image.new( - self.var_info.bits_per_pixel == 1 and "1" or "RGB", - (self.fix_info.line_length * 8 // self.var_info.bits_per_pixel, self.yres), - "white") - - self._draw = ImageDraw.Draw(self._img) - - @property - def xres(self): - """ - Horizontal screen resolution - """ - return self.var_info.xres - - @property - def yres(self): - """ - Vertical screen resolution - """ - return self.var_info.yres - - @property - def shape(self): - """ - Dimensions of the screen. - """ - return (self.xres, self.yres) - - @property - def draw(self): - """ - Returns a handle to PIL.ImageDraw.Draw class associated with the screen. - - Example:: - - screen.draw.rectangle((10,10,60,20), fill='black') - """ - return self._draw - - @property - def image(self): - """ - Returns a handle to PIL.Image class that is backing the screen. This can - be accessed for blitting images to the screen. - - Example:: - - screen.image.paste(picture, (0, 0)) - """ - return self._img - - def clear(self): - """ - Clears the screen - """ - self._draw.rectangle(((0, 0), self.shape), fill="white") - - def _color565(self, r, g, b): - """Convert red, green, blue components to a 16-bit 565 RGB value. Components - should be values 0 to 255. - """ - return (((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)) - - def _img_to_rgb565_bytes(self): - pixels = [self._color565(r, g, b) for (r, g, b) in self._img.getdata()] - return pack('H' * len(pixels), *pixels) - - def update(self): - """ - Applies pending changes to the screen. - Nothing will be drawn on the screen until this function is called. - """ - if self.var_info.bits_per_pixel == 1: - self.mmap[:] = self._img.tobytes("raw", "1;IR") - elif self.var_info.bits_per_pixel == 16: - self.mmap[:] = self._img_to_rgb565_bytes() - else: - raise Exception("Not supported") - - -def _make_scales(notes): - """ Utility function used by Sound class for building the note frequencies table """ - res = dict() - for note, freq in notes: - freq = round(freq) - for n in note.split('/'): - res[n] = freq - return res - - -class Sound: - """ - Sound-related functions. The class has only static methods and is not - intended for instantiation. It can beep, play wav files, or convert text to - speech. - - Note that all methods of the class spawn system processes and return - subprocess.Popen objects. The methods are asynchronous (they return - immediately after child process was spawned, without waiting for its - completion), but you can call wait() on the returned result. - - Examples:: - - # Play 'bark.wav', return immediately: - Sound.play('bark.wav') - - # Introduce yourself, wait for completion: - Sound.speak('Hello, I am Robot').wait() - - # Play a small song - Sound.play_song(( - ('D4', 'e3'), - ('D4', 'e3'), - ('D4', 'e3'), - ('G4', 'h'), - ('D5', 'h') - )) - """ - - channel = None - - @staticmethod - def beep(args=''): - """ - Call beep command with the provided arguments (if any). - See `beep man page`_ and google `linux beep music`_ for inspiration. - - .. _`beep man page`: https://linux.die.net/man/1/beep - .. _`linux beep music`: https://www.google.com/search?q=linux+beep+music - """ - with open(os.devnull, 'w') as n: - return Popen(shlex.split('/usr/bin/beep %s' % args), stdout=n) - - @staticmethod - def tone(*args): - """ - .. rubric:: tone(tone_sequence) - - Play tone sequence. The tone_sequence parameter is a list of tuples, - where each tuple contains up to three numbers. The first number is - frequency in Hz, the second is duration in milliseconds, and the third - is delay in milliseconds between this and the next tone in the - sequence. - - Here is a cheerful example:: - - Sound.tone([ - (392, 350, 100), (392, 350, 100), (392, 350, 100), (311.1, 250, 100), - (466.2, 25, 100), (392, 350, 100), (311.1, 250, 100), (466.2, 25, 100), - (392, 700, 100), (587.32, 350, 100), (587.32, 350, 100), - (587.32, 350, 100), (622.26, 250, 100), (466.2, 25, 100), - (369.99, 350, 100), (311.1, 250, 100), (466.2, 25, 100), (392, 700, 100), - (784, 350, 100), (392, 250, 100), (392, 25, 100), (784, 350, 100), - (739.98, 250, 100), (698.46, 25, 100), (659.26, 25, 100), - (622.26, 25, 100), (659.26, 50, 400), (415.3, 25, 200), (554.36, 350, 100), - (523.25, 250, 100), (493.88, 25, 100), (466.16, 25, 100), (440, 25, 100), - (466.16, 50, 400), (311.13, 25, 200), (369.99, 350, 100), - (311.13, 250, 100), (392, 25, 100), (466.16, 350, 100), (392, 250, 100), - (466.16, 25, 100), (587.32, 700, 100), (784, 350, 100), (392, 250, 100), - (392, 25, 100), (784, 350, 100), (739.98, 250, 100), (698.46, 25, 100), - (659.26, 25, 100), (622.26, 25, 100), (659.26, 50, 400), (415.3, 25, 200), - (554.36, 350, 100), (523.25, 250, 100), (493.88, 25, 100), - (466.16, 25, 100), (440, 25, 100), (466.16, 50, 400), (311.13, 25, 200), - (392, 350, 100), (311.13, 250, 100), (466.16, 25, 100), - (392.00, 300, 150), (311.13, 250, 100), (466.16, 25, 100), (392, 700) - ]).wait() - - .. rubric:: tone(frequency, duration) - - Play single tone of given frequency (Hz) and duration (milliseconds). - """ - def play_tone_sequence(tone_sequence): - def beep_args(frequency=None, duration=None, delay=None): - args = '' - if frequency is not None: args += '-f %s ' % frequency - if duration is not None: args += '-l %s ' % duration - if delay is not None: args += '-D %s ' % delay - - return args - - return Sound.beep(' -n '.join([beep_args(*t) for t in tone_sequence])) - - if len(args) == 1: - return play_tone_sequence(args[0]) - elif len(args) == 2: - return play_tone_sequence([(args[0], args[1])]) - else: - raise Exception("Unsupported number of parameters in Sound.tone()") - - @staticmethod - def play(wav_file): - """ - Play wav file. - """ - with open(os.devnull, 'w') as n: - return Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) - - @staticmethod - def speak(text, espeak_opts='-a 200 -s 130'): - """ - Speak the given text aloud. - """ - with open(os.devnull, 'w') as n: - cmd_line = '/usr/bin/espeak --stdout {0} "{1}"'.format(espeak_opts, text) - espeak = Popen(shlex.split(cmd_line), stdout=PIPE) - play = Popen(['/usr/bin/aplay', '-q'], stdin=espeak.stdout, stdout=n) - return espeak - - @staticmethod - def _get_channel(): - """ - :return: the detected sound channel - :rtype: str - """ - if Sound.channel is None: - # Get default channel as the first one that pops up in - # 'amixer scontrols' output, which contains strings in the - # following format: - # - # Simple mixer control 'Master',0 - # Simple mixer control 'Capture',0 - out = check_output(['amixer', 'scontrols']).decode() - m = re.search("'(?P[^']+)'", out) - if m: - Sound.channel = m.group('channel') - else: - Sound.channel = 'Playback' - - return Sound.channel - - @staticmethod - def set_volume(pct, channel=None): - """ - Sets the sound volume to the given percentage [0-100] by calling - ``amixer -q set %``. - If the channel is not specified, it tries to determine the default one - by running ``amixer scontrols``. If that fails as well, it uses the - ``Playback`` channel, as that is the only channel on the EV3. - """ - - if channel is None: - channel = Sound._get_channel() - - cmd_line = '/usr/bin/amixer -q set {0} {1:d}%'.format(channel, pct) - Popen(shlex.split(cmd_line)).wait() - - @staticmethod - def get_volume(channel=None): - """ - Gets the current sound volume by parsing the output of - ``amixer get ``. - If the channel is not specified, it tries to determine the default one - by running ``amixer scontrols``. If that fails as well, it uses the - ``Playback`` channel, as that is the only channel on the EV3. - """ - - if channel is None: - channel = Sound._get_channel() - - out = check_output(['amixer', 'get', channel]).decode() - m = re.search('\[(?P\d+)%\]', out) - if m: - return int(m.group('volume')) - else: - raise Exception('Failed to parse output of `amixer get {}`'.format(channel)) - - @classmethod - def play_song(cls, song, tempo=120, delay=50): - """ Plays a song provided as a list of tuples containing the note name and its - value using music conventional notation instead of numerical values for frequency - and duration. - - It supports symbolic notes (e.g. ``A4``, ``D#3``, ``Gb5``) and durations (e.g. ``q``, ``h``). - - For an exhaustive list of accepted note symbols and values, have a look at the :py:attr:`_NOTE_FREQUENCIES` - and :py:attr:`_NOTE_VALUES` private dictionaries in the source code. - - The value can be suffixed by modifiers: - - - a *divider* introduced by a ``/`` to obtain triplets for instance - (e.g. ``q/3`` for a triplet of eight note) - - a *multiplier* introduced by ``*`` (e.g. ``*1.5`` is a dotted note). - - Shortcuts exist for common modifiers: - - - ``3`` produces a triplet member note. For instance `e3` gives a triplet of eight notes, - i.e. 3 eight notes in the duration of a single quarter. You must ensure that 3 triplets - notes are defined in sequence to match the count, otherwise the result will not be the - expected one. - - ``.`` produces a dotted note, i.e. which duration is one and a half the base one. Double dots - are not currently supported. - - Example:: - - >>> # A long time ago in a galaxy far, - >>> # far away... - >>> Sound.play_song(( - >>> ('D4', 'e3'), # intro anacrouse - >>> ('D4', 'e3'), - >>> ('D4', 'e3'), - >>> ('G4', 'h'), # meas 1 - >>> ('D5', 'h'), - >>> ('C5', 'e3'), # meas 2 - >>> ('B4', 'e3'), - >>> ('A4', 'e3'), - >>> ('G5', 'h'), - >>> ('D5', 'q'), - >>> ('C5', 'e3'), # meas 3 - >>> ('B4', 'e3'), - >>> ('A4', 'e3'), - >>> ('G5', 'h'), - >>> ('D5', 'q'), - >>> ('C5', 'e3'), # meas 4 - >>> ('B4', 'e3'), - >>> ('C5', 'e3'), - >>> ('A4', 'h.'), - >>> )) - - .. important:: - - Only 4/4 signature songs are supported with respect to note durations. - - Args: - song (iterable[tuple(str, str)]): the song - tempo (int): the song tempo, given in quarters per minute - delay (int): delay in ms between notes - - Returns: - subprocess.Popen: the spawn subprocess - """ - meas_duration = 60000 / tempo * 4 - - def beep_args(note, value): - """ Builds the arguments string for producing a beep matching - the requested note and value. - - Args: - note (str): the note note and octave - value (str): the note value expression - Returns: - str: the arguments to be passed to the beep command - """ - freq = Sound._NOTE_FREQUENCIES[note.upper()] - if '/' in value: - base, factor = value.split('/') - duration = meas_duration * Sound._NOTE_VALUES[base] / float(factor) - elif '*' in value: - base, factor = value.split('*') - duration = meas_duration * Sound._NOTE_VALUES[base] * float(factor) - elif value.endswith('.'): - base = value[:-1] - duration = meas_duration * Sound._NOTE_VALUES[base] * 1.5 - elif value.endswith('3'): - base = value[:-1] - duration = meas_duration * Sound._NOTE_VALUES[base] * 2 / 3 - else: - duration = meas_duration * Sound._NOTE_VALUES[value] - - return '-f %d -l %d -D %d' % (freq, duration, delay) - - return Sound.beep(' -n '.join( - [beep_args(note, value) for note, value in song] - )) - - #: Note frequencies. - #: - #: This dictionary gives the rounded frequency of a note specified by its - #: standard US abbreviation and its octave number (e.g. ``C3``). - #: Alterations use the ``#`` and ``b`` symbols, respectively for - #: *sharp* and *flat*, between the note code and the octave number (e.g. ``D#4``, ``Gb5``). - _NOTE_FREQUENCIES = _make_scales(( - ('C0', 16.35), - ('C#0/Db0', 17.32), - ('D0', 18.35), - ('D#0/Eb0', 19.45), # expanded in one entry per symbol by _make_scales - ('E0', 20.60), - ('F0', 21.83), - ('F#0/Gb0', 23.12), - ('G0', 24.50), - ('G#0/Ab0', 25.96), - ('A0', 27.50), - ('A#0/Bb0', 29.14), - ('B0', 30.87), - ('C1', 32.70), - ('C#1/Db1', 34.65), - ('D1', 36.71), - ('D#1/Eb1', 38.89), - ('E1', 41.20), - ('F1', 43.65), - ('F#1/Gb1', 46.25), - ('G1', 49.00), - ('G#1/Ab1', 51.91), - ('A1', 55.00), - ('A#1/Bb1', 58.27), - ('B1', 61.74), - ('C2', 65.41), - ('C#2/Db2', 69.30), - ('D2', 73.42), - ('D#2/Eb2', 77.78), - ('E2', 82.41), - ('F2', 87.31), - ('F#2/Gb2', 92.50), - ('G2', 98.00), - ('G#2/Ab2', 103.83), - ('A2', 110.00), - ('A#2/Bb2', 116.54), - ('B2', 123.47), - ('C3', 130.81), - ('C#3/Db3', 138.59), - ('D3', 146.83), - ('D#3/Eb3', 155.56), - ('E3', 164.81), - ('F3', 174.61), - ('F#3/Gb3', 185.00), - ('G3', 196.00), - ('G#3/Ab3', 207.65), - ('A3', 220.00), - ('A#3/Bb3', 233.08), - ('B3', 246.94), - ('C4', 261.63), - ('C#4/Db4', 277.18), - ('D4', 293.66), - ('D#4/Eb4', 311.13), - ('E4', 329.63), - ('F4', 349.23), - ('F#4/Gb4', 369.99), - ('G4', 392.00), - ('G#4/Ab4', 415.30), - ('A4', 440.00), - ('A#4/Bb4', 466.16), - ('B4', 493.88), - ('C5', 523.25), - ('C#5/Db5', 554.37), - ('D5', 587.33), - ('D#5/Eb5', 622.25), - ('E5', 659.25), - ('F5', 698.46), - ('F#5/Gb5', 739.99), - ('G5', 783.99), - ('G#5/Ab5', 830.61), - ('A5', 880.00), - ('A#5/Bb5', 932.33), - ('B5', 987.77), - ('C6', 1046.50), - ('C#6/Db6', 1108.73), - ('D6', 1174.66), - ('D#6/Eb6', 1244.51), - ('E6', 1318.51), - ('F6', 1396.91), - ('F#6/Gb6', 1479.98), - ('G6', 1567.98), - ('G#6/Ab6', 1661.22), - ('A6', 1760.00), - ('A#6/Bb6', 1864.66), - ('B6', 1975.53), - ('C7', 2093.00), - ('C#7/Db7', 2217.46), - ('D7', 2349.32), - ('D#7/Eb7', 2489.02), - ('E7', 2637.02), - ('F7', 2793.83), - ('F#7/Gb7', 2959.96), - ('G7', 3135.96), - ('G#7/Ab7', 3322.44), - ('A7', 3520.00), - ('A#7/Bb7', 3729.31), - ('B7', 3951.07), - ('C8', 4186.01), - ('C#8/Db8', 4434.92), - ('D8', 4698.63), - ('D#8/Eb8', 4978.03), - ('E8', 5274.04), - ('F8', 5587.65), - ('F#8/Gb8', 5919.91), - ('G8', 6271.93), - ('G#8/Ab8', 6644.88), - ('A8', 7040.00), - ('A#8/Bb8', 7458.62), - ('B8', 7902.13) - )) - - #: Common note values. - #: - #: See https://en.wikipedia.org/wiki/Note_value - #: - #: This dictionary provides the multiplier to be applied to de whole note duration - #: to obtain subdivisions, given the corresponding symbolic identifier: - #: - #: = =============================== - #: w whole note (UK: semibreve) - #: h half note (UK: minim) - #: q quarter note (UK: crotchet) - #: e eight note (UK: quaver) - #: s sixteenth note (UK: semiquaver) - #: = =============================== - #: - #: - #: Triplets can be obtained by dividing the corresponding reference by 3. - #: For instance, the note value of a eight triplet will be ``NOTE_VALUE['e'] / 3``. - #: It is simpler however to user the ``3`` modifier of notes, as supported by the - #: :py:meth:`Sound.play_song` method. - _NOTE_VALUES = { - 'w': 1., - 'h': 1./2, - 'q': 1./4, - 'e': 1./8, - 's': 1./16, - } - - diff --git a/ev3dev/display.py b/ev3dev/display.py new file mode 100644 index 0000000..416be8b --- /dev/null +++ b/ev3dev/display.py @@ -0,0 +1,269 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import os +import mmap +import ctypes +from struct import pack +import fcntl + + +class FbMem(object): + + """The framebuffer memory object. + + Made of: + - the framebuffer file descriptor + - the fix screen info struct + - the var screen info struct + - the mapped memory + """ + + # ------------------------------------------------------------------ + # The code is adapted from + # https://github.com/LinkCareServices/cairotft/blob/master/cairotft/linuxfb.py + # + # The original code came with the following license: + # ------------------------------------------------------------------ + # Copyright (c) 2012 Kurichan + # + # This program is free software. It comes without any warranty, to + # the extent permitted by applicable law. You can redistribute it + # and/or modify it under the terms of the Do What The Fuck You Want + # To Public License, Version 2, as published by Sam Hocevar. See + # http://sam.zoy.org/wtfpl/COPYING for more details. + # ------------------------------------------------------------------ + + __slots__ = ('fid', 'fix_info', 'var_info', 'mmap') + + FBIOGET_VSCREENINFO = 0x4600 + FBIOGET_FSCREENINFO = 0x4602 + + FB_VISUAL_MONO01 = 0 + FB_VISUAL_MONO10 = 1 + + class FixScreenInfo(ctypes.Structure): + + """The fb_fix_screeninfo from fb.h.""" + + _fields_ = [ + ('id_name', ctypes.c_char * 16), + ('smem_start', ctypes.c_ulong), + ('smem_len', ctypes.c_uint32), + ('type', ctypes.c_uint32), + ('type_aux', ctypes.c_uint32), + ('visual', ctypes.c_uint32), + ('xpanstep', ctypes.c_uint16), + ('ypanstep', ctypes.c_uint16), + ('ywrapstep', ctypes.c_uint16), + ('line_length', ctypes.c_uint32), + ('mmio_start', ctypes.c_ulong), + ('mmio_len', ctypes.c_uint32), + ('accel', ctypes.c_uint32), + ('reserved', ctypes.c_uint16 * 3), + ] + + class VarScreenInfo(ctypes.Structure): + + class FbBitField(ctypes.Structure): + + """The fb_bitfield struct from fb.h.""" + + _fields_ = [ + ('offset', ctypes.c_uint32), + ('length', ctypes.c_uint32), + ('msb_right', ctypes.c_uint32), + ] + + """The fb_var_screeninfo struct from fb.h.""" + + _fields_ = [ + ('xres', ctypes.c_uint32), + ('yres', ctypes.c_uint32), + ('xres_virtual', ctypes.c_uint32), + ('yres_virtual', ctypes.c_uint32), + ('xoffset', ctypes.c_uint32), + ('yoffset', ctypes.c_uint32), + + ('bits_per_pixel', ctypes.c_uint32), + ('grayscale', ctypes.c_uint32), + + ('red', FbBitField), + ('green', FbBitField), + ('blue', FbBitField), + ('transp', FbBitField), + ] + + def __init__(self, fbdev=None): + """Create the FbMem framebuffer memory object.""" + fid = FbMem._open_fbdev(fbdev) + fix_info = FbMem._get_fix_info(fid) + fbmmap = FbMem._map_fb_memory(fid, fix_info) + self.fid = fid + self.fix_info = fix_info + self.var_info = FbMem._get_var_info(fid) + self.mmap = fbmmap + + def __del__(self): + """Close the FbMem framebuffer memory object.""" + self.mmap.close() + FbMem._close_fbdev(self.fid) + + @staticmethod + def _open_fbdev(fbdev=None): + """Return the framebuffer file descriptor. + + Try to use the FRAMEBUFFER + environment variable if fbdev is not given. Use '/dev/fb0' by + default. + """ + dev = fbdev or os.getenv('FRAMEBUFFER', '/dev/fb0') + fbfid = os.open(dev, os.O_RDWR) + return fbfid + + @staticmethod + def _close_fbdev(fbfid): + """Close the framebuffer file descriptor.""" + os.close(fbfid) + + @staticmethod + def _get_fix_info(fbfid): + """Return the fix screen info from the framebuffer file descriptor.""" + fix_info = FbMem.FixScreenInfo() + fcntl.ioctl(fbfid, FbMem.FBIOGET_FSCREENINFO, fix_info) + return fix_info + + @staticmethod + def _get_var_info(fbfid): + """Return the var screen info from the framebuffer file descriptor.""" + var_info = FbMem.VarScreenInfo() + fcntl.ioctl(fbfid, FbMem.FBIOGET_VSCREENINFO, var_info) + return var_info + + @staticmethod + def _map_fb_memory(fbfid, fix_info): + """Map the framebuffer memory.""" + return mmap.mmap( + fbfid, + fix_info.smem_len, + mmap.MAP_SHARED, + mmap.PROT_READ | mmap.PROT_WRITE, + offset=0 + ) + + +class Screen(FbMem): + """ + A convenience wrapper for the FbMem class. + Provides drawing functions from the python imaging library (PIL). + """ + + def __init__(self): + from PIL import Image, ImageDraw + FbMem.__init__(self) + + self._img = Image.new( + self.var_info.bits_per_pixel == 1 and "1" or "RGB", + (self.fix_info.line_length * 8 // self.var_info.bits_per_pixel, self.yres), + "white") + + self._draw = ImageDraw.Draw(self._img) + + @property + def xres(self): + """ + Horizontal screen resolution + """ + return self.var_info.xres + + @property + def yres(self): + """ + Vertical screen resolution + """ + return self.var_info.yres + + @property + def shape(self): + """ + Dimensions of the screen. + """ + return (self.xres, self.yres) + + @property + def draw(self): + """ + Returns a handle to PIL.ImageDraw.Draw class associated with the screen. + + Example:: + + screen.draw.rectangle((10,10,60,20), fill='black') + """ + return self._draw + + @property + def image(self): + """ + Returns a handle to PIL.Image class that is backing the screen. This can + be accessed for blitting images to the screen. + + Example:: + + screen.image.paste(picture, (0, 0)) + """ + return self._img + + def clear(self): + """ + Clears the screen + """ + self._draw.rectangle(((0, 0), self.shape), fill="white") + + def _color565(self, r, g, b): + """Convert red, green, blue components to a 16-bit 565 RGB value. Components + should be values 0 to 255. + """ + return (((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)) + + def _img_to_rgb565_bytes(self): + pixels = [self._color565(r, g, b) for (r, g, b) in self._img.getdata()] + return pack('H' * len(pixels), *pixels) + + def update(self): + """ + Applies pending changes to the screen. + Nothing will be drawn on the screen until this function is called. + """ + if self.var_info.bits_per_pixel == 1: + self.mmap[:] = self._img.tobytes("raw", "1;IR") + elif self.var_info.bits_per_pixel == 16: + self.mmap[:] = self._img_to_rgb565_bytes() + else: + raise Exception("Not supported") diff --git a/ev3dev/ev3.py b/ev3dev/ev3.py deleted file mode 100644 index 85fd864..0000000 --- a/ev3dev/ev3.py +++ /dev/null @@ -1,167 +0,0 @@ -# -*- coding: utf-8 -*- - -# ------------------------------------------------------------------------------ -# Copyright (c) 2015 Eric Pascual -# -# 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 NON INFRINGEMENT. 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. -# ----------------------------------------------------------------------------- - -""" -An assortment of classes modeling specific features of the EV3 brick. -""" - -from .core import * - - -OUTPUT_A = 'outA' -OUTPUT_B = 'outB' -OUTPUT_C = 'outC' -OUTPUT_D = 'outD' - -INPUT_1 = 'in1' -INPUT_2 = 'in2' -INPUT_3 = 'in3' -INPUT_4 = 'in4' - - -class Leds(object): - """ - The EV3 LEDs. - """ - red_left = Led(name_pattern='led0:red:brick-status') - red_right = Led(name_pattern='led1:red:brick-status') - green_left = Led(name_pattern='led0:green:brick-status') - green_right = Led(name_pattern='led1:green:brick-status') - - LEFT = ( red_left, green_left, ) - RIGHT = ( red_right, green_right, ) - - BLACK = ( 0, 0, ) - RED = ( 1, 0, ) - GREEN = ( 0, 1, ) - AMBER = ( 1, 1, ) - ORANGE = ( 1, 0.5, ) - YELLOW = ( 0.1, 1, ) - - @staticmethod - def set_color(group, color, pct=1): - """ - Sets brigthness of leds in the given group to the values specified in - color tuple. When percentage is specified, brightness of each led is - reduced proportionally. - - Example:: - - Leds.set_color(LEFT, AMBER) - """ - for l, v in zip(group, color): - l.brightness_pct = v * pct - - @staticmethod - def set(group, **kwargs): - """ - Set attributes for each led in group. - - Example:: - - Leds.set(LEFT, brightness_pct=0.5, trigger='timer') - """ - for led in group: - for k in kwargs: - setattr(led, k, kwargs[k]) - - @staticmethod - def all_off(): - """ - Turn all leds off - """ - Leds.red_left.brightness = 0 - Leds.red_right.brightness = 0 - Leds.green_left.brightness = 0 - Leds.green_right.brightness = 0 - - -class Button(ButtonEVIO): - """ - EV3 Buttons - """ - - _buttons_filename = '/dev/input/by-path/platform-gpio_keys-event' - _buttons = { - 'up': {'name': _buttons_filename, 'value': 103}, - 'down': {'name': _buttons_filename, 'value': 108}, - 'left': {'name': _buttons_filename, 'value': 105}, - 'right': {'name': _buttons_filename, 'value': 106}, - 'enter': {'name': _buttons_filename, 'value': 28}, - 'backspace': {'name': _buttons_filename, 'value': 14}, - } - evdev_device_name = 'EV3 brick buttons' - - ''' - These handlers are called by `process()` whenever state of 'up', 'down', - etc buttons have changed since last `process()` call - ''' - on_up = None - on_down = None - on_left = None - on_right = None - on_enter = None - on_backspace = None - - @property - def up(self): - """ - Check if 'up' button is pressed. - """ - return 'up' in self.buttons_pressed - - @property - def down(self): - """ - Check if 'down' button is pressed. - """ - return 'down' in self.buttons_pressed - - @property - def left(self): - """ - Check if 'left' button is pressed. - """ - return 'left' in self.buttons_pressed - - @property - def right(self): - """ - Check if 'right' button is pressed. - """ - return 'right' in self.buttons_pressed - - @property - def enter(self): - """ - Check if 'enter' button is pressed. - """ - return 'enter' in self.buttons_pressed - - @property - def backspace(self): - """ - Check if 'backspace' button is pressed. - """ - return 'backspace' in self.buttons_pressed diff --git a/ev3dev/evb.py b/ev3dev/evb.py deleted file mode 100644 index 2c056cd..0000000 --- a/ev3dev/evb.py +++ /dev/null @@ -1,87 +0,0 @@ - -""" -An assortment of classes modeling specific features of the EVB. -""" - -from .core import * - - -OUTPUT_A = 'outA' -OUTPUT_B = 'outB' -OUTPUT_C = 'outC' -OUTPUT_D = 'outD' - -INPUT_1 = 'in1' -INPUT_2 = 'in2' -INPUT_3 = 'in3' -INPUT_4 = 'in4' - - -class Button(ButtonEVIO): - """ - EVB Buttons - """ - - _buttons_filename = '/dev/input/by-path/platform-evb-buttons-event' - _buttons = { - 'up': {'name': _buttons_filename, 'value': 103}, - 'down': {'name': _buttons_filename, 'value': 108}, - 'left': {'name': _buttons_filename, 'value': 105}, - 'right': {'name': _buttons_filename, 'value': 106}, - 'enter': {'name': _buttons_filename, 'value': 28}, - 'backspace': {'name': _buttons_filename, 'value': 14}, - } - evdev_device_name = 'evb-input' - - ''' - These handlers are called by `process()` whenever state of 'up', 'down', - etc buttons have changed since last `process()` call - ''' - on_up = None - on_down = None - on_left = None - on_right = None - on_enter = None - on_backspace = None - - @property - def up(self): - """ - Check if 'up' button is pressed. - """ - return 'up' in self.buttons_pressed - - @property - def down(self): - """ - Check if 'down' button is pressed. - """ - return 'down' in self.buttons_pressed - - @property - def left(self): - """ - Check if 'left' button is pressed. - """ - return 'left' in self.buttons_pressed - - @property - def right(self): - """ - Check if 'right' button is pressed. - """ - return 'right' in self.buttons_pressed - - @property - def enter(self): - """ - Check if 'enter' button is pressed. - """ - return 'enter' in self.buttons_pressed - - @property - def backspace(self): - """ - Check if 'backspace' button is pressed. - """ - return 'backspace' in self.buttons_pressed diff --git a/ev3dev/helper.py b/ev3dev/helper.py deleted file mode 100644 index bdfec54..0000000 --- a/ev3dev/helper.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 - -import logging -import math -import os -import re -import sys -import time -import ev3dev.auto -from collections import OrderedDict -from ev3dev.auto import InfraredSensor, MoveTank -from time import sleep - -log = logging.getLogger(__name__) - - -# ============= -# Motor classes -# ============= -class MotorStartFail(Exception): - pass - - -class MotorStopFail(Exception): - pass - - -class MotorPositionFail(Exception): - pass - - -class MotorStall(Exception): - pass - - -class MotorMixin(object): - shutdown = False - - def running(self): - prev_pos = self.position - time.sleep(0.01) - pos = self.position - return True if pos != prev_pos else False - - def wait_for_running(self, timeout=5): - """ - timeout is in seconds - """ - tic = time.time() + timeout - prev_pos = None - - while time.time() < tic: - - if self.shutdown: - break - - pos = self.position - - if prev_pos is not None and pos != prev_pos: - break - else: - prev_pos = pos - time.sleep(0.001) - else: - raise MotorStartFail("%s: failed to start within %ds" % (self, timeout)) - - def wait_for_stop(self, timeout=60): - """ - timeout is in seconds - """ - tic = time.time() + timeout - prev_pos = None - stall_count = 0 - - while time.time() < tic: - if self.shutdown: - break - - pos = self.position - log.debug("%s: wait_for_stop() pos %s, prev_pos %s, stall_count %d" % (self, pos, prev_pos, stall_count)) - - if prev_pos is not None and pos == prev_pos: - stall_count += 1 - else: - stall_count = 0 - - prev_pos = pos - - if stall_count >= 5: - break - else: - time.sleep(0.001) - else: - raise MotorStopFail("%s: failed to stop within %ds" % (self, timeout)) - - def wait_for_position(self, target_position, delta=2, timeout=10, stall_ok=False): - """ - delta is in degrees - timeout is in seconds - """ - min_pos = target_position - delta - max_pos = target_position + delta - time_cutoff = time.time() + timeout - prev_pos = None - stall_count = 0 - - while time.time() < time_cutoff: - - if self.shutdown: - break - - pos = self.position - log.debug("%s: wait_for_pos() pos %d/%d, min_pos %d, max_pos %d" % (self, pos, target_position, min_pos, max_pos)) - - if pos >= min_pos and pos <= max_pos: - break - - if prev_pos is not None and pos == prev_pos: - stall_count += 1 - else: - stall_count = 0 - - if stall_count == 50: - if stall_ok: - log.warning("%s: stalled at position %d, target was %d" % (self, pos, target_position)) - break - else: - raise MotorStall("%s: stalled at position %d, target was %d" % (self, pos, target_position)) - - prev_pos = pos - time.sleep(0.001) - else: - raise MotorPositionFail("%s: failed to reach %s within %ss, current position %d" % - (self, target_position, timeout, pos)) - - -class LargeMotor(ev3dev.auto.LargeMotor, MotorMixin): - pass - - -class MediumMotor(ev3dev.auto.MediumMotor, MotorMixin): - pass - - -# ============ -# Tank classes -# ============ -class RemoteControlledTank(MoveTank): - - def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed=400, channel=1): - MoveTank.__init__(self, left_motor_port, right_motor_port) - self.set_polarity(polarity) - - left_motor = self.motors[left_motor_port] - right_motor = self.motors[right_motor_port] - self.speed_sp = speed - self.remote = InfraredSensor() - self.remote.on_red_up = self.make_move(left_motor, self.speed_sp) - self.remote.on_red_down = self.make_move(left_motor, self.speed_sp* -1) - self.remote.on_blue_up = self.make_move(right_motor, self.speed_sp) - self.remote.on_blue_down = self.make_move(right_motor, self.speed_sp * -1) - self.channel = channel - - def make_move(self, motor, dc_sp): - def move(state): - if state: - motor.run_forever(speed_sp=dc_sp) - else: - motor.stop() - return move - - def main(self): - - try: - while True: - self.remote.process(self.channel) - time.sleep(0.01) - - # Exit cleanly so that all motors are stopped - except (KeyboardInterrupt, Exception) as e: - log.exception(e) - self.stop() - - -# ===================== -# Wheel and Rim classes -# ===================== -class Wheel(object): - """ - A base class for various types of wheels, tires, etc - All units are in mm - """ - - def __init__(self, diameter, width): - self.diameter = float(diameter) - self.width = float(width) - self.radius = float(diameter/2) - self.circumference = diameter * math.pi - - -# A great reference when adding new wheels is http://wheels.sariel.pl/ -class EV3RubberWheel(Wheel): - - def __init__(self): - Wheel.__init__(self, 43.2, 21) diff --git a/ev3dev/led.py b/ev3dev/led.py new file mode 100644 index 0000000..d7cc39a --- /dev/null +++ b/ev3dev/led.py @@ -0,0 +1,331 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import os +import stat +import time +from collections import OrderedDict +from ev3dev import get_current_platform, Device + +# Import the LED settings, this is platform specific +platform = get_current_platform() + +if platform == 'ev3': + from ev3dev._platform.ev3 import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'evb': + from ev3dev._platform.evb import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'pistorms': + from ev3dev._platform.pistorms import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'brickpi': + from ev3dev._platform.brickpi import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'brickpi3': + from ev3dev._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS + +else: + raise Exception("Unsupported platform '%s'" % platform) + + +class Led(Device): + """ + Any device controlled by the generic LED driver. + See https://www.kernel.org/doc/Documentation/leds/leds-class.txt + for more details. + """ + + SYSTEM_CLASS_NAME = 'leds' + SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_max_brightness', + '_brightness', + '_triggers', + '_trigger', + '_delay_on', + '_delay_off', + 'desc', + ] + + def __init__(self, address=None, + name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, + desc='LED', **kwargs): + + if address is not None: + kwargs['address'] = address + super(Led, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._max_brightness = None + self._brightness = None + self._triggers = None + self._trigger = None + self._delay_on = None + self._delay_off = None + self.desc = desc + + def __str__(self): + return self.desc + + @property + def max_brightness(self): + """ + Returns the maximum allowable brightness value. + """ + self._max_brightness, value = self.get_attr_int(self._max_brightness, 'max_brightness') + return value + + @property + def brightness(self): + """ + Sets the brightness level. Possible values are from 0 to `max_brightness`. + """ + self._brightness, value = self.get_attr_int(self._brightness, 'brightness') + return value + + @brightness.setter + def brightness(self, value): + self._brightness = self.set_attr_int(self._brightness, 'brightness', value) + + @property + def triggers(self): + """ + Returns a list of available triggers. + """ + self._triggers, value = self.get_attr_set(self._triggers, 'trigger') + return value + + @property + def trigger(self): + """ + Sets the led trigger. A trigger + is a kernel based source of led events. Triggers can either be simple or + complex. A simple trigger isn't configurable and is designed to slot into + existing subsystems with minimal additional code. Examples are the `ide-disk` and + `nand-disk` triggers. + + Complex triggers whilst available to all LEDs have LED specific + parameters and work on a per LED basis. The `timer` trigger is an example. + The `timer` trigger will periodically change the LED brightness between + 0 and the current brightness setting. The `on` and `off` time can + be specified via `delay_{on,off}` attributes in milliseconds. + You can change the brightness value of a LED independently of the timer + trigger. However, if you set the brightness value to 0 it will + also disable the `timer` trigger. + """ + self._trigger, value = self.get_attr_from_set(self._trigger, 'trigger') + return value + + @trigger.setter + def trigger(self, value): + self._trigger = self.set_attr_string(self._trigger, 'trigger', value) + + # Workaround for ev3dev/ev3dev#225. + # When trigger is set to 'timer', we need to wait for 'delay_on' and + # 'delay_off' attributes to appear with correct permissions. + if value == 'timer': + for attr in ('delay_on', 'delay_off'): + path = self._path + '/' + attr + + # Make sure the file has been created: + for _ in range(5): + if os.path.exists(path): + break + time.sleep(0.2) + else: + raise Exception('"{}" attribute has not been created'.format(attr)) + + # Make sure the file has correct permissions: + for _ in range(5): + mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE]) + if mode & stat.S_IRGRP and mode & stat.S_IWGRP: + break + time.sleep(0.2) + else: + raise Exception('"{}" attribute has wrong permissions'.format(attr)) + + @property + def delay_on(self): + """ + The `timer` trigger will periodically change the LED brightness between + 0 and the current brightness setting. The `on` time can + be specified via `delay_on` attribute in milliseconds. + """ + + # Workaround for ev3dev/ev3dev#225. + # 'delay_on' and 'delay_off' attributes are created when trigger is set + # to 'timer', and destroyed when it is set to anything else. + # This means the file cache may become outdated, and we may have to + # reopen the file. + for retry in (True, False): + try: + self._delay_on, value = self.get_attr_int(self._delay_on, 'delay_on') + return value + except OSError: + if retry: + self._delay_on = None + else: + raise + + @delay_on.setter + def delay_on(self, value): + # Workaround for ev3dev/ev3dev#225. + # 'delay_on' and 'delay_off' attributes are created when trigger is set + # to 'timer', and destroyed when it is set to anything else. + # This means the file cache may become outdated, and we may have to + # reopen the file. + for retry in (True, False): + try: + self._delay_on = self.set_attr_int(self._delay_on, 'delay_on', value) + return + except OSError: + if retry: + self._delay_on = None + else: + raise + + @property + def delay_off(self): + """ + The `timer` trigger will periodically change the LED brightness between + 0 and the current brightness setting. The `off` time can + be specified via `delay_off` attribute in milliseconds. + """ + + # Workaround for ev3dev/ev3dev#225. + # 'delay_on' and 'delay_off' attributes are created when trigger is set + # to 'timer', and destroyed when it is set to anything else. + # This means the file cache may become outdated, and we may have to + # reopen the file. + for retry in (True, False): + try: + self._delay_off, value = self.get_attr_int(self._delay_off, 'delay_off') + return value + except OSError: + if retry: + self._delay_off = None + else: + raise + + @delay_off.setter + def delay_off(self, value): + # Workaround for ev3dev/ev3dev#225. + # 'delay_on' and 'delay_off' attributes are created when trigger is set + # to 'timer', and destroyed when it is set to anything else. + # This means the file cache may become outdated, and we may have to + # reopen the file. + for retry in (True, False): + try: + self._delay_off = self.set_attr_int(self._delay_off, 'delay_off', value) + return + except OSError: + if retry: + self._delay_off = None + else: + raise + + @property + def brightness_pct(self): + """ + Returns led brightness as a fraction of max_brightness + """ + return float(self.brightness) / self.max_brightness + + @brightness_pct.setter + def brightness_pct(self, value): + self.brightness = value * self.max_brightness + + +class Leds(object): + + def __init__(self): + self.leds = OrderedDict() + self.led_groups = OrderedDict() + self.led_colors = LED_COLORS + + for (key, value) in LEDS.items(): + self.leds[key] = Led(name_pattern=value, desc=key) + + for (key, value) in LED_GROUPS.items(): + self.led_groups[key] = [] + + for led_name in value: + self.led_groups[key].append(self.leds[led_name]) + + def set_color(self, group, color, pct=1): + """ + Sets brigthness of leds in the given group to the values specified in + color tuple. When percentage is specified, brightness of each led is + reduced proportionally. + + Example:: + my_leds = Leds() + my_leds.set_color('LEFT', 'AMBER') + """ + # If this is a platform without LEDs there is nothing to do + if not self.leds: + return + + assert group in self.led_groups, "%s is an invalid LED group, valid choices are %s" % (group, ','.join(self.led_groups.keys())) + assert color in self.led_colors, "%s is an invalid LED color, valid choices are %s" % (color, ','.join(self.led_colors.keys())) + + for led, value in zip(self.led_groups[group], self.led_colors[color]): + led.brightness_pct = value * pct + + def set(self, group, **kwargs): + """ + Set attributes for each led in group. + + Example:: + my_leds = Leds() + my_leds.set_color('LEFT', brightness_pct=0.5, trigger='timer') + """ + + # If this is a platform without LEDs there is nothing to do + if not self.leds: + return + + assert group in self.led_groups, "%s is an invalid LED group, valid choices are %s" % (group, ','.join(self.led_groups.keys())) + + for led in self.led_groups[group]: + for k in kwargs: + setattr(led, k, kwargs[k]) + + def all_off(self): + """ + Turn all leds off + """ + + # If this is a platform without LEDs there is nothing to do + if not self.leds: + return + + for led in self.leds.values(): + led.brightness = 0 diff --git a/ev3dev/motor.py b/ev3dev/motor.py new file mode 100644 index 0000000..dc09161 --- /dev/null +++ b/ev3dev/motor.py @@ -0,0 +1,1686 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import select +import time +from os.path import abspath +from ev3dev import get_current_platform, Device + +# The number of milliseconds we wait for the state of a motor to +# update to 'running' in the "on_for_XYZ" methods of the Motor class +WAIT_RUNNING_TIMEOUT = 100 + + +# OUTPUT ports have platform specific values that we must import +platform = get_current_platform() + +if platform == 'ev3': + from ev3dev._platform.ev3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + +elif platform == 'evb': + from ev3dev._platform.evb import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + +elif platform == 'pistorms': + from ev3dev._platform.pistorms import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + +elif platform == 'brickpi': + from ev3dev._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + +elif platform == 'brickpi3': + from ev3dev._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + +else: + raise Exception("Unsupported platform '%s'" % platform) + + +class Motor(Device): + + """ + The motor class provides a uniform interface for using motors with + positional and directional feedback such as the EV3 and NXT motors. + This feedback allows for precise control of the motors. This is the + most common type of motor, so we just call it `motor`. + + The way to configure a motor is to set the '_sp' attributes when + calling a command or before. Only in 'run_direct' mode attribute + changes are processed immediately, in the other modes they only + take place when a new command is issued. + """ + + SYSTEM_CLASS_NAME = 'tacho-motor' + SYSTEM_DEVICE_NAME_CONVENTION = '*' + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + if address is not None: + kwargs['address'] = address + super(Motor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._address = None + self._command = None + self._commands = None + self._count_per_rot = None + self._count_per_m = None + self._driver_name = None + self._duty_cycle = None + self._duty_cycle_sp = None + self._full_travel_count = None + self._polarity = None + self._position = None + self._position_p = None + self._position_i = None + self._position_d = None + self._position_sp = None + self._max_speed = None + self._speed = None + self._speed_sp = None + self._ramp_up_sp = None + self._ramp_down_sp = None + self._speed_p = None + self._speed_i = None + self._speed_d = None + self._state = None + self._stop_action = None + self._stop_actions = None + self._time_sp = None + self._poll = None + + __slots__ = [ + '_address', + '_command', + '_commands', + '_count_per_rot', + '_count_per_m', + '_driver_name', + '_duty_cycle', + '_duty_cycle_sp', + '_full_travel_count', + '_polarity', + '_position', + '_position_p', + '_position_i', + '_position_d', + '_position_sp', + '_max_speed', + '_speed', + '_speed_sp', + '_ramp_up_sp', + '_ramp_down_sp', + '_speed_p', + '_speed_i', + '_speed_d', + '_state', + '_stop_action', + '_stop_actions', + '_time_sp', + '_poll', + ] + + @property + def address(self): + """ + Returns the name of the port that this motor is connected to. + """ + self._address, value = self.get_attr_string(self._address, 'address') + return value + + @property + def command(self): + """ + Sends a command to the motor controller. See `commands` for a list of + possible values. + """ + raise Exception("command is a write-only property!") + + @command.setter + def command(self, value): + self._command = self.set_attr_string(self._command, 'command', value) + + @property + def commands(self): + """ + Returns a list of commands that are supported by the motor + controller. Possible values are `run-forever`, `run-to-abs-pos`, `run-to-rel-pos`, + `run-timed`, `run-direct`, `stop` and `reset`. Not all commands may be supported. + + - `run-forever` will cause the motor to run until another command is sent. + - `run-to-abs-pos` will run to an absolute position specified by `position_sp` + and then stop using the action specified in `stop_action`. + - `run-to-rel-pos` will run to a position relative to the current `position` value. + The new position will be current `position` + `position_sp`. When the new + position is reached, the motor will stop using the action specified by `stop_action`. + - `run-timed` will run the motor for the amount of time specified in `time_sp` + and then stop the motor using the action specified by `stop_action`. + - `run-direct` will run the motor at the duty cycle specified by `duty_cycle_sp`. + Unlike other run commands, changing `duty_cycle_sp` while running *will* + take effect immediately. + - `stop` will stop any of the run commands before they are complete using the + action specified by `stop_action`. + - `reset` will reset all of the motor parameter attributes to their default value. + This will also have the effect of stopping the motor. + """ + self._commands, value = self.get_attr_set(self._commands, 'commands') + return value + + @property + def count_per_rot(self): + """ + Returns the number of tacho counts in one rotation of the motor. Tacho counts + are used by the position and speed attributes, so you can use this value + to convert rotations or degrees to tacho counts. (rotation motors only) + """ + self._count_per_rot, value = self.get_attr_int(self._count_per_rot, 'count_per_rot') + return value + + @property + def count_per_m(self): + """ + Returns the number of tacho counts in one meter of travel of the motor. Tacho + counts are used by the position and speed attributes, so you can use this + value to convert from distance to tacho counts. (linear motors only) + """ + self._count_per_m, value = self.get_attr_int(self._count_per_m, 'count_per_m') + return value + + @property + def driver_name(self): + """ + Returns the name of the driver that provides this tacho motor device. + """ + self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + return value + + @property + def duty_cycle(self): + """ + Returns the current duty cycle of the motor. Units are percent. Values + are -100 to 100. + """ + self._duty_cycle, value = self.get_attr_int(self._duty_cycle, 'duty_cycle') + return value + + @property + def duty_cycle_sp(self): + """ + Writing sets the duty cycle setpoint. Reading returns the current value. + Units are in percent. Valid values are -100 to 100. A negative value causes + the motor to rotate in reverse. + """ + self._duty_cycle_sp, value = self.get_attr_int(self._duty_cycle_sp, 'duty_cycle_sp') + return value + + @duty_cycle_sp.setter + def duty_cycle_sp(self, value): + self._duty_cycle_sp = self.set_attr_int(self._duty_cycle_sp, 'duty_cycle_sp', value) + + @property + def full_travel_count(self): + """ + Returns the number of tacho counts in the full travel of the motor. When + combined with the `count_per_m` atribute, you can use this value to + calculate the maximum travel distance of the motor. (linear motors only) + """ + self._full_travel_count, value = self.get_attr_int(self._full_travel_count, 'full_travel_count') + return value + + @property + def polarity(self): + """ + Sets the polarity of the motor. With `normal` polarity, a positive duty + cycle will cause the motor to rotate clockwise. With `inversed` polarity, + a positive duty cycle will cause the motor to rotate counter-clockwise. + Valid values are `normal` and `inversed`. + """ + self._polarity, value = self.get_attr_string(self._polarity, 'polarity') + return value + + @polarity.setter + def polarity(self, value): + self._polarity = self.set_attr_string(self._polarity, 'polarity', value) + + @property + def position(self): + """ + Returns the current position of the motor in pulses of the rotary + encoder. When the motor rotates clockwise, the position will increase. + Likewise, rotating counter-clockwise causes the position to decrease. + Writing will set the position to that value. + """ + self._position, value = self.get_attr_int(self._position, 'position') + return value + + @position.setter + def position(self, value): + self._position = self.set_attr_int(self._position, 'position', value) + + @property + def position_p(self): + """ + The proportional constant for the position PID. + """ + self._position_p, value = self.get_attr_int(self._position_p, 'hold_pid/Kp') + return value + + @position_p.setter + def position_p(self, value): + self._position_p = self.set_attr_int(self._position_p, 'hold_pid/Kp', value) + + @property + def position_i(self): + """ + The integral constant for the position PID. + """ + self._position_i, value = self.get_attr_int(self._position_i, 'hold_pid/Ki') + return value + + @position_i.setter + def position_i(self, value): + self._position_i = self.set_attr_int(self._position_i, 'hold_pid/Ki', value) + + @property + def position_d(self): + """ + The derivative constant for the position PID. + """ + self._position_d, value = self.get_attr_int(self._position_d, 'hold_pid/Kd') + return value + + @position_d.setter + def position_d(self, value): + self._position_d = self.set_attr_int(self._position_d, 'hold_pid/Kd', value) + + @property + def position_sp(self): + """ + Writing specifies the target position for the `run-to-abs-pos` and `run-to-rel-pos` + commands. Reading returns the current value. Units are in tacho counts. You + can use the value returned by `counts_per_rot` to convert tacho counts to/from + rotations or degrees. + """ + self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') + return value + + @position_sp.setter + def position_sp(self, value): + self._position_sp = self.set_attr_int(self._position_sp, 'position_sp', value) + + @property + def max_speed(self): + """ + Returns the maximum value that is accepted by the `speed_sp` attribute. This + may be slightly different than the maximum speed that a particular motor can + reach - it's the maximum theoretical speed. + """ + self._max_speed, value = self.get_attr_int(self._max_speed, 'max_speed') + return value + + @property + def speed(self): + """ + Returns the current motor speed in tacho counts per second. Note, this is + not necessarily degrees (although it is for LEGO motors). Use the `count_per_rot` + attribute to convert this value to RPM or deg/sec. + """ + self._speed, value = self.get_attr_int(self._speed, 'speed') + return value + + @property + def speed_sp(self): + """ + Writing sets the target speed in tacho counts per second used for all `run-*` + commands except `run-direct`. Reading returns the current value. A negative + value causes the motor to rotate in reverse with the exception of `run-to-*-pos` + commands where the sign is ignored. Use the `count_per_rot` attribute to convert + RPM or deg/sec to tacho counts per second. Use the `count_per_m` attribute to + convert m/s to tacho counts per second. + """ + self._speed_sp, value = self.get_attr_int(self._speed_sp, 'speed_sp') + return value + + @speed_sp.setter + def speed_sp(self, value): + self._speed_sp = self.set_attr_int(self._speed_sp, 'speed_sp', value) + + @property + def ramp_up_sp(self): + """ + Writing sets the ramp up setpoint. Reading returns the current value. Units + are in milliseconds and must be positive. When set to a non-zero value, the + motor speed will increase from 0 to 100% of `max_speed` over the span of this + setpoint. The actual ramp time is the ratio of the difference between the + `speed_sp` and the current `speed` and max_speed multiplied by `ramp_up_sp`. + """ + self._ramp_up_sp, value = self.get_attr_int(self._ramp_up_sp, 'ramp_up_sp') + return value + + @ramp_up_sp.setter + def ramp_up_sp(self, value): + self._ramp_up_sp = self.set_attr_int(self._ramp_up_sp, 'ramp_up_sp', value) + + @property + def ramp_down_sp(self): + """ + Writing sets the ramp down setpoint. Reading returns the current value. Units + are in milliseconds and must be positive. When set to a non-zero value, the + motor speed will decrease from 0 to 100% of `max_speed` over the span of this + setpoint. The actual ramp time is the ratio of the difference between the + `speed_sp` and the current `speed` and max_speed multiplied by `ramp_down_sp`. + """ + self._ramp_down_sp, value = self.get_attr_int(self._ramp_down_sp, 'ramp_down_sp') + return value + + @ramp_down_sp.setter + def ramp_down_sp(self, value): + self._ramp_down_sp = self.set_attr_int(self._ramp_down_sp, 'ramp_down_sp', value) + + @property + def speed_p(self): + """ + The proportional constant for the speed regulation PID. + """ + self._speed_p, value = self.get_attr_int(self._speed_p, 'speed_pid/Kp') + return value + + @speed_p.setter + def speed_p(self, value): + self._speed_p = self.set_attr_int(self._speed_p, 'speed_pid/Kp', value) + + @property + def speed_i(self): + """ + The integral constant for the speed regulation PID. + """ + self._speed_i, value = self.get_attr_int(self._speed_i, 'speed_pid/Ki') + return value + + @speed_i.setter + def speed_i(self, value): + self._speed_i = self.set_attr_int(self._speed_i, 'speed_pid/Ki', value) + + @property + def speed_d(self): + """ + The derivative constant for the speed regulation PID. + """ + self._speed_d, value = self.get_attr_int(self._speed_d, 'speed_pid/Kd') + return value + + @speed_d.setter + def speed_d(self, value): + self._speed_d = self.set_attr_int(self._speed_d, 'speed_pid/Kd', value) + + @property + def state(self): + """ + Reading returns a list of state flags. Possible flags are + `running`, `ramping`, `holding`, `overloaded` and `stalled`. + """ + self._state, value = self.get_attr_set(self._state, 'state') + return value + + @property + def stop_action(self): + """ + Reading returns the current stop action. Writing sets the stop action. + The value determines the motors behavior when `command` is set to `stop`. + Also, it determines the motors behavior when a run command completes. See + `stop_actions` for a list of possible values. + """ + self._stop_action, value = self.get_attr_string(self._stop_action, 'stop_action') + return value + + @stop_action.setter + def stop_action(self, value): + self._stop_action = self.set_attr_string(self._stop_action, 'stop_action', value) + + @property + def stop_actions(self): + """ + Returns a list of stop actions supported by the motor controller. + Possible values are `coast`, `brake` and `hold`. `coast` means that power will + be removed from the motor and it will freely coast to a stop. `brake` means + that power will be removed from the motor and a passive electrical load will + be placed on the motor. This is usually done by shorting the motor terminals + together. This load will absorb the energy from the rotation of the motors and + cause the motor to stop more quickly than coasting. `hold` does not remove + power from the motor. Instead it actively tries to hold the motor at the current + position. If an external force tries to turn the motor, the motor will 'push + back' to maintain its position. + """ + self._stop_actions, value = self.get_attr_set(self._stop_actions, 'stop_actions') + return value + + @property + def time_sp(self): + """ + Writing specifies the amount of time the motor will run when using the + `run-timed` command. Reading returns the current value. Units are in + milliseconds. + """ + self._time_sp, value = self.get_attr_int(self._time_sp, 'time_sp') + return value + + @time_sp.setter + def time_sp(self, value): + self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) + + #: Run the motor until another command is sent. + COMMAND_RUN_FOREVER = 'run-forever' + + #: Run to an absolute position specified by `position_sp` and then + #: stop using the action specified in `stop_action`. + COMMAND_RUN_TO_ABS_POS = 'run-to-abs-pos' + + #: Run to a position relative to the current `position` value. + #: The new position will be current `position` + `position_sp`. + #: When the new position is reached, the motor will stop using + #: the action specified by `stop_action`. + COMMAND_RUN_TO_REL_POS = 'run-to-rel-pos' + + #: Run the motor for the amount of time specified in `time_sp` + #: and then stop the motor using the action specified by `stop_action`. + COMMAND_RUN_TIMED = 'run-timed' + + #: Run the motor at the duty cycle specified by `duty_cycle_sp`. + #: Unlike other run commands, changing `duty_cycle_sp` while running *will* + #: take effect immediately. + COMMAND_RUN_DIRECT = 'run-direct' + + #: Stop any of the run commands before they are complete using the + #: action specified by `stop_action`. + COMMAND_STOP = 'stop' + + #: Reset all of the motor parameter attributes to their default value. + #: This will also have the effect of stopping the motor. + COMMAND_RESET = 'reset' + + #: Sets the normal polarity of the rotary encoder. + ENCODER_POLARITY_NORMAL = 'normal' + + #: Sets the inversed polarity of the rotary encoder. + ENCODER_POLARITY_INVERSED = 'inversed' + + #: With `normal` polarity, a positive duty cycle will + #: cause the motor to rotate clockwise. + POLARITY_NORMAL = 'normal' + + #: With `inversed` polarity, a positive duty cycle will + #: cause the motor to rotate counter-clockwise. + POLARITY_INVERSED = 'inversed' + + #: Power is being sent to the motor. + STATE_RUNNING = 'running' + + #: The motor is ramping up or down and has not yet reached a constant output level. + STATE_RAMPING = 'ramping' + + #: The motor is not turning, but rather attempting to hold a fixed position. + STATE_HOLDING = 'holding' + + #: The motor is turning, but cannot reach its `speed_sp`. + STATE_OVERLOADED = 'overloaded' + + #: The motor is not turning when it should be. + STATE_STALLED = 'stalled' + + #: Power will be removed from the motor and it will freely coast to a stop. + STOP_ACTION_COAST = 'coast' + + #: Power will be removed from the motor and a passive electrical load will + #: be placed on the motor. This is usually done by shorting the motor terminals + #: together. This load will absorb the energy from the rotation of the motors and + #: cause the motor to stop more quickly than coasting. + STOP_ACTION_BRAKE = 'brake' + + #: Does not remove power from the motor. Instead it actively try to hold the motor + #: at the current position. If an external force tries to turn the motor, the motor + #: will `push back` to maintain its position. + STOP_ACTION_HOLD = 'hold' + + def run_forever(self, **kwargs): + """Run the motor until another command is sent. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_FOREVER + + def run_to_abs_pos(self, **kwargs): + """Run to an absolute position specified by `position_sp` and then + stop using the action specified in `stop_action`. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_TO_ABS_POS + + def run_to_rel_pos(self, **kwargs): + """Run to a position relative to the current `position` value. + The new position will be current `position` + `position_sp`. + When the new position is reached, the motor will stop using + the action specified by `stop_action`. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_TO_REL_POS + + def run_timed(self, **kwargs): + """Run the motor for the amount of time specified in `time_sp` + and then stop the motor using the action specified by `stop_action`. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_TIMED + + def run_direct(self, **kwargs): + """Run the motor at the duty cycle specified by `duty_cycle_sp`. + Unlike other run commands, changing `duty_cycle_sp` while running *will* + take effect immediately. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_DIRECT + + def stop(self, **kwargs): + """Stop any of the run commands before they are complete using the + action specified by `stop_action`. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_STOP + + def reset(self, **kwargs): + """Reset all of the motor parameter attributes to their default value. + This will also have the effect of stopping the motor. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RESET + + @property + def is_running(self): + """Power is being sent to the motor. + """ + return self.STATE_RUNNING in self.state + + @property + def is_ramping(self): + """The motor is ramping up or down and has not yet reached a constant output level. + """ + return self.STATE_RAMPING in self.state + + @property + def is_holding(self): + """The motor is not turning, but rather attempting to hold a fixed position. + """ + return self.STATE_HOLDING in self.state + + @property + def is_overloaded(self): + """The motor is turning, but cannot reach its `speed_sp`. + """ + return self.STATE_OVERLOADED in self.state + + @property + def is_stalled(self): + """The motor is not turning when it should be. + """ + return self.STATE_STALLED in self.state + + def wait(self, cond, timeout=None): + """ + Blocks until ``cond(self.state)`` is ``True``. The condition is + checked when there is an I/O event related to the ``state`` attribute. + Exits early when ``timeout`` (in milliseconds) is reached. + + Returns ``True`` if the condition is met, and ``False`` if the timeout + is reached. + """ + + tic = time.time() + + if self._poll is None: + if self._state is None: + self._state = self._attribute_file_open('state') + self._poll = select.poll() + self._poll.register(self._state, select.POLLPRI) + + while True: + self._poll.poll(None if timeout is None else timeout) + + if timeout is not None and time.time() >= tic + timeout / 1000: + return False + + if cond(self.state): + return True + + def wait_until_not_moving(self, timeout=None): + """ + Blocks until ``running`` is not in ``self.state`` or ``stalled`` is in + ``self.state``. The condition is checked when there is an I/O event + related to the ``state`` attribute. Exits early when ``timeout`` + (in milliseconds) is reached. + + Returns ``True`` if the condition is met, and ``False`` if the timeout + is reached. + + Example:: + + m.wait_until_not_moving() + """ + return self.wait(lambda state: self.STATE_RUNNING not in state or self.STATE_STALLED in state, timeout) + + def wait_until(self, s, timeout=None): + """ + Blocks until ``s`` is in ``self.state``. The condition is checked when + there is an I/O event related to the ``state`` attribute. Exits early + when ``timeout`` (in milliseconds) is reached. + + Returns ``True`` if the condition is met, and ``False`` if the timeout + is reached. + + Example:: + + m.wait_until('stalled') + """ + return self.wait(lambda state: s in state, timeout) + + def wait_while(self, s, timeout=None): + """ + Blocks until ``s`` is not in ``self.state``. The condition is checked + when there is an I/O event related to the ``state`` attribute. Exits + early when ``timeout`` (in milliseconds) is reached. + + Returns ``True`` if the condition is met, and ``False`` if the timeout + is reached. + + Example:: + + m.wait_while('running') + """ + return self.wait(lambda state: s not in state, timeout) + + def _set_position_rotations(self, speed_pct, rotations): + if speed_pct > 0: + self.position_sp = self.position + int(rotations * self.count_per_rot) + else: + self.position_sp = self.position - int(rotations * self.count_per_rot) + + def _set_position_degrees(self, speed_pct, degrees): + if speed_pct > 0: + self.position_sp = self.position + int((degrees * self.count_per_rot)/360) + else: + self.position_sp = self.position - int((degrees * self.count_per_rot)/360) + + def _set_brake(self, brake): + if brake: + self.stop_action = self.STOP_ACTION_HOLD + else: + self.stop_action = self.STOP_ACTION_COAST + + def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): + """ + Rotate the motor at 'speed' for 'rotations' + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self._set_position_rotations(speed_pct, rotations) + self._set_brake(brake) + self.run_to_abs_pos() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + + def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): + """ + Rotate the motor at 'speed' for 'degrees' + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self._set_position_degrees(speed_pct, degrees) + self._set_brake(brake) + self.run_to_abs_pos() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + + def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): + """ + Rotate the motor at 'speed' for 'seconds' + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.time_sp = int(seconds * 1000) + self._set_brake(brake) + self.run_timed() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + + def on(self, speed_pct): + """ + Rotate the motor at 'speed' for forever + """ + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.run_forever() + + def off(self, brake=True): + self._set_brake(brake) + self.stop() + + @property + def rotations(self): + return float(self.position / self.count_per_rot) + + @property + def degrees(self): + return self.rotations * 360 + + +def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): + """ + This is a generator function that enumerates all tacho motors that match + the provided arguments. + + Parameters: + name_pattern: pattern that device name should match. + For example, 'motor*'. Default value: '*'. + keyword arguments: used for matching the corresponding device + attributes. For example, driver_name='lego-ev3-l-motor', or + address=['outB', 'outC']. When argument value + is a list, then a match against any entry of the list is + enough. + """ + class_path = abspath(Device.DEVICE_ROOT_PATH + '/' + Motor.SYSTEM_CLASS_NAME) + + return (Motor(name_pattern=name, name_exact=True) + for name in list_device_names(class_path, name_pattern, **kwargs)) + +class LargeMotor(Motor): + + """ + EV3/NXT large servo motor + """ + + SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + super(LargeMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-l-motor', 'lego-nxt-motor'], **kwargs) + + +class MediumMotor(Motor): + + """ + EV3 medium servo motor + """ + + SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + super(MediumMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-m-motor'], **kwargs) + + +class ActuonixL1250Motor(Motor): + + """ + Actuonix L12 50 linear servo motor + """ + + SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = 'linear*' + __slots__ = [] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + super(ActuonixL1250Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-50'], **kwargs) + + +class ActuonixL12100Motor(Motor): + + """ + Actuonix L12 100 linear servo motor + """ + + SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = 'linear*' + __slots__ = [] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + super(ActuonixL12100Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-100'], **kwargs) + + +class DcMotor(Device): + + """ + The DC motor class provides a uniform interface for using regular DC motors + with no fancy controls or feedback. This includes LEGO MINDSTORMS RCX motors + and LEGO Power Functions motors. + """ + + SYSTEM_CLASS_NAME = 'dc-motor' + SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' + __slots__ = [ + '_address', + '_command', + '_commands', + '_driver_name', + '_duty_cycle', + '_duty_cycle_sp', + '_polarity', + '_ramp_down_sp', + '_ramp_up_sp', + '_state', + '_stop_action', + '_stop_actions', + '_time_sp', + ] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + if address is not None: + kwargs['address'] = address + super(DcMotor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._address = None + self._command = None + self._commands = None + self._driver_name = None + self._duty_cycle = None + self._duty_cycle_sp = None + self._polarity = None + self._ramp_down_sp = None + self._ramp_up_sp = None + self._state = None + self._stop_action = None + self._stop_actions = None + self._time_sp = None + + @property + def address(self): + """ + Returns the name of the port that this motor is connected to. + """ + self._address, value = self.get_attr_string(self._address, 'address') + return value + + @property + def command(self): + """ + Sets the command for the motor. Possible values are `run-forever`, `run-timed` and + `stop`. Not all commands may be supported, so be sure to check the contents + of the `commands` attribute. + """ + raise Exception("command is a write-only property!") + + @command.setter + def command(self, value): + self._command = self.set_attr_string(self._command, 'command', value) + + @property + def commands(self): + """ + Returns a list of commands supported by the motor + controller. + """ + self._commands, value = self.get_attr_set(self._commands, 'commands') + return value + + @property + def driver_name(self): + """ + Returns the name of the motor driver that loaded this device. See the list + of [supported devices] for a list of drivers. + """ + self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + return value + + @property + def duty_cycle(self): + """ + Shows the current duty cycle of the PWM signal sent to the motor. Values + are -100 to 100 (-100% to 100%). + """ + self._duty_cycle, value = self.get_attr_int(self._duty_cycle, 'duty_cycle') + return value + + @property + def duty_cycle_sp(self): + """ + Writing sets the duty cycle setpoint of the PWM signal sent to the motor. + Valid values are -100 to 100 (-100% to 100%). Reading returns the current + setpoint. + """ + self._duty_cycle_sp, value = self.get_attr_int(self._duty_cycle_sp, 'duty_cycle_sp') + return value + + @duty_cycle_sp.setter + def duty_cycle_sp(self, value): + self._duty_cycle_sp = self.set_attr_int(self._duty_cycle_sp, 'duty_cycle_sp', value) + + @property + def polarity(self): + """ + Sets the polarity of the motor. Valid values are `normal` and `inversed`. + """ + self._polarity, value = self.get_attr_string(self._polarity, 'polarity') + return value + + @polarity.setter + def polarity(self, value): + self._polarity = self.set_attr_string(self._polarity, 'polarity', value) + + @property + def ramp_down_sp(self): + """ + Sets the time in milliseconds that it take the motor to ramp down from 100% + to 0%. Valid values are 0 to 10000 (10 seconds). Default is 0. + """ + self._ramp_down_sp, value = self.get_attr_int(self._ramp_down_sp, 'ramp_down_sp') + return value + + @ramp_down_sp.setter + def ramp_down_sp(self, value): + self._ramp_down_sp = self.set_attr_int(self._ramp_down_sp, 'ramp_down_sp', value) + + @property + def ramp_up_sp(self): + """ + Sets the time in milliseconds that it take the motor to up ramp from 0% to + 100%. Valid values are 0 to 10000 (10 seconds). Default is 0. + """ + self._ramp_up_sp, value = self.get_attr_int(self._ramp_up_sp, 'ramp_up_sp') + return value + + @ramp_up_sp.setter + def ramp_up_sp(self, value): + self._ramp_up_sp = self.set_attr_int(self._ramp_up_sp, 'ramp_up_sp', value) + + @property + def state(self): + """ + Gets a list of flags indicating the motor status. Possible + flags are `running` and `ramping`. `running` indicates that the motor is + powered. `ramping` indicates that the motor has not yet reached the + `duty_cycle_sp`. + """ + self._state, value = self.get_attr_set(self._state, 'state') + return value + + @property + def stop_action(self): + """ + Sets the stop action that will be used when the motor stops. Read + `stop_actions` to get the list of valid values. + """ + raise Exception("stop_action is a write-only property!") + + @stop_action.setter + def stop_action(self, value): + self._stop_action = self.set_attr_string(self._stop_action, 'stop_action', value) + + @property + def stop_actions(self): + """ + Gets a list of stop actions. Valid values are `coast` + and `brake`. + """ + self._stop_actions, value = self.get_attr_set(self._stop_actions, 'stop_actions') + return value + + @property + def time_sp(self): + """ + Writing specifies the amount of time the motor will run when using the + `run-timed` command. Reading returns the current value. Units are in + milliseconds. + """ + self._time_sp, value = self.get_attr_int(self._time_sp, 'time_sp') + return value + + @time_sp.setter + def time_sp(self, value): + self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) + + #: Run the motor until another command is sent. + COMMAND_RUN_FOREVER = 'run-forever' + + #: Run the motor for the amount of time specified in `time_sp` + #: and then stop the motor using the action specified by `stop_action`. + COMMAND_RUN_TIMED = 'run-timed' + + #: Run the motor at the duty cycle specified by `duty_cycle_sp`. + #: Unlike other run commands, changing `duty_cycle_sp` while running *will* + #: take effect immediately. + COMMAND_RUN_DIRECT = 'run-direct' + + #: Stop any of the run commands before they are complete using the + #: action specified by `stop_action`. + COMMAND_STOP = 'stop' + + #: With `normal` polarity, a positive duty cycle will + #: cause the motor to rotate clockwise. + POLARITY_NORMAL = 'normal' + + #: With `inversed` polarity, a positive duty cycle will + #: cause the motor to rotate counter-clockwise. + POLARITY_INVERSED = 'inversed' + + #: Power will be removed from the motor and it will freely coast to a stop. + STOP_ACTION_COAST = 'coast' + + #: Power will be removed from the motor and a passive electrical load will + #: be placed on the motor. This is usually done by shorting the motor terminals + #: together. This load will absorb the energy from the rotation of the motors and + #: cause the motor to stop more quickly than coasting. + STOP_ACTION_BRAKE = 'brake' + + def run_forever(self, **kwargs): + """Run the motor until another command is sent. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_FOREVER + + def run_timed(self, **kwargs): + """Run the motor for the amount of time specified in `time_sp` + and then stop the motor using the action specified by `stop_action`. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_TIMED + + def run_direct(self, **kwargs): + """Run the motor at the duty cycle specified by `duty_cycle_sp`. + Unlike other run commands, changing `duty_cycle_sp` while running *will* + take effect immediately. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN_DIRECT + + def stop(self, **kwargs): + """Stop any of the run commands before they are complete using the + action specified by `stop_action`. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_STOP + + +class ServoMotor(Device): + + """ + The servo motor class provides a uniform interface for using hobby type + servo motors. + """ + + SYSTEM_CLASS_NAME = 'servo-motor' + SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' + __slots__ = [ + '_address', + '_command', + '_driver_name', + '_max_pulse_sp', + '_mid_pulse_sp', + '_min_pulse_sp', + '_polarity', + '_position_sp', + '_rate_sp', + '_state', + ] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + if address is not None: + kwargs['address'] = address + super(ServoMotor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._address = None + self._command = None + self._driver_name = None + self._max_pulse_sp = None + self._mid_pulse_sp = None + self._min_pulse_sp = None + self._polarity = None + self._position_sp = None + self._rate_sp = None + self._state = None + + @property + def address(self): + """ + Returns the name of the port that this motor is connected to. + """ + self._address, value = self.get_attr_string(self._address, 'address') + return value + + @property + def command(self): + """ + Sets the command for the servo. Valid values are `run` and `float`. Setting + to `run` will cause the servo to be driven to the position_sp set in the + `position_sp` attribute. Setting to `float` will remove power from the motor. + """ + raise Exception("command is a write-only property!") + + @command.setter + def command(self, value): + self._command = self.set_attr_string(self._command, 'command', value) + + @property + def driver_name(self): + """ + Returns the name of the motor driver that loaded this device. See the list + of [supported devices] for a list of drivers. + """ + self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + return value + + @property + def max_pulse_sp(self): + """ + Used to set the pulse size in milliseconds for the signal that tells the + servo to drive to the maximum (clockwise) position_sp. Default value is 2400. + Valid values are 2300 to 2700. You must write to the position_sp attribute for + changes to this attribute to take effect. + """ + self._max_pulse_sp, value = self.get_attr_int(self._max_pulse_sp, 'max_pulse_sp') + return value + + @max_pulse_sp.setter + def max_pulse_sp(self, value): + self._max_pulse_sp = self.set_attr_int(self._max_pulse_sp, 'max_pulse_sp', value) + + @property + def mid_pulse_sp(self): + """ + Used to set the pulse size in milliseconds for the signal that tells the + servo to drive to the mid position_sp. Default value is 1500. Valid + values are 1300 to 1700. For example, on a 180 degree servo, this would be + 90 degrees. On continuous rotation servo, this is the 'neutral' position_sp + where the motor does not turn. You must write to the position_sp attribute for + changes to this attribute to take effect. + """ + self._mid_pulse_sp, value = self.get_attr_int(self._mid_pulse_sp, 'mid_pulse_sp') + return value + + @mid_pulse_sp.setter + def mid_pulse_sp(self, value): + self._mid_pulse_sp = self.set_attr_int(self._mid_pulse_sp, 'mid_pulse_sp', value) + + @property + def min_pulse_sp(self): + """ + Used to set the pulse size in milliseconds for the signal that tells the + servo to drive to the miniumum (counter-clockwise) position_sp. Default value + is 600. Valid values are 300 to 700. You must write to the position_sp + attribute for changes to this attribute to take effect. + """ + self._min_pulse_sp, value = self.get_attr_int(self._min_pulse_sp, 'min_pulse_sp') + return value + + @min_pulse_sp.setter + def min_pulse_sp(self, value): + self._min_pulse_sp = self.set_attr_int(self._min_pulse_sp, 'min_pulse_sp', value) + + @property + def polarity(self): + """ + Sets the polarity of the servo. Valid values are `normal` and `inversed`. + Setting the value to `inversed` will cause the position_sp value to be + inversed. i.e `-100` will correspond to `max_pulse_sp`, and `100` will + correspond to `min_pulse_sp`. + """ + self._polarity, value = self.get_attr_string(self._polarity, 'polarity') + return value + + @polarity.setter + def polarity(self, value): + self._polarity = self.set_attr_string(self._polarity, 'polarity', value) + + @property + def position_sp(self): + """ + Reading returns the current position_sp of the servo. Writing instructs the + servo to move to the specified position_sp. Units are percent. Valid values + are -100 to 100 (-100% to 100%) where `-100` corresponds to `min_pulse_sp`, + `0` corresponds to `mid_pulse_sp` and `100` corresponds to `max_pulse_sp`. + """ + self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') + return value + + @position_sp.setter + def position_sp(self, value): + self._position_sp = self.set_attr_int(self._position_sp, 'position_sp', value) + + @property + def rate_sp(self): + """ + Sets the rate_sp at which the servo travels from 0 to 100.0% (half of the full + range of the servo). Units are in milliseconds. Example: Setting the rate_sp + to 1000 means that it will take a 180 degree servo 2 second to move from 0 + to 180 degrees. Note: Some servo controllers may not support this in which + case reading and writing will fail with `-EOPNOTSUPP`. In continuous rotation + servos, this value will affect the rate_sp at which the speed ramps up or down. + """ + self._rate_sp, value = self.get_attr_int(self._rate_sp, 'rate_sp') + return value + + @rate_sp.setter + def rate_sp(self, value): + self._rate_sp = self.set_attr_int(self._rate_sp, 'rate_sp', value) + + @property + def state(self): + """ + Returns a list of flags indicating the state of the servo. + Possible values are: + * `running`: Indicates that the motor is powered. + """ + self._state, value = self.get_attr_set(self._state, 'state') + return value + + #: Drive servo to the position set in the `position_sp` attribute. + COMMAND_RUN = 'run' + + #: Remove power from the motor. + COMMAND_FLOAT = 'float' + + #: With `normal` polarity, a positive duty cycle will + #: cause the motor to rotate clockwise. + POLARITY_NORMAL = 'normal' + + #: With `inversed` polarity, a positive duty cycle will + #: cause the motor to rotate counter-clockwise. + POLARITY_INVERSED = 'inversed' + + def run(self, **kwargs): + """Drive servo to the position set in the `position_sp` attribute. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_RUN + + def float(self, **kwargs): + """Remove power from the motor. + """ + for key in kwargs: + setattr(self, key, kwargs[key]) + self.command = self.COMMAND_FLOAT + + +class MotorSet(object): + + def __init__(self, motor_specs, desc=None): + """ + motor_specs is a dictionary such as + { + OUTPUT_A : LargeMotor, + OUTPUT_C : LargeMotor, + } + """ + self.motors = {} + for motor_port in sorted(motor_specs.keys()): + motor_class = motor_specs[motor_port] + self.motors[motor_port] = motor_class(motor_port) + + self.desc = desc + self.verify_connected() + + def __str__(self): + + if self.desc: + return self.desc + else: + return self.__class__.__name__ + + def verify_connected(self): + for motor in self.motors.values(): + if not motor.connected: + print("%s: %s is not connected" % (self, motor)) + sys.exit(1) + + def set_args(self, **kwargs): + motors = kwargs.get('motors', self.motors.values()) + + for motor in motors: + for key in kwargs: + if key != 'motors': + try: + setattr(motor, key, kwargs[key]) + except AttributeError as e: + #log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) + raise e + + def set_polarity(self, polarity, motors=None): + valid_choices = (LargeMotor.POLARITY_NORMAL, LargeMotor.POLARITY_INVERSED) + + assert polarity in valid_choices,\ + "%s is an invalid polarity choice, must be %s" % (polarity, ', '.join(valid_choices)) + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.polarity = polarity + + def _run_command(self, **kwargs): + motors = kwargs.get('motors', self.motors.values()) + + for motor in motors: + for key in kwargs: + if key not in ('motors', 'commands'): + #log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) + setattr(motor, key, kwargs[key]) + + for motor in motors: + motor.command = kwargs['command'] + #log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) + + def run_forever(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_FOREVER + self._run_command(**kwargs) + + def run_to_abs_pos(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_TO_ABS_POS + self._run_command(**kwargs) + + def run_to_rel_pos(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_TO_REL_POS + self._run_command(**kwargs) + + def run_timed(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_TIMED + self._run_command(**kwargs) + + def run_direct(self, **kwargs): + kwargs['command'] = LargeMotor.COMMAND_RUN_DIRECT + self._run_command(**kwargs) + + def reset(self, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.reset() + + def stop(self, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.stop() + + def _is_state(self, motors, state): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + if state not in motor.state: + return False + + return True + + @property + def is_running(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_RUNNING) + + @property + def is_ramping(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_RAMPING) + + @property + def is_holding(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_HOLDING) + + @property + def is_overloaded(self, motors=None): + return self._is_state(motors, LargeMotor.STATE_OVERLOADED) + + @property + def is_stalled(self): + return self._is_state(motors, LargeMotor.STATE_STALLED) + + def wait(self, cond, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait(cond, timeout) + + def wait_until_not_moving(self, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_until_not_moving(timeout) + + def wait_until(self, s, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_until(s, timeout) + + def wait_while(self, s, timeout=None, motors=None): + motors = motors if motors is not None else self.motors.values() + + for motor in motors: + motor.wait_while(s, timeout) + + +class MoveTank(MotorSet): + + def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=LargeMotor): + motor_specs = { + left_motor_port : motor_class, + right_motor_port : motor_class, + } + + MotorSet.__init__(self, motor_specs, desc) + self.left_motor = self.motors[left_motor_port] + self.right_motor = self.motors[right_motor_port] + self.max_speed = self.left_motor.max_speed + + def _block(self): + self.left_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.right_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.left_motor.wait_until_not_moving() + self.right_motor.wait_until_not_moving() + + def _validate_speed_pct(self, left_speed_pct, right_speed_pct): + assert left_speed_pct >= -100 and left_speed_pct <= 100,\ + "%s is an invalid left_speed_pct, must be between -100 and 100 (inclusive)" % left_speed_pct + assert right_speed_pct >= -100 and right_speed_pct <= 100,\ + "%s is an invalid right_speed_pct, must be between -100 and 100 (inclusive)" % right_speed_pct + assert left_speed_pct or right_speed_pct,\ + "Either left_speed_pct or right_speed_pct must be non-zero" + + def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=True, block=True): + """ + Rotate the motor at 'left_speed & right_speed' for 'rotations' + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + left_speed = int((left_speed_pct * self.max_speed) / 100) + right_speed = int((right_speed_pct * self.max_speed) / 100) + + if left_speed > right_speed: + left_rotations = rotations + right_rotations = float(right_speed / left_speed) * rotations + else: + left_rotations = float(left_speed / right_speed) * rotations + right_rotations = rotations + + # Set all parameters + self.left_motor.speed_sp = left_speed + self.left_motor._set_position_rotations(left_speed, left_rotations) + self.left_motor._set_brake(brake) + self.right_motor.speed_sp = right_speed + self.right_motor._set_position_rotations(right_speed, right_rotations) + self.right_motor._set_brake(brake) + + # Start the motors + self.left_motor.run_to_abs_pos() + self.right_motor.run_to_abs_pos() + + if block: + self._block() + + def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, block=True): + """ + Rotate the motor at 'left_speed & right_speed' for 'degrees' + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + left_speed = int((left_speed_pct * self.max_speed) / 100) + right_speed = int((right_speed_pct * self.max_speed) / 100) + + if left_speed > right_speed: + left_degrees = degrees + right_degrees = float(right_speed / left_speed) * degrees + else: + left_degrees = float(left_speed / right_speed) * degrees + right_degrees = degrees + + # Set all parameters + self.left_motor.speed_sp = left_speed + self.left_motor._set_position_degrees(left_speed, left_degrees) + self.left_motor._set_brake(brake) + self.right_motor.speed_sp = right_speed + self.right_motor._set_position_degrees(right_speed, right_degrees) + self.right_motor._set_brake(brake) + + # Start the motors + self.left_motor.run_to_abs_pos() + self.right_motor.run_to_abs_pos() + + if block: + self._block() + + def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, block=True): + """ + Rotate the motor at 'left_speed & right_speed' for 'seconds' + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + + # Set all parameters + self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) + self.left_motor.time_sp = int(seconds * 1000) + self.left_motor._set_brake(brake) + self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) + self.right_motor.time_sp = int(seconds * 1000) + self.right_motor._set_brake(brake) + + # Start the motors + self.left_motor.run_timed() + self.right_motor.run_timed() + + if block: + self._block() + + def on(self, left_speed_pct, right_speed_pct): + """ + Rotate the motor at 'left_speed & right_speed' for forever + """ + self._validate_speed_pct(left_speed_pct, right_speed_pct) + self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) + self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) + + # Start the motors + self.left_motor.run_forever() + self.right_motor.run_forever() + + def off(self, brake=True): + self.left_motor._set_brake(brake) + self.right_motor._set_brake(brake) + self.left_motor.stop() + self.right_motor.stop() + + +class MoveSteering(MoveTank): + + def get_speed_steering(self, steering, speed_pct): + """ + Calculate the speed_sp for each motor in a pair to achieve the specified + steering. Note that calling this function alone will not make the + motors move, it only sets the speed. A run_* function must be called + afterwards to make the motors move. + + steering [-100, 100]: + * -100 means turn left as fast as possible, + * 0 means drive in a straight line, and + * 100 means turn right as fast as possible. + + speed_pct: + The speed that should be applied to the outmost motor (the one + rotating faster). The speed of the other motor will be computed + automatically. + """ + + assert steering >= -100 and steering <= 100,\ + "%s is an invalid steering, must be between -100 and 100 (inclusive)" % steering + assert speed_pct >= -100 and speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + + left_speed = int((speed_pct * self.max_speed) / 100) + right_speed = left_speed + speed = (50 - abs(float(steering))) / 50 + + if steering >= 0: + right_speed *= speed + else: + left_speed *= speed + + left_speed_pct = int((left_speed * 100) / self.left_motor.max_speed) + right_speed_pct = int((right_speed * 100) / self.right_motor.max_speed) + #log.debug("%s: steering %d, %s speed %d, %s speed %d" % + # (self, steering, self.left_motor, left_speed_pct, self.right_motor, right_speed_pct)) + + return (left_speed_pct, right_speed_pct) + + def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake, block) + + def on_for_degrees(self, steering, speed_pct, degrees, brake=True, block=True): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake, block) + + def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake, block) + + def on(self, steering, speed_pct): + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on(self, left_speed_pct, right_speed_pct) diff --git a/ev3dev/pistorms.py b/ev3dev/pistorms.py deleted file mode 100644 index e7aa78c..0000000 --- a/ev3dev/pistorms.py +++ /dev/null @@ -1,16 +0,0 @@ - -""" -An assortment of classes modeling specific features of the PiStorms. -""" - -from .core import * - -OUTPUT_A = 'pistorms:BBM1' -OUTPUT_B = 'pistorms:BBM2' -OUTPUT_C = 'pistorms:BAM2' -OUTPUT_D = 'pistorms:BAM1' - -INPUT_1 = 'pistorms:BBS1' -INPUT_2 = 'pistorms:BBS2' -INPUT_3 = 'pistorms:BAS2' -INPUT_4 = 'pistorms:BAS1' diff --git a/ev3dev/port.py b/ev3dev/port.py new file mode 100644 index 0000000..0ac71fb --- /dev/null +++ b/ev3dev/port.py @@ -0,0 +1,150 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + + +class LegoPort(Device): + """ + The `lego-port` class provides an interface for working with input and + output ports that are compatible with LEGO MINDSTORMS RCX/NXT/EV3, LEGO + WeDo and LEGO Power Functions sensors and motors. Supported devices include + the LEGO MINDSTORMS EV3 Intelligent Brick, the LEGO WeDo USB hub and + various sensor multiplexers from 3rd party manufacturers. + + Some types of ports may have multiple modes of operation. For example, the + input ports on the EV3 brick can communicate with sensors using UART, I2C + or analog validate signals - but not all at the same time. Therefore there + are multiple modes available to connect to the different types of sensors. + + In most cases, ports are able to automatically detect what type of sensor + or motor is connected. In some cases though, this must be manually specified + using the `mode` and `set_device` attributes. The `mode` attribute affects + how the port communicates with the connected device. For example the input + ports on the EV3 brick can communicate using UART, I2C or analog voltages, + but not all at the same time, so the mode must be set to the one that is + appropriate for the connected sensor. The `set_device` attribute is used to + specify the exact type of sensor that is connected. Note: the mode must be + correctly set before setting the sensor type. + + Ports can be found at `/sys/class/lego-port/port` where `` is + incremented each time a new port is registered. Note: The number is not + related to the actual port at all - use the `address` attribute to find + a specific port. + """ + + SYSTEM_CLASS_NAME = 'lego-port' + SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_address', + '_driver_name', + '_modes', + '_mode', + '_set_device', + '_status', + ] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + if address is not None: + kwargs['address'] = address + super(LegoPort, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._address = None + self._driver_name = None + self._modes = None + self._mode = None + self._set_device = None + self._status = None + + @property + def address(self): + """ + Returns the name of the port. See individual driver documentation for + the name that will be returned. + """ + self._address, value = self.get_attr_string(self._address, 'address') + return value + + @property + def driver_name(self): + """ + Returns the name of the driver that loaded this device. You can find the + complete list of drivers in the [list of port drivers]. + """ + self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + return value + + @property + def modes(self): + """ + Returns a list of the available modes of the port. + """ + self._modes, value = self.get_attr_set(self._modes, 'modes') + return value + + @property + def mode(self): + """ + Reading returns the currently selected mode. Writing sets the mode. + Generally speaking when the mode changes any sensor or motor devices + associated with the port will be removed new ones loaded, however this + this will depend on the individual driver implementing this class. + """ + self._mode, value = self.get_attr_string(self._mode, 'mode') + return value + + @mode.setter + def mode(self, value): + self._mode = self.set_attr_string(self._mode, 'mode', value) + + @property + def set_device(self): + """ + For modes that support it, writing the name of a driver will cause a new + device to be registered for that driver and attached to this port. For + example, since NXT/Analog sensors cannot be auto-detected, you must use + this attribute to load the correct driver. Returns -EOPNOTSUPP if setting a + device is not supported. + """ + raise Exception("set_device is a write-only property!") + + @set_device.setter + def set_device(self, value): + self._set_device = self.set_attr_string(self._set_device, 'set_device', value) + + @property + def status(self): + """ + In most cases, reading status will return the same value as `mode`. In + cases where there is an `auto` mode additional values may be returned, + such as `no-device` or `error`. See individual port driver documentation + for the full list of possible values. + """ + self._status, value = self.get_attr_string(self._status, 'status') + return value diff --git a/ev3dev/power.py b/ev3dev/power.py new file mode 100644 index 0000000..a04bcb8 --- /dev/null +++ b/ev3dev/power.py @@ -0,0 +1,120 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +from ev3dev import Device + + +class PowerSupply(Device): + """ + A generic interface to read data from the system's power_supply class. + Uses the built-in legoev3-battery if none is specified. + """ + + SYSTEM_CLASS_NAME = 'power_supply' + SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_measured_current', + '_measured_voltage', + '_max_voltage', + '_min_voltage', + '_technology', + '_type', + ] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + if address is not None: + kwargs['address'] = address + super(PowerSupply, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._measured_current = None + self._measured_voltage = None + self._max_voltage = None + self._min_voltage = None + self._technology = None + self._type = None + + @property + def measured_current(self): + """ + The measured current that the battery is supplying (in microamps) + """ + self._measured_current, value = self.get_attr_int(self._measured_current, 'current_now') + return value + + @property + def measured_voltage(self): + """ + The measured voltage that the battery is supplying (in microvolts) + """ + self._measured_voltage, value = self.get_attr_int(self._measured_voltage, 'voltage_now') + return value + + @property + def max_voltage(self): + """ + """ + self._max_voltage, value = self.get_attr_int(self._max_voltage, 'voltage_max_design') + return value + + @property + def min_voltage(self): + """ + """ + self._min_voltage, value = self.get_attr_int(self._min_voltage, 'voltage_min_design') + return value + + @property + def technology(self): + """ + """ + self._technology, value = self.get_attr_string(self._technology, 'technology') + return value + + @property + def type(self): + """ + """ + self._type, value = self.get_attr_string(self._type, 'type') + return value + + @property + def measured_amps(self): + """ + The measured current that the battery is supplying (in amps) + """ + return self.measured_current / 1e6 + + @property + def measured_volts(self): + """ + The measured voltage that the battery is supplying (in volts) + """ + return self.measured_voltage / 1e6 diff --git a/ev3dev/sensor/__init__.py b/ev3dev/sensor/__init__.py new file mode 100644 index 0000000..1253cf1 --- /dev/null +++ b/ev3dev/sensor/__init__.py @@ -0,0 +1,341 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import numbers +from os.path import abspath +from struct import unpack +from ev3dev import get_current_platform, Device + + +# INPUT ports have platform specific values that we must import +platform = get_current_platform() + +if platform == 'ev3': + from ev3dev._platform.ev3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + +elif platform == 'evb': + from ev3dev._platform.evb import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + +elif platform == 'pistorms': + from ev3dev._platform.pistorms import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + +elif platform == 'brickpi': + from ev3dev._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + +elif platform == 'brickpi3': + from ev3dev._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + +else: + raise Exception("Unsupported platform '%s'" % platform) + + +class Sensor(Device): + """ + The sensor class provides a uniform interface for using most of the + sensors available for the EV3. The various underlying device drivers will + create a `lego-sensor` device for interacting with the sensors. + + Sensors are primarily controlled by setting the `mode` and monitored by + reading the `value` attributes. Values can be converted to floating point + if needed by `value` / 10.0 ^ `decimals`. + + Since the name of the `sensor` device node does not correspond to the port + that a sensor is plugged in to, you must look at the `address` attribute if + you need to know which port a sensor is plugged in to. However, if you don't + have more than one sensor of each type, you can just look for a matching + `driver_name`. Then it will not matter which port a sensor is plugged in to - your + program will still work. + """ + + SYSTEM_CLASS_NAME = 'lego-sensor' + SYSTEM_DEVICE_NAME_CONVENTION = 'sensor*' + __slots__ = [ + '_address', + '_command', + '_commands', + '_decimals', + '_driver_name', + '_mode', + '_modes', + '_num_values', + '_units', + '_value', + '_bin_data_format', + '_bin_data_size', + '_bin_data', + '_mode_scale' + ] + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + + if address is not None: + kwargs['address'] = address + super(Sensor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) + + self._address = None + self._command = None + self._commands = None + self._decimals = None + self._driver_name = None + self._mode = None + self._modes = None + self._num_values = None + self._units = None + self._value = [None,None,None,None,None,None,None,None] + + self._bin_data_format = None + self._bin_data_size = None + self._bin_data = None + self._mode_scale = {} + + def _scale(self, mode): + """ + Returns value scaling coefficient for the given mode. + """ + if mode in self._mode_scale: + scale = self._mode_scale[mode] + else: + scale = 10**(-self.decimals) + self._mode_scale[mode] = scale + + return scale + + @property + def address(self): + """ + Returns the name of the port that the sensor is connected to, e.g. `ev3:in1`. + I2C sensors also include the I2C address (decimal), e.g. `ev3:in1:i2c8`. + """ + self._address, value = self.get_attr_string(self._address, 'address') + return value + + @property + def command(self): + """ + Sends a command to the sensor. + """ + raise Exception("command is a write-only property!") + + @command.setter + def command(self, value): + self._command = self.set_attr_string(self._command, 'command', value) + + @property + def commands(self): + """ + Returns a list of the valid commands for the sensor. + Returns -EOPNOTSUPP if no commands are supported. + """ + self._commands, value = self.get_attr_set(self._commands, 'commands') + return value + + @property + def decimals(self): + """ + Returns the number of decimal places for the values in the `value` + attributes of the current mode. + """ + self._decimals, value = self.get_attr_int(self._decimals, 'decimals') + return value + + @property + def driver_name(self): + """ + Returns the name of the sensor device/driver. See the list of [supported + sensors] for a complete list of drivers. + """ + self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + return value + + @property + def mode(self): + """ + Returns the current mode. Writing one of the values returned by `modes` + sets the sensor to that mode. + """ + self._mode, value = self.get_attr_string(self._mode, 'mode') + return value + + @mode.setter + def mode(self, value): + self._mode = self.set_attr_string(self._mode, 'mode', value) + + @property + def modes(self): + """ + Returns a list of the valid modes for the sensor. + """ + self._modes, value = self.get_attr_set(self._modes, 'modes') + return value + + @property + def num_values(self): + """ + Returns the number of `value` attributes that will return a valid value + for the current mode. + """ + self._num_values, value = self.get_attr_int(self._num_values, 'num_values') + return value + + @property + def units(self): + """ + Returns the units of the measured value for the current mode. May return + empty string + """ + self._units, value = self.get_attr_string(self._units, 'units') + return value + + def value(self, n=0): + """ + Returns the value or values measured by the sensor. Check num_values to + see how many values there are. Values with N >= num_values will return + an error. The values are fixed point numbers, so check decimals to see + if you need to divide to get the actual value. + """ + if isinstance(n, numbers.Real): + n = int(n) + elif isinstance(n, str): + n = int(n) + + self._value[n], value = self.get_attr_int(self._value[n], 'value'+str(n)) + return value + + @property + def bin_data_format(self): + """ + Returns the format of the values in `bin_data` for the current mode. + Possible values are: + + - `u8`: Unsigned 8-bit integer (byte) + - `s8`: Signed 8-bit integer (sbyte) + - `u16`: Unsigned 16-bit integer (ushort) + - `s16`: Signed 16-bit integer (short) + - `s16_be`: Signed 16-bit integer, big endian + - `s32`: Signed 32-bit integer (int) + - `float`: IEEE 754 32-bit floating point (float) + """ + self._bin_data_format, value = self.get_attr_string(self._bin_data_format, 'bin_data_format') + return value + + def bin_data(self, fmt=None): + """ + Returns the unscaled raw values in the `value` attributes as raw byte + array. Use `bin_data_format`, `num_values` and the individual sensor + documentation to determine how to interpret the data. + + Use `fmt` to unpack the raw bytes into a struct. + + Example:: + + >>> from ev3dev import * + >>> ir = InfraredSensor() + >>> ir.value() + 28 + >>> ir.bin_data(' +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import time +from ev3dev._button import ButtonBase +from ev3dev.sensor import Sensor + + +class TouchSensor(Sensor): + """ + Touch Sensor + """ + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Button state + MODE_TOUCH = 'TOUCH' + MODES = (MODE_TOUCH,) + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(TouchSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-touch', 'lego-nxt-touch'], **kwargs) + + @property + def is_pressed(self): + """ + A boolean indicating whether the current touch sensor is being + pressed. + """ + self.mode = self.MODE_TOUCH + return self.value(0) + + @property + def is_released(self): + return not self.is_pressed + + def _wait(self, wait_for_press, timeout_ms, sleep_ms): + tic = time.time() + + if sleep_ms: + sleep_ms = float(sleep_ms/1000) + + # The kernel does not supoort POLLPRI or POLLIN for sensors so we have + # to drop into a loop and check often + while True: + + if self.is_pressed == wait_for_press: + return True + + if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: + return False + + if sleep_ms: + time.sleep(sleep_ms) + + def wait_for_pressed(self, timeout_ms=None, sleep_ms=10): + return self._wait(True, timeout_ms, sleep_ms) + + def wait_for_released(self, timeout_ms=None, sleep_ms=10): + return self._wait(False, timeout_ms, sleep_ms) + + def wait_for_bump(self, timeout_ms=None, sleep_ms=10): + """ + Wait for the touch sensor to be pressed down and then released. + Both actions must happen within timeout_ms. + """ + start_time = time.time() + + if self.wait_for_pressed(timeout_ms, sleep_ms): + if timeout_ms is not None: + timeout_ms -= int((time.time() - start_time) * 1000) + return self.wait_for_released(timeout_ms, sleep_ms) + + return False + + +class ColorSensor(Sensor): + """ + LEGO EV3 color sensor. + """ + + __slots__ = ['red_max', 'green_max', 'blue_max'] + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Reflected light. Red LED on. + MODE_COL_REFLECT = 'COL-REFLECT' + + #: Ambient light. Red LEDs off. + MODE_COL_AMBIENT = 'COL-AMBIENT' + + #: Color. All LEDs rapidly cycling, appears white. + MODE_COL_COLOR = 'COL-COLOR' + + #: Raw reflected. Red LED on + MODE_REF_RAW = 'REF-RAW' + + #: Raw Color Components. All LEDs rapidly cycling, appears white. + MODE_RGB_RAW = 'RGB-RAW' + + #: No color. + COLOR_NOCOLOR = 0 + + #: Black color. + COLOR_BLACK = 1 + + #: Blue color. + COLOR_BLUE = 2 + + #: Green color. + COLOR_GREEN = 3 + + #: Yellow color. + COLOR_YELLOW = 4 + + #: Red color. + COLOR_RED = 5 + + #: White color. + COLOR_WHITE = 6 + + #: Brown color. + COLOR_BROWN = 7 + + MODES = ( + MODE_COL_REFLECT, + MODE_COL_AMBIENT, + MODE_COL_COLOR, + MODE_REF_RAW, + MODE_RGB_RAW + ) + + COLORS = ( + 'NoColor', + 'Black', + 'Blue', + 'Green', + 'Yellow', + 'Red', + 'White', + 'Brown', + ) + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-color'], **kwargs) + + # See calibrate_white() for more details + self.red_max = 300 + self.green_max = 300 + self.blue_max = 300 + + @property + def reflected_light_intensity(self): + """ + Reflected light intensity as a percentage. Light on sensor is red. + """ + self.mode = self.MODE_COL_REFLECT + return self.value(0) + + @property + def ambient_light_intensity(self): + """ + Ambient light intensity. Light on sensor is dimly lit blue. + """ + self.mode = self.MODE_COL_AMBIENT + return self.value(0) + + @property + def color(self): + """ + Color detected by the sensor, categorized by overall value. + - 0: No color + - 1: Black + - 2: Blue + - 3: Green + - 4: Yellow + - 5: Red + - 6: White + - 7: Brown + """ + self.mode = self.MODE_COL_COLOR + return self.value(0) + + @property + def color_name(self): + """ + Returns NoColor, Black, Blue, etc + """ + return self.COLORS[self.color] + + @property + def raw(self): + """ + Red, green, and blue components of the detected color, officially in the + range 0-1020 but the values returned will never be that high. We do not + yet know why the values returned are low, but pointing the color sensor + at a well lit sheet of white paper will return values in the 250-400 range. + + If this is an issue, check out the rgb() and calibrate_white() methods. + """ + self.mode = self.MODE_RGB_RAW + return self.value(0), self.value(1), self.value(2) + + def calibrate_white(self): + """ + The RGB raw values are on a scale of 0-1020 but you never see a value + anywhere close to 1020. This function is designed to be called when + the sensor is placed over a white object in order to figure out what + are the maximum RGB values the robot can expect to see. We will use + these maximum values to scale future raw values to a 0-255 range in + rgb(). + + If you never call this function red_max, green_max, and blue_max will + use a default value of 300. This default was selected by measuring + the RGB values of a white sheet of paper in a well lit room. + + Note that there are several variables that influence the maximum RGB + values detected by the color sensor + - the distance of the color sensor to the white object + - the amount of light in the room + - shadows that the robot casts on the sensor + """ + (self.red_max, self.green_max, self.blue_max) = self.raw + + @property + def rgb(self): + """ + Same as raw() but RGB values are scaled to 0-255 + """ + (red, green, blue) = self.raw + + return (min(int((red * 255) / self.red_max), 255), + min(int((green * 255) / self.green_max), 255), + min(int((blue * 255) / self.blue_max), 255)) + + @property + def red(self): + """ + Red component of the detected color, in the range 0-1020. + """ + self.mode = self.MODE_RGB_RAW + return self.value(0) + + @property + def green(self): + """ + Green component of the detected color, in the range 0-1020. + """ + self.mode = self.MODE_RGB_RAW + return self.value(1) + + @property + def blue(self): + """ + Blue component of the detected color, in the range 0-1020. + """ + self.mode = self.MODE_RGB_RAW + return self.value(2) + + +class UltrasonicSensor(Sensor): + """ + LEGO EV3 ultrasonic sensor. + """ + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Continuous measurement in centimeters. + MODE_US_DIST_CM = 'US-DIST-CM' + + #: Continuous measurement in inches. + MODE_US_DIST_IN = 'US-DIST-IN' + + #: Listen. + MODE_US_LISTEN = 'US-LISTEN' + + #: Single measurement in centimeters. + MODE_US_SI_CM = 'US-SI-CM' + + #: Single measurement in inches. + MODE_US_SI_IN = 'US-SI-IN' + + MODES = ( + MODE_US_DIST_CM, + MODE_US_DIST_IN, + MODE_US_LISTEN, + MODE_US_SI_CM, + MODE_US_SI_IN, + ) + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(UltrasonicSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-us', 'lego-nxt-us'], **kwargs) + + @property + def distance_centimeters(self): + """ + Measurement of the distance detected by the sensor, + in centimeters. + """ + self.mode = self.MODE_US_DIST_CM + return self.value(0) * self._scale('US_DIST_CM') + + @property + def distance_inches(self): + """ + Measurement of the distance detected by the sensor, + in inches. + """ + self.mode = self.MODE_US_DIST_IN + return self.value(0) * self._scale('US_DIST_IN') + + @property + def other_sensor_present(self): + """ + Value indicating whether another ultrasonic sensor could + be heard nearby. + """ + self.mode = self.MODE_US_LISTEN + return self.value(0) + + +class GyroSensor(Sensor): + """ + LEGO EV3 gyro sensor. + """ + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Angle + MODE_GYRO_ANG = 'GYRO-ANG' + + #: Rotational speed + MODE_GYRO_RATE = 'GYRO-RATE' + + #: Raw sensor value + MODE_GYRO_FAS = 'GYRO-FAS' + + #: Angle and rotational speed + MODE_GYRO_G_A = 'GYRO-G&A' + + #: Calibration ??? + MODE_GYRO_CAL = 'GYRO-CAL' + + # Newer versions of the Gyro sensor also have an additional second axis + # accessible via the TILT-ANGLE and TILT-RATE modes that is not usable + # using the official EV3-G blocks + MODE_TILT_ANG = 'TILT-ANGLE' + MODE_TILT_RATE = 'TILT-RATE' + + MODES = ( + MODE_GYRO_ANG, + MODE_GYRO_RATE, + MODE_GYRO_FAS, + MODE_GYRO_G_A, + MODE_GYRO_CAL, + MODE_TILT_ANG, + MODE_TILT_RATE, + ) + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-gyro'], **kwargs) + self._direct = None + + @property + def angle(self): + """ + The number of degrees that the sensor has been rotated + since it was put into this mode. + """ + self.mode = self.MODE_GYRO_ANG + return self.value(0) + + @property + def rate(self): + """ + The rate at which the sensor is rotating, in degrees/second. + """ + self.mode = self.MODE_GYRO_RATE + return self.value(0) + + @property + def rate_and_angle(self): + """ + Angle (degrees) and Rotational Speed (degrees/second). + """ + self.mode = self.MODE_GYRO_G_A + return self.value(0), self.value(1) + + @property + def tilt_angle(self): + self.mode = self.MODE_TILT_ANG + return self.value(0) + + @property + def tilt_rate(self): + self.mode = self.MODE_TILT_RATE + return self.value(0) + + def reset(self): + self.mode = self.MODE_GYRO_ANG + self._direct = self.set_attr_raw(self._direct, 'direct', 17) + + +class InfraredSensor(Sensor, ButtonBase): + """ + LEGO EV3 infrared sensor. + """ + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Proximity + MODE_IR_PROX = 'IR-PROX' + + #: IR Seeker + MODE_IR_SEEK = 'IR-SEEK' + + #: IR Remote Control + MODE_IR_REMOTE = 'IR-REMOTE' + + #: IR Remote Control. State of the buttons is coded in binary + MODE_IR_REM_A = 'IR-REM-A' + + #: Calibration ??? + MODE_IR_CAL = 'IR-CAL' + + MODES = ( + MODE_IR_PROX, + MODE_IR_SEEK, + MODE_IR_REMOTE, + MODE_IR_REM_A, + MODE_IR_CAL + ) + + # The following are all of the various combinations of button presses for + # the remote control. The key/index is the number that will be written in + # the attribute file to indicate what combination of buttons are currently + # pressed. + _BUTTON_VALUES = { + 0: [], + 1: ['top_left'], + 2: ['bottom_left'], + 3: ['top_right'], + 4: ['bottom_right'], + 5: ['top_left', 'top_right'], + 6: ['top_left', 'bottom_right'], + 7: ['bottom_left', 'top_right'], + 8: ['bottom_left', 'bottom_right'], + 9: ['beacon'], + 10: ['top_left', 'bottom_left'], + 11: ['top_right', 'bottom_right'] + } + + _BUTTONS = ('top_left', 'bottom_left', 'top_right', 'bottom_right', 'beacon') + + # See process() for an explanation on how to use these + #: Handles ``Red Up``, etc events on channel 1 + on_channel1_top_left = None + on_channel1_bottom_left = None + on_channel1_top_right = None + on_channel1_bottom_right = None + on_channel1_beacon = None + + #: Handles ``Red Up``, etc events on channel 2 + on_channel2_top_left = None + on_channel2_bottom_left = None + on_channel2_top_right = None + on_channel2_bottom_right = None + on_channel2_beacon = None + + #: Handles ``Red Up``, etc events on channel 3 + on_channel3_top_left = None + on_channel3_bottom_left = None + on_channel3_top_right = None + on_channel3_bottom_right = None + on_channel3_beacon = None + + #: Handles ``Red Up``, etc events on channel 4 + on_channel4_top_left = None + on_channel4_bottom_left = None + on_channel4_top_right = None + on_channel4_bottom_right = None + on_channel4_beacon = None + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-ir'], **kwargs) + + def _normalize_channel(self, channel): + assert channel >= 1 and channel <= 4, "channel is %s, it must be 1, 2, 3, or 4" % channel + channel = max(1, min(4, channel)) - 1 + return channel + + @property + def proximity(self): + """ + A measurement of the distance between the sensor and the remote, + as a percentage. 100% is approximately 70cm/27in. + """ + self.mode = self.MODE_IR_PROX + return self.value(0) + + def heading(self, channel=1): + """ + Returns heading (-25, 25) to the beacon on the given channel. + """ + self.mode = self.MODE_IR_SEEK + channel = self._normalize_channel(channel) + return self.value(channel * 2) + + def distance(self, channel=1): + """ + Returns distance (0, 100) to the beacon on the given channel. + Returns None when beacon is not found. + """ + self.mode = self.MODE_IR_SEEK + channel = self._normalize_channel(channel) + ret_value = self.value((channel * 2) + 1) + + # The value will be -128 if no beacon is found, return None instead + return None if ret_value == -128 else ret_value + + def heading_and_distance(self, channel=1): + """ + Returns heading and distance to the beacon on the given channel as a + tuple. + """ + return (self.heading(channel), self.distance(channel)) + + def top_left(self, channel=1): + """ + Checks if `top_left` button is pressed. + """ + return 'top_left' in self.buttons_pressed(channel) + + def bottom_left(self, channel=1): + """ + Checks if `bottom_left` button is pressed. + """ + return 'bottom_left' in self.buttons_pressed(channel) + + def top_right(self, channel=1): + """ + Checks if `top_right` button is pressed. + """ + return 'top_right' in self.buttons_pressed(channel) + + def bottom_right(self, channel=1): + """ + Checks if `bottom_right` button is pressed. + """ + return 'bottom_right' in self.buttons_pressed(channel) + + def beacon(self, channel=1): + """ + Checks if `beacon` button is pressed. + """ + return 'beacon' in self.buttons_pressed(channel) + + def buttons_pressed(self, channel=1): + """ + Returns list of currently pressed buttons. + """ + self.mode = self.MODE_IR_REMOTE + channel = self._normalize_channel(channel) + return self._BUTTON_VALUES.get(self.value(channel), []) + + def process(self): + """ + Check for currenly pressed buttons. If the new state differs from the + old state, call the appropriate button event handlers. + + To use the on_channel1_top_left, etc handlers your program would do something like: + + def top_left_channel_1_action(state): + print("top left on channel 1: %s" % state) + + def bottom_right_channel_4_action(state): + print("bottom right on channel 4: %s" % state) + + ir = InfraredSensor() + ir.on_channel1_top_left = top_left_channel_1_action + ir.on_channel4_bottom_right = bottom_right_channel_4_action + + while True: + ir.process() + time.sleep(0.01) + """ + new_state = [] + state_diff = [] + + for channel in range(1,5): + + for button in self.buttons_pressed(channel): + new_state.append((button, channel)) + + # Key was not pressed before but now is pressed + if (button, channel) not in self._state: + state_diff.append((button, channel)) + + # Key was pressed but is no longer pressed + for button in self._BUTTONS: + if (button, channel) not in new_state and (button, channel) in self._state: + state_diff.append((button, channel)) + + old_state = self._state + self._state = new_state + + for (button, channel) in state_diff: + handler = getattr(self, 'on_channel' + str(channel) + '_' + button ) + + if handler is not None: + handler((button, channel) in new_state) + + if self.on_change is not None and state_diff: + self.on_change([(button, channel, button in new_state) for (button, channel) in state_diff]) + + +class SoundSensor(Sensor): + """ + LEGO NXT Sound Sensor + """ + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Sound pressure level. Flat weighting + MODE_DB = 'DB' + + #: Sound pressure level. A weighting + MODE_DBA = 'DBA' + + MODES = ( + MODE_DB, + MODE_DBA, + ) + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(SoundSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-sound'], **kwargs) + + @property + def sound_pressure(self): + """ + A measurement of the measured sound pressure level, as a + percent. Uses a flat weighting. + """ + self.mode = self.MODE_DB + return self.value(0) * self._scale('DB') + + @property + def sound_pressure_low(self): + """ + A measurement of the measured sound pressure level, as a + percent. Uses A-weighting, which focuses on levels up to 55 dB. + """ + self.mode = self.MODE_DBA + return self.value(0) * self._scale('DBA') + + +class LightSensor(Sensor): + """ + LEGO NXT Light Sensor + """ + + SYSTEM_CLASS_NAME = Sensor.SYSTEM_CLASS_NAME + SYSTEM_DEVICE_NAME_CONVENTION = Sensor.SYSTEM_DEVICE_NAME_CONVENTION + + #: Reflected light. LED on + MODE_REFLECT = 'REFLECT' + + #: Ambient light. LED off + MODE_AMBIENT = 'AMBIENT' + + MODES = ( + MODE_REFLECT, + MODE_AMBIENT, + ) + + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + super(LightSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-light'], **kwargs) + + @property + def reflected_light_intensity(self): + """ + A measurement of the reflected light intensity, as a percentage. + """ + self.mode = self.MODE_REFLECT + return self.value(0) * self._scale('REFLECT') + + @property + def ambient_light_intensity(self): + """ + A measurement of the ambient light intensity, as a percentage. + """ + self.mode = self.MODE_AMBIENT + return self.value(0) * self._scale('AMBIENT') diff --git a/ev3dev/sound.py b/ev3dev/sound.py new file mode 100644 index 0000000..8bea72f --- /dev/null +++ b/ev3dev/sound.py @@ -0,0 +1,464 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# Copyright (c) 2015 Anton Vanhoucke +# Copyright (c) 2015 Denis Demidov +# Copyright (c) 2015 Eric Pascual +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +import io +import re +import shlex +from subprocess import Popen, check_output, PIPE + + +def _make_scales(notes): + """ Utility function used by Sound class for building the note frequencies table """ + res = dict() + for note, freq in notes: + freq = round(freq) + for n in note.split('/'): + res[n] = freq + return res + + +class Sound: + """ + Sound-related functions. The class has only static methods and is not + intended for instantiation. It can beep, play wav files, or convert text to + speech. + + Note that all methods of the class spawn system processes and return + subprocess.Popen objects. The methods are asynchronous (they return + immediately after child process was spawned, without waiting for its + completion), but you can call wait() on the returned result. + + Examples:: + + # Play 'bark.wav', return immediately: + Sound.play('bark.wav') + + # Introduce yourself, wait for completion: + Sound.speak('Hello, I am Robot').wait() + + # Play a small song + Sound.play_song(( + ('D4', 'e3'), + ('D4', 'e3'), + ('D4', 'e3'), + ('G4', 'h'), + ('D5', 'h') + )) + """ + + channel = None + + @staticmethod + def beep(args=''): + """ + Call beep command with the provided arguments (if any). + See `beep man page`_ and google `linux beep music`_ for inspiration. + + .. _`beep man page`: https://linux.die.net/man/1/beep + .. _`linux beep music`: https://www.google.com/search?q=linux+beep+music + """ + with open(os.devnull, 'w') as n: + return Popen(shlex.split('/usr/bin/beep %s' % args), stdout=n) + + @staticmethod + def tone(*args): + """ + .. rubric:: tone(tone_sequence) + + Play tone sequence. The tone_sequence parameter is a list of tuples, + where each tuple contains up to three numbers. The first number is + frequency in Hz, the second is duration in milliseconds, and the third + is delay in milliseconds between this and the next tone in the + sequence. + + Here is a cheerful example:: + + Sound.tone([ + (392, 350, 100), (392, 350, 100), (392, 350, 100), (311.1, 250, 100), + (466.2, 25, 100), (392, 350, 100), (311.1, 250, 100), (466.2, 25, 100), + (392, 700, 100), (587.32, 350, 100), (587.32, 350, 100), + (587.32, 350, 100), (622.26, 250, 100), (466.2, 25, 100), + (369.99, 350, 100), (311.1, 250, 100), (466.2, 25, 100), (392, 700, 100), + (784, 350, 100), (392, 250, 100), (392, 25, 100), (784, 350, 100), + (739.98, 250, 100), (698.46, 25, 100), (659.26, 25, 100), + (622.26, 25, 100), (659.26, 50, 400), (415.3, 25, 200), (554.36, 350, 100), + (523.25, 250, 100), (493.88, 25, 100), (466.16, 25, 100), (440, 25, 100), + (466.16, 50, 400), (311.13, 25, 200), (369.99, 350, 100), + (311.13, 250, 100), (392, 25, 100), (466.16, 350, 100), (392, 250, 100), + (466.16, 25, 100), (587.32, 700, 100), (784, 350, 100), (392, 250, 100), + (392, 25, 100), (784, 350, 100), (739.98, 250, 100), (698.46, 25, 100), + (659.26, 25, 100), (622.26, 25, 100), (659.26, 50, 400), (415.3, 25, 200), + (554.36, 350, 100), (523.25, 250, 100), (493.88, 25, 100), + (466.16, 25, 100), (440, 25, 100), (466.16, 50, 400), (311.13, 25, 200), + (392, 350, 100), (311.13, 250, 100), (466.16, 25, 100), + (392.00, 300, 150), (311.13, 250, 100), (466.16, 25, 100), (392, 700) + ]).wait() + + .. rubric:: tone(frequency, duration) + + Play single tone of given frequency (Hz) and duration (milliseconds). + """ + def play_tone_sequence(tone_sequence): + def beep_args(frequency=None, duration=None, delay=None): + args = '' + if frequency is not None: args += '-f %s ' % frequency + if duration is not None: args += '-l %s ' % duration + if delay is not None: args += '-D %s ' % delay + + return args + + return Sound.beep(' -n '.join([beep_args(*t) for t in tone_sequence])) + + if len(args) == 1: + return play_tone_sequence(args[0]) + elif len(args) == 2: + return play_tone_sequence([(args[0], args[1])]) + else: + raise Exception("Unsupported number of parameters in Sound.tone()") + + @staticmethod + def play(wav_file): + """ + Play wav file. + """ + with open(os.devnull, 'w') as n: + return Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) + + @staticmethod + def speak(text, espeak_opts='-a 200 -s 130'): + """ + Speak the given text aloud. + """ + with open(os.devnull, 'w') as n: + cmd_line = '/usr/bin/espeak --stdout {0} "{1}"'.format(espeak_opts, text) + espeak = Popen(shlex.split(cmd_line), stdout=PIPE) + play = Popen(['/usr/bin/aplay', '-q'], stdin=espeak.stdout, stdout=n) + return espeak + + @staticmethod + def _get_channel(): + """ + :return: the detected sound channel + :rtype: str + """ + if Sound.channel is None: + # Get default channel as the first one that pops up in + # 'amixer scontrols' output, which contains strings in the + # following format: + # + # Simple mixer control 'Master',0 + # Simple mixer control 'Capture',0 + out = check_output(['amixer', 'scontrols']).decode() + m = re.search("'(?P[^']+)'", out) + if m: + Sound.channel = m.group('channel') + else: + Sound.channel = 'Playback' + + return Sound.channel + + @staticmethod + def set_volume(pct, channel=None): + """ + Sets the sound volume to the given percentage [0-100] by calling + ``amixer -q set %``. + If the channel is not specified, it tries to determine the default one + by running ``amixer scontrols``. If that fails as well, it uses the + ``Playback`` channel, as that is the only channel on the EV3. + """ + + if channel is None: + channel = Sound._get_channel() + + cmd_line = '/usr/bin/amixer -q set {0} {1:d}%'.format(channel, pct) + Popen(shlex.split(cmd_line)).wait() + + @staticmethod + def get_volume(channel=None): + """ + Gets the current sound volume by parsing the output of + ``amixer get ``. + If the channel is not specified, it tries to determine the default one + by running ``amixer scontrols``. If that fails as well, it uses the + ``Playback`` channel, as that is the only channel on the EV3. + """ + + if channel is None: + channel = Sound._get_channel() + + out = check_output(['amixer', 'get', channel]).decode() + m = re.search('\[(?P\d+)%\]', out) + if m: + return int(m.group('volume')) + else: + raise Exception('Failed to parse output of `amixer get {}`'.format(channel)) + + @classmethod + def play_song(cls, song, tempo=120, delay=50): + """ Plays a song provided as a list of tuples containing the note name and its + value using music conventional notation instead of numerical values for frequency + and duration. + + It supports symbolic notes (e.g. ``A4``, ``D#3``, ``Gb5``) and durations (e.g. ``q``, ``h``). + + For an exhaustive list of accepted note symbols and values, have a look at the :py:attr:`_NOTE_FREQUENCIES` + and :py:attr:`_NOTE_VALUES` private dictionaries in the source code. + + The value can be suffixed by modifiers: + + - a *divider* introduced by a ``/`` to obtain triplets for instance + (e.g. ``q/3`` for a triplet of eight note) + - a *multiplier* introduced by ``*`` (e.g. ``*1.5`` is a dotted note). + + Shortcuts exist for common modifiers: + + - ``3`` produces a triplet member note. For instance `e3` gives a triplet of eight notes, + i.e. 3 eight notes in the duration of a single quarter. You must ensure that 3 triplets + notes are defined in sequence to match the count, otherwise the result will not be the + expected one. + - ``.`` produces a dotted note, i.e. which duration is one and a half the base one. Double dots + are not currently supported. + + Example:: + + >>> # A long time ago in a galaxy far, + >>> # far away... + >>> Sound.play_song(( + >>> ('D4', 'e3'), # intro anacrouse + >>> ('D4', 'e3'), + >>> ('D4', 'e3'), + >>> ('G4', 'h'), # meas 1 + >>> ('D5', 'h'), + >>> ('C5', 'e3'), # meas 2 + >>> ('B4', 'e3'), + >>> ('A4', 'e3'), + >>> ('G5', 'h'), + >>> ('D5', 'q'), + >>> ('C5', 'e3'), # meas 3 + >>> ('B4', 'e3'), + >>> ('A4', 'e3'), + >>> ('G5', 'h'), + >>> ('D5', 'q'), + >>> ('C5', 'e3'), # meas 4 + >>> ('B4', 'e3'), + >>> ('C5', 'e3'), + >>> ('A4', 'h.'), + >>> )) + + .. important:: + + Only 4/4 signature songs are supported with respect to note durations. + + Args: + song (iterable[tuple(str, str)]): the song + tempo (int): the song tempo, given in quarters per minute + delay (int): delay in ms between notes + + Returns: + subprocess.Popen: the spawn subprocess + """ + meas_duration = 60000 / tempo * 4 + + def beep_args(note, value): + """ Builds the arguments string for producing a beep matching + the requested note and value. + + Args: + note (str): the note note and octave + value (str): the note value expression + Returns: + str: the arguments to be passed to the beep command + """ + freq = Sound._NOTE_FREQUENCIES[note.upper()] + if '/' in value: + base, factor = value.split('/') + duration = meas_duration * Sound._NOTE_VALUES[base] / float(factor) + elif '*' in value: + base, factor = value.split('*') + duration = meas_duration * Sound._NOTE_VALUES[base] * float(factor) + elif value.endswith('.'): + base = value[:-1] + duration = meas_duration * Sound._NOTE_VALUES[base] * 1.5 + elif value.endswith('3'): + base = value[:-1] + duration = meas_duration * Sound._NOTE_VALUES[base] * 2 / 3 + else: + duration = meas_duration * Sound._NOTE_VALUES[value] + + return '-f %d -l %d -D %d' % (freq, duration, delay) + + return Sound.beep(' -n '.join( + [beep_args(note, value) for note, value in song] + )) + + #: Note frequencies. + #: + #: This dictionary gives the rounded frequency of a note specified by its + #: standard US abbreviation and its octave number (e.g. ``C3``). + #: Alterations use the ``#`` and ``b`` symbols, respectively for + #: *sharp* and *flat*, between the note code and the octave number (e.g. ``D#4``, ``Gb5``). + _NOTE_FREQUENCIES = _make_scales(( + ('C0', 16.35), + ('C#0/Db0', 17.32), + ('D0', 18.35), + ('D#0/Eb0', 19.45), # expanded in one entry per symbol by _make_scales + ('E0', 20.60), + ('F0', 21.83), + ('F#0/Gb0', 23.12), + ('G0', 24.50), + ('G#0/Ab0', 25.96), + ('A0', 27.50), + ('A#0/Bb0', 29.14), + ('B0', 30.87), + ('C1', 32.70), + ('C#1/Db1', 34.65), + ('D1', 36.71), + ('D#1/Eb1', 38.89), + ('E1', 41.20), + ('F1', 43.65), + ('F#1/Gb1', 46.25), + ('G1', 49.00), + ('G#1/Ab1', 51.91), + ('A1', 55.00), + ('A#1/Bb1', 58.27), + ('B1', 61.74), + ('C2', 65.41), + ('C#2/Db2', 69.30), + ('D2', 73.42), + ('D#2/Eb2', 77.78), + ('E2', 82.41), + ('F2', 87.31), + ('F#2/Gb2', 92.50), + ('G2', 98.00), + ('G#2/Ab2', 103.83), + ('A2', 110.00), + ('A#2/Bb2', 116.54), + ('B2', 123.47), + ('C3', 130.81), + ('C#3/Db3', 138.59), + ('D3', 146.83), + ('D#3/Eb3', 155.56), + ('E3', 164.81), + ('F3', 174.61), + ('F#3/Gb3', 185.00), + ('G3', 196.00), + ('G#3/Ab3', 207.65), + ('A3', 220.00), + ('A#3/Bb3', 233.08), + ('B3', 246.94), + ('C4', 261.63), + ('C#4/Db4', 277.18), + ('D4', 293.66), + ('D#4/Eb4', 311.13), + ('E4', 329.63), + ('F4', 349.23), + ('F#4/Gb4', 369.99), + ('G4', 392.00), + ('G#4/Ab4', 415.30), + ('A4', 440.00), + ('A#4/Bb4', 466.16), + ('B4', 493.88), + ('C5', 523.25), + ('C#5/Db5', 554.37), + ('D5', 587.33), + ('D#5/Eb5', 622.25), + ('E5', 659.25), + ('F5', 698.46), + ('F#5/Gb5', 739.99), + ('G5', 783.99), + ('G#5/Ab5', 830.61), + ('A5', 880.00), + ('A#5/Bb5', 932.33), + ('B5', 987.77), + ('C6', 1046.50), + ('C#6/Db6', 1108.73), + ('D6', 1174.66), + ('D#6/Eb6', 1244.51), + ('E6', 1318.51), + ('F6', 1396.91), + ('F#6/Gb6', 1479.98), + ('G6', 1567.98), + ('G#6/Ab6', 1661.22), + ('A6', 1760.00), + ('A#6/Bb6', 1864.66), + ('B6', 1975.53), + ('C7', 2093.00), + ('C#7/Db7', 2217.46), + ('D7', 2349.32), + ('D#7/Eb7', 2489.02), + ('E7', 2637.02), + ('F7', 2793.83), + ('F#7/Gb7', 2959.96), + ('G7', 3135.96), + ('G#7/Ab7', 3322.44), + ('A7', 3520.00), + ('A#7/Bb7', 3729.31), + ('B7', 3951.07), + ('C8', 4186.01), + ('C#8/Db8', 4434.92), + ('D8', 4698.63), + ('D#8/Eb8', 4978.03), + ('E8', 5274.04), + ('F8', 5587.65), + ('F#8/Gb8', 5919.91), + ('G8', 6271.93), + ('G#8/Ab8', 6644.88), + ('A8', 7040.00), + ('A#8/Bb8', 7458.62), + ('B8', 7902.13) + )) + + #: Common note values. + #: + #: See https://en.wikipedia.org/wiki/Note_value + #: + #: This dictionary provides the multiplier to be applied to de whole note duration + #: to obtain subdivisions, given the corresponding symbolic identifier: + #: + #: = =============================== + #: w whole note (UK: semibreve) + #: h half note (UK: minim) + #: q quarter note (UK: crotchet) + #: e eight note (UK: quaver) + #: s sixteenth note (UK: semiquaver) + #: = =============================== + #: + #: + #: Triplets can be obtained by dividing the corresponding reference by 3. + #: For instance, the note value of a eight triplet will be ``NOTE_VALUE['e'] / 3``. + #: It is simpler however to user the ``3`` modifier of notes, as supported by the + #: :py:meth:`Sound.play_song` method. + _NOTE_VALUES = { + 'w': 1., + 'h': 1./2, + 'q': 1./4, + 'e': 1./8, + 's': 1./16, + } diff --git a/setup.py b/setup.py index 88c0791..4f76a0e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,11 @@ license='MIT', url='https://github.com/rhempel/ev3dev-lang-python', include_package_data=True, - packages=['ev3dev', 'ev3dev.fonts'], + packages=['ev3dev', + 'ev3dev.fonts', + 'ev3dev.sensor', + 'ev3dev.control', + 'ev3dev._platform'], package_data={'': ['*.pil', '*.pbm']}, install_requires=['Pillow'] ) diff --git a/tests/api_tests.py b/tests/api_tests.py index 7476352..89b76d5 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -9,7 +9,9 @@ from populate_arena import populate_arena from clean_arena import clean_arena -import ev3dev.core as ev3 +import ev3dev as ev3 +from ev3dev.sensor.lego import InfraredSensor +from ev3dev.motor import MediumMotor ev3.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') @@ -47,9 +49,9 @@ def dummy(self): populate_arena({'medium_motor' : [0, 'outA']}) # Do not write motor.command on exit (so that fake tree stays intact) - ev3.MediumMotor.__del__ = dummy + MediumMotor.__del__ = dummy - m = ev3.MediumMotor() + m = MediumMotor() self.assertTrue(m.connected); @@ -82,7 +84,7 @@ def test_infrared_sensor(self): clean_arena() populate_arena({'infrared_sensor' : [0, 'in1']}) - s = ev3.InfraredSensor() + s = InfraredSensor() self.assertTrue(s.connected) From 10d8b03184579b3fc4f8d37e75178d1445af426d Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 13:00:04 +0000 Subject: [PATCH 022/172] Fix utils/ imports --- utils/move_motor.py | 2 +- utils/stop_all_motors.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/move_motor.py b/utils/move_motor.py index 9b7360b..26ba891 100755 --- a/utils/move_motor.py +++ b/utils/move_motor.py @@ -5,7 +5,7 @@ where you can"t move the motor by hand. """ -from ev3dev.auto import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, Motor +from ev3dev.motor import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, Motor import argparse import logging import sys diff --git a/utils/stop_all_motors.py b/utils/stop_all_motors.py index 55da422..d1a1338 100755 --- a/utils/stop_all_motors.py +++ b/utils/stop_all_motors.py @@ -3,7 +3,7 @@ """ Stop all motors """ -from ev3dev.auto import list_motors +from ev3dev.motor import list_motors for motor in list_motors(): motor.stop(stop_action='brake') From 9f87c9e95c5f911d055b8c32c74ac3f50d024c1c Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 13:06:13 +0000 Subject: [PATCH 023/172] Move motor constants to top of motor class --- ev3dev/motor.py | 208 ++++++++++++++++++++++++------------------------ 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index dc09161..d2289ae 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -77,6 +77,110 @@ class Motor(Device): SYSTEM_CLASS_NAME = 'tacho-motor' SYSTEM_DEVICE_NAME_CONVENTION = '*' + __slots__ = [ + '_address', + '_command', + '_commands', + '_count_per_rot', + '_count_per_m', + '_driver_name', + '_duty_cycle', + '_duty_cycle_sp', + '_full_travel_count', + '_polarity', + '_position', + '_position_p', + '_position_i', + '_position_d', + '_position_sp', + '_max_speed', + '_speed', + '_speed_sp', + '_ramp_up_sp', + '_ramp_down_sp', + '_speed_p', + '_speed_i', + '_speed_d', + '_state', + '_stop_action', + '_stop_actions', + '_time_sp', + '_poll', + ] + + #: Run the motor until another command is sent. + COMMAND_RUN_FOREVER = 'run-forever' + + #: Run to an absolute position specified by `position_sp` and then + #: stop using the action specified in `stop_action`. + COMMAND_RUN_TO_ABS_POS = 'run-to-abs-pos' + + #: Run to a position relative to the current `position` value. + #: The new position will be current `position` + `position_sp`. + #: When the new position is reached, the motor will stop using + #: the action specified by `stop_action`. + COMMAND_RUN_TO_REL_POS = 'run-to-rel-pos' + + #: Run the motor for the amount of time specified in `time_sp` + #: and then stop the motor using the action specified by `stop_action`. + COMMAND_RUN_TIMED = 'run-timed' + + #: Run the motor at the duty cycle specified by `duty_cycle_sp`. + #: Unlike other run commands, changing `duty_cycle_sp` while running *will* + #: take effect immediately. + COMMAND_RUN_DIRECT = 'run-direct' + + #: Stop any of the run commands before they are complete using the + #: action specified by `stop_action`. + COMMAND_STOP = 'stop' + + #: Reset all of the motor parameter attributes to their default value. + #: This will also have the effect of stopping the motor. + COMMAND_RESET = 'reset' + + #: Sets the normal polarity of the rotary encoder. + ENCODER_POLARITY_NORMAL = 'normal' + + #: Sets the inversed polarity of the rotary encoder. + ENCODER_POLARITY_INVERSED = 'inversed' + + #: With `normal` polarity, a positive duty cycle will + #: cause the motor to rotate clockwise. + POLARITY_NORMAL = 'normal' + + #: With `inversed` polarity, a positive duty cycle will + #: cause the motor to rotate counter-clockwise. + POLARITY_INVERSED = 'inversed' + + #: Power is being sent to the motor. + STATE_RUNNING = 'running' + + #: The motor is ramping up or down and has not yet reached a constant output level. + STATE_RAMPING = 'ramping' + + #: The motor is not turning, but rather attempting to hold a fixed position. + STATE_HOLDING = 'holding' + + #: The motor is turning, but cannot reach its `speed_sp`. + STATE_OVERLOADED = 'overloaded' + + #: The motor is not turning when it should be. + STATE_STALLED = 'stalled' + + #: Power will be removed from the motor and it will freely coast to a stop. + STOP_ACTION_COAST = 'coast' + + #: Power will be removed from the motor and a passive electrical load will + #: be placed on the motor. This is usually done by shorting the motor terminals + #: together. This load will absorb the energy from the rotation of the motors and + #: cause the motor to stop more quickly than coasting. + STOP_ACTION_BRAKE = 'brake' + + #: Does not remove power from the motor. Instead it actively try to hold the motor + #: at the current position. If an external force tries to turn the motor, the motor + #: will `push back` to maintain its position. + STOP_ACTION_HOLD = 'hold' + def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): if address is not None: @@ -112,37 +216,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._time_sp = None self._poll = None - __slots__ = [ - '_address', - '_command', - '_commands', - '_count_per_rot', - '_count_per_m', - '_driver_name', - '_duty_cycle', - '_duty_cycle_sp', - '_full_travel_count', - '_polarity', - '_position', - '_position_p', - '_position_i', - '_position_d', - '_position_sp', - '_max_speed', - '_speed', - '_speed_sp', - '_ramp_up_sp', - '_ramp_down_sp', - '_speed_p', - '_speed_i', - '_speed_d', - '_state', - '_stop_action', - '_stop_actions', - '_time_sp', - '_poll', - ] - @property def address(self): """ @@ -491,79 +564,6 @@ def time_sp(self): def time_sp(self, value): self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) - #: Run the motor until another command is sent. - COMMAND_RUN_FOREVER = 'run-forever' - - #: Run to an absolute position specified by `position_sp` and then - #: stop using the action specified in `stop_action`. - COMMAND_RUN_TO_ABS_POS = 'run-to-abs-pos' - - #: Run to a position relative to the current `position` value. - #: The new position will be current `position` + `position_sp`. - #: When the new position is reached, the motor will stop using - #: the action specified by `stop_action`. - COMMAND_RUN_TO_REL_POS = 'run-to-rel-pos' - - #: Run the motor for the amount of time specified in `time_sp` - #: and then stop the motor using the action specified by `stop_action`. - COMMAND_RUN_TIMED = 'run-timed' - - #: Run the motor at the duty cycle specified by `duty_cycle_sp`. - #: Unlike other run commands, changing `duty_cycle_sp` while running *will* - #: take effect immediately. - COMMAND_RUN_DIRECT = 'run-direct' - - #: Stop any of the run commands before they are complete using the - #: action specified by `stop_action`. - COMMAND_STOP = 'stop' - - #: Reset all of the motor parameter attributes to their default value. - #: This will also have the effect of stopping the motor. - COMMAND_RESET = 'reset' - - #: Sets the normal polarity of the rotary encoder. - ENCODER_POLARITY_NORMAL = 'normal' - - #: Sets the inversed polarity of the rotary encoder. - ENCODER_POLARITY_INVERSED = 'inversed' - - #: With `normal` polarity, a positive duty cycle will - #: cause the motor to rotate clockwise. - POLARITY_NORMAL = 'normal' - - #: With `inversed` polarity, a positive duty cycle will - #: cause the motor to rotate counter-clockwise. - POLARITY_INVERSED = 'inversed' - - #: Power is being sent to the motor. - STATE_RUNNING = 'running' - - #: The motor is ramping up or down and has not yet reached a constant output level. - STATE_RAMPING = 'ramping' - - #: The motor is not turning, but rather attempting to hold a fixed position. - STATE_HOLDING = 'holding' - - #: The motor is turning, but cannot reach its `speed_sp`. - STATE_OVERLOADED = 'overloaded' - - #: The motor is not turning when it should be. - STATE_STALLED = 'stalled' - - #: Power will be removed from the motor and it will freely coast to a stop. - STOP_ACTION_COAST = 'coast' - - #: Power will be removed from the motor and a passive electrical load will - #: be placed on the motor. This is usually done by shorting the motor terminals - #: together. This load will absorb the energy from the rotation of the motors and - #: cause the motor to stop more quickly than coasting. - STOP_ACTION_BRAKE = 'brake' - - #: Does not remove power from the motor. Instead it actively try to hold the motor - #: at the current position. If an external force tries to turn the motor, the motor - #: will `push back` to maintain its position. - STOP_ACTION_HOLD = 'hold' - def run_forever(self, **kwargs): """Run the motor until another command is sent. """ From b29bee1a91f4531edca9bb4a32e986153093cabf Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 13:07:51 +0000 Subject: [PATCH 024/172] Add missing list_device_names imports --- ev3dev/motor.py | 2 +- ev3dev/sensor/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index d2289ae..a69bfde 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -31,7 +31,7 @@ import select import time from os.path import abspath -from ev3dev import get_current_platform, Device +from ev3dev import get_current_platform, Device, list_device_names # The number of milliseconds we wait for the state of a motor to # update to 'running' in the "on_for_XYZ" methods of the Motor class diff --git a/ev3dev/sensor/__init__.py b/ev3dev/sensor/__init__.py index 1253cf1..efb044b 100644 --- a/ev3dev/sensor/__init__.py +++ b/ev3dev/sensor/__init__.py @@ -31,7 +31,7 @@ import numbers from os.path import abspath from struct import unpack -from ev3dev import get_current_platform, Device +from ev3dev import get_current_platform, Device, list_device_names # INPUT ports have platform specific values that we must import From aab7a1cd6b21851ed3aa46e4f1ce00feed22d717 Mon Sep 17 00:00:00 2001 From: Greg Cowell Date: Wed, 4 Oct 2017 23:27:36 +1000 Subject: [PATCH 025/172] Fix steering bug (#394) --- ev3dev/motor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index dc09161..a556e4c 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -1538,9 +1538,9 @@ def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=Tru if left_speed > right_speed: left_rotations = rotations - right_rotations = float(right_speed / left_speed) * rotations + right_rotations = abs(float(right_speed / left_speed)) * rotations else: - left_rotations = float(left_speed / right_speed) * rotations + left_rotations = abs(float(left_speed / right_speed)) * rotations right_rotations = rotations # Set all parameters From 3de7d3435de1af0d2e142185b12553aacc58fc42 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 13:16:27 +0000 Subject: [PATCH 026/172] motor: assert to validate rotations and degrees --- ev3dev/motor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index a69bfde..19169a8 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -726,12 +726,20 @@ def wait_while(self, s, timeout=None): return self.wait(lambda state: s not in state, timeout) def _set_position_rotations(self, speed_pct, rotations): + + # +/- speed is used to control direction, rotations must be positive + assert rotations >= 0, "rotations is %s, must be >= 0" % rotations + if speed_pct > 0: self.position_sp = self.position + int(rotations * self.count_per_rot) else: self.position_sp = self.position - int(rotations * self.count_per_rot) def _set_position_degrees(self, speed_pct, degrees): + + # +/- speed is used to control direction, degrees must be positive + assert degrees >= 0, "degrees is %s, must be >= 0" % degrees + if speed_pct > 0: self.position_sp = self.position + int((degrees * self.count_per_rot)/360) else: From e42f4c0eb4b4055ba579c172956c72b5eb758c60 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 14:04:23 +0000 Subject: [PATCH 027/172] Support for 'fake' platform type --- ev3dev/__init__.py | 6 +++++- ev3dev/_platform/fake.py | 17 +++++++++++++++++ ev3dev/button.py | 3 +++ ev3dev/led.py | 3 +++ ev3dev/motor.py | 3 +++ ev3dev/sensor/__init__.py | 3 +++ 6 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 ev3dev/_platform/fake.py diff --git a/ev3dev/__init__.py b/ev3dev/__init__.py index 5b992f3..69e488f 100644 --- a/ev3dev/__init__.py +++ b/ev3dev/__init__.py @@ -44,7 +44,7 @@ def get_current_platform(): """ Look in /sys/class/board-info/ to determine the platform type. - This can return 'ev3', 'evb', 'pistorms', 'brickpi' or 'brickpi3'. + This can return 'ev3', 'evb', 'pistorms', 'brickpi', 'brickpi3' or 'fake'. """ board_info_dir = '/sys/class/board-info/' @@ -74,6 +74,10 @@ def get_current_platform(): elif value == 'Dexter Industries BrickPi3': return 'brickpi3' + + elif value == 'FAKE-SYS': + return 'fake' + return None diff --git a/ev3dev/_platform/fake.py b/ev3dev/_platform/fake.py new file mode 100644 index 0000000..2780a55 --- /dev/null +++ b/ev3dev/_platform/fake.py @@ -0,0 +1,17 @@ + +OUTPUT_A = 'outA' +OUTPUT_B = 'outB' +OUTPUT_C = 'outC' +OUTPUT_D = 'outD' + +INPUT_1 = 'in1' +INPUT_2 = 'in2' +INPUT_3 = 'in3' +INPUT_4 = 'in4' + +BUTTONS_FILENAME = None +EVDEV_DEVICE_NAME = None + +LEDS = {} +LED_GROUPS = {} +LED_COLORS = {} diff --git a/ev3dev/button.py b/ev3dev/button.py index 45fb1fc..892365e 100644 --- a/ev3dev/button.py +++ b/ev3dev/button.py @@ -60,6 +60,9 @@ elif platform == 'brickpi3': from ev3dev._platform.brickpi3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME +elif platform == 'fake': + from ev3dev._platform.fake import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + else: raise Exception("Unsupported platform '%s'" % platform) diff --git a/ev3dev/led.py b/ev3dev/led.py index d7cc39a..8d9b108 100644 --- a/ev3dev/led.py +++ b/ev3dev/led.py @@ -52,6 +52,9 @@ elif platform == 'brickpi3': from ev3dev._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS +elif platform == 'fake': + from ev3dev._platform.fake import LEDS, LED_GROUPS, LED_COLORS + else: raise Exception("Unsupported platform '%s'" % platform) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index 19169a8..541d96b 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -56,6 +56,9 @@ elif platform == 'brickpi3': from ev3dev._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D +elif platform == 'fake': + from ev3dev._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + else: raise Exception("Unsupported platform '%s'" % platform) diff --git a/ev3dev/sensor/__init__.py b/ev3dev/sensor/__init__.py index efb044b..dc224fe 100644 --- a/ev3dev/sensor/__init__.py +++ b/ev3dev/sensor/__init__.py @@ -52,6 +52,9 @@ elif platform == 'brickpi3': from ev3dev._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 +elif platform == 'fake': + from ev3dev._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + else: raise Exception("Unsupported platform '%s'" % platform) From dc91c1c4ec3878bc1ec779e1a32b0cd3ef643de6 Mon Sep 17 00:00:00 2001 From: Greg Cowell Date: Wed, 4 Oct 2017 23:27:36 +1000 Subject: [PATCH 028/172] Fix steering bug (#394) --- ev3dev/motor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index 541d96b..91e1013 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -1549,9 +1549,9 @@ def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=Tru if left_speed > right_speed: left_rotations = rotations - right_rotations = float(right_speed / left_speed) * rotations + right_rotations = abs(float(right_speed / left_speed)) * rotations else: - left_rotations = float(left_speed / right_speed) * rotations + left_rotations = abs(float(left_speed / right_speed)) * rotations right_rotations = rotations # Set all parameters From aeb015516a22715349a1473673fc02d6d22a55eb Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 4 Oct 2017 21:05:29 -0400 Subject: [PATCH 029/172] Switch fake-sys submodule from rhempel to ddemidov (#397) * Switch fake-sys submodule from rhempel to ddemidov * Updated to latest fake-sys * Fix board_info_dir for FAKE_SYS --- .gitmodules | 2 +- ev3dev/__init__.py | 3 +++ ev3dev/sensor/lego.py | 2 +- tests/fake-sys | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 87dee32..5af5621 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "tests/fake-sys"] path = tests/fake-sys - url = https://github.com/rhempel/ev3dev-lang-fake-sys.git + url = https://github.com/ddemidov/ev3dev-lang-fake-sys.git diff --git a/ev3dev/__init__.py b/ev3dev/__init__.py index 69e488f..4a2eec2 100644 --- a/ev3dev/__init__.py +++ b/ev3dev/__init__.py @@ -48,6 +48,9 @@ def get_current_platform(): """ board_info_dir = '/sys/class/board-info/' + if not os.path.exists(board_info_dir): + return 'fake' + for board in os.listdir(board_info_dir): uevent_filename = os.path.join(board_info_dir, board, 'uevent') diff --git a/ev3dev/sensor/lego.py b/ev3dev/sensor/lego.py index 201da1e..50d5143 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev/sensor/lego.py @@ -29,7 +29,7 @@ raise SystemError('Must be using Python 3.4 or higher') import time -from ev3dev._button import ButtonBase +from ev3dev.button import ButtonBase from ev3dev.sensor import Sensor diff --git a/tests/fake-sys b/tests/fake-sys index c0e2292..61d8ded 160000 --- a/tests/fake-sys +++ b/tests/fake-sys @@ -1 +1 @@ -Subproject commit c0e2292eb2f39bd22e741193259ce12eb308a824 +Subproject commit 61d8dedecd55614331aa0e6c1d6f180c4a4d949c From ef23296365c7a4f2c06c6926ae9bcd3fc6dd4f87 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 8 Oct 2017 09:05:23 -0400 Subject: [PATCH 030/172] EV3-G API Sound (#396) Resolves issue #358 --- ev3dev/sound.py | 134 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 92 insertions(+), 42 deletions(-) diff --git a/ev3dev/sound.py b/ev3dev/sound.py index 8bea72f..1dc75c8 100644 --- a/ev3dev/sound.py +++ b/ev3dev/sound.py @@ -28,10 +28,10 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') -import io +import os import re import shlex -from subprocess import Popen, check_output, PIPE +from subprocess import check_output, Popen def _make_scales(notes): @@ -44,11 +44,9 @@ def _make_scales(notes): return res -class Sound: +class Sound(object): """ - Sound-related functions. The class has only static methods and is not - intended for instantiation. It can beep, play wav files, or convert text to - speech. + Support beep, play wav files, or convert text to speech. Note that all methods of the class spawn system processes and return subprocess.Popen objects. The methods are asynchronous (they return @@ -75,8 +73,21 @@ class Sound: channel = None - @staticmethod - def beep(args=''): + # play_types + PLAY_WAIT_FOR_COMPLETE = 0 + PLAY_NO_WAIT_FOR_COMPLETE = 1 + PLAY_LOOP = 2 + + PLAY_TYPES = ( + PLAY_WAIT_FOR_COMPLETE, + PLAY_NO_WAIT_FOR_COMPLETE, + PLAY_LOOP + ) + + def _validate_play_type(self, play_type): + assert play_type in self.PLAY_TYPES, "Invalid play_type %s, must be one of %s" % (play_type, ','.join(self.PLAY_TYPES)) + + def beep(self, args=''): """ Call beep command with the provided arguments (if any). See `beep man page`_ and google `linux beep music`_ for inspiration. @@ -87,8 +98,7 @@ def beep(args=''): with open(os.devnull, 'w') as n: return Popen(shlex.split('/usr/bin/beep %s' % args), stdout=n) - @staticmethod - def tone(*args): + def tone(self, *args): """ .. rubric:: tone(tone_sequence) @@ -134,7 +144,7 @@ def beep_args(frequency=None, duration=None, delay=None): return args - return Sound.beep(' -n '.join([beep_args(*t) for t in tone_sequence])) + return self.beep(' -n '.join([beep_args(*t) for t in tone_sequence])) if len(args) == 1: return play_tone_sequence(args[0]) @@ -143,32 +153,75 @@ def beep_args(frequency=None, duration=None, delay=None): else: raise Exception("Unsupported number of parameters in Sound.tone()") - @staticmethod - def play(wav_file): + def play_tone(self, frequency, duration_ms, delay_ms=100, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): + self._validate_play_type(play_type) + self.set_volume(volume) + + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: + play = self.tone([(frequency, duration_ms, delay_ms)]) + play.wait() + + elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: + return self.tone([(frequency, duration_ms, delay_ms)]) + + elif play_type == Sound.PLAY_LOOP: + while True: + play = self.tone([(frequency, duration_ms, delay_ms)]) + play.wait() + + def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): """ Play wav file. """ + self._validate_play_type(play_type) + with open(os.devnull, 'w') as n: - return Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) - @staticmethod - def speak(text, espeak_opts='-a 200 -s 130'): + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: + pid = Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) + pid.wait() + + # Do not wait, run in the background + elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: + return Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) + + elif play_type == Sound.PLAY_LOOP: + while True: + pid = Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) + pid.wait() + + def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): + self.set_volume(volume) + self.play(wav_file, play_type) + + def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Speak the given text aloud. """ + self._validate_play_type(play_type) + self.set_volume(volume) + with open(os.devnull, 'w') as n: - cmd_line = '/usr/bin/espeak --stdout {0} "{1}"'.format(espeak_opts, text) - espeak = Popen(shlex.split(cmd_line), stdout=PIPE) - play = Popen(['/usr/bin/aplay', '-q'], stdin=espeak.stdout, stdout=n) - return espeak + cmd_line = '/usr/bin/espeak --stdout {0} "{1}" | /usr/bin/aplay -q'.format(espeak_opts, text) + + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: + play = Popen(cmd_line, stdout=n, shell=True) + play.wait() + + elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: + return Popen(cmd_line, stdout=n, shell=True) + + elif play_type == Sound.PLAY_LOOP: + while True: + play = Popen(cmd_line, stdout=n, shell=True) + play.wait() - @staticmethod - def _get_channel(): + def _get_channel(self): """ :return: the detected sound channel :rtype: str """ - if Sound.channel is None: + if self.channel is None: # Get default channel as the first one that pops up in # 'amixer scontrols' output, which contains strings in the # following format: @@ -178,14 +231,13 @@ def _get_channel(): out = check_output(['amixer', 'scontrols']).decode() m = re.search("'(?P[^']+)'", out) if m: - Sound.channel = m.group('channel') + self.channel = m.group('channel') else: - Sound.channel = 'Playback' + self.channel = 'Playback' - return Sound.channel + return self.channel - @staticmethod - def set_volume(pct, channel=None): + def set_volume(self, pct, channel=None): """ Sets the sound volume to the given percentage [0-100] by calling ``amixer -q set %``. @@ -195,13 +247,12 @@ def set_volume(pct, channel=None): """ if channel is None: - channel = Sound._get_channel() + channel = self._get_channel() cmd_line = '/usr/bin/amixer -q set {0} {1:d}%'.format(channel, pct) Popen(shlex.split(cmd_line)).wait() - @staticmethod - def get_volume(channel=None): + def get_volume(self, channel=None): """ Gets the current sound volume by parsing the output of ``amixer get ``. @@ -211,7 +262,7 @@ def get_volume(channel=None): """ if channel is None: - channel = Sound._get_channel() + channel = self._get_channel() out = check_output(['amixer', 'get', channel]).decode() m = re.search('\[(?P\d+)%\]', out) @@ -220,8 +271,7 @@ def get_volume(channel=None): else: raise Exception('Failed to parse output of `amixer get {}`'.format(channel)) - @classmethod - def play_song(cls, song, tempo=120, delay=50): + def play_song(self, song, tempo=120, delay=50): """ Plays a song provided as a list of tuples containing the note name and its value using music conventional notation instead of numerical values for frequency and duration. @@ -296,26 +346,26 @@ def beep_args(note, value): Returns: str: the arguments to be passed to the beep command """ - freq = Sound._NOTE_FREQUENCIES[note.upper()] + freq = self._NOTE_FREQUENCIES[note.upper()] if '/' in value: base, factor = value.split('/') - duration = meas_duration * Sound._NOTE_VALUES[base] / float(factor) + duration = meas_duration * self._NOTE_VALUES[base] / float(factor) elif '*' in value: base, factor = value.split('*') - duration = meas_duration * Sound._NOTE_VALUES[base] * float(factor) + duration = meas_duration * self._NOTE_VALUES[base] * float(factor) elif value.endswith('.'): base = value[:-1] - duration = meas_duration * Sound._NOTE_VALUES[base] * 1.5 + duration = meas_duration * self._NOTE_VALUES[base] * 1.5 elif value.endswith('3'): base = value[:-1] - duration = meas_duration * Sound._NOTE_VALUES[base] * 2 / 3 + duration = meas_duration * self._NOTE_VALUES[base] * 2 / 3 else: - duration = meas_duration * Sound._NOTE_VALUES[value] + duration = meas_duration * self._NOTE_VALUES[value] return '-f %d -l %d -D %d' % (freq, duration, delay) - return Sound.beep(' -n '.join( - [beep_args(note, value) for note, value in song] + return self.beep(' -n '.join( + [beep_args(note, value) for (note, value) in song] )) #: Note frequencies. From 6bd672135c7e273a5e36fb582b996d28e440d4f7 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 8 Oct 2017 07:25:47 -0700 Subject: [PATCH 031/172] Enable using markdown for Sphinx docs (#401) --- docs/conf.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 2153039..77efd5c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,8 +25,10 @@ if on_rtd: import pip pip.main(['install', 'sphinx_bootstrap_theme']) + pip.main(['install', 'recommonmark']) import sphinx_bootstrap_theme +from recommonmark.parser import CommonMarkParser # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -49,10 +51,14 @@ # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] +source_parsers = { + '.md': CommonMarkParser, +} + # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ['.rst', '.md'] # The encoding of source files. #source_encoding = 'utf-8-sig' From 96f77b441ef7017ae2b227a8fddef88b30e32256 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 10 Oct 2017 08:03:52 -0400 Subject: [PATCH 032/172] Added MoveJoystick (#398) * Added MoveJoystick * remove some dead code * Clean up angle comparisons --- ev3dev/control/rc_tank.py | 5 +- ev3dev/control/webserver.py | 317 +----------------------------------- ev3dev/motor.py | 207 ++++++++++++++++++++++- 3 files changed, 210 insertions(+), 319 deletions(-) diff --git a/ev3dev/control/rc_tank.py b/ev3dev/control/rc_tank.py index 7f6fec6..d3a7ee5 100644 --- a/ev3dev/control/rc_tank.py +++ b/ev3dev/control/rc_tank.py @@ -1,6 +1,7 @@ import logging -from ev3dev.auto import InfraredSensor, MoveTank +from ev3dev.motor import MoveTank +from ev3dev.sensor.lego import InfraredSensor from time import sleep log = logging.getLogger(__name__) @@ -36,7 +37,7 @@ def main(self): try: while True: - self.remote.process(self.channel) + self.remote.process() sleep(0.01) # Exit cleanly so that all motors are stopped diff --git a/ev3dev/control/webserver.py b/ev3dev/control/webserver.py index f421981..bbfcce3 100644 --- a/ev3dev/control/webserver.py +++ b/ev3dev/control/webserver.py @@ -1,305 +1,14 @@ #!/usr/bin/env python3 import logging -import math import os import re -import sys -import time -import ev3dev.auto -from ev3dev.helper import LargeMotorPair, list_motors +from ev3dev.motor import MoveJoystick, list_motors, LargeMotor from http.server import BaseHTTPRequestHandler, HTTPServer -from time import sleep log = logging.getLogger(__name__) -# =============================================== -# "Joystick" code for the web interface for tanks -# =============================================== -def angle_to_speed_percentage(angle): - """ - (1, 1) - . . . . . . . - . | . - . | . - (0, 1) . | . (1, 0) - . | . - . | . - . | . - . | . - . | . - . | x-axis . - (-1, 1) .---------------------------------------. (1, -1) - . | . - . | . - . | . - . | y-axis . - . | . - (0, -1) . | . (-1, 0) - . | . - . | . - . . . . . . . - (-1, -1) - - - The joystick is a circle within a circle where the (x, y) coordinates - of the joystick form an angle with the x-axis. Our job is to translate - this angle into the percentage of power that should be sent to each motor. - For instance if the joystick is moved all the way to the top of the circle - we want both motors to move forward with 100% power...that is represented - above by (1, 1). If the joystick is moved all the way to the right side of - the circle we want to rotate clockwise so we move the left motor forward 100% - and the right motor backwards 100%...so (1, -1). If the joystick is at - 45 degrees then we move apply (1, 0) to move the left motor forward 100% and - the right motor stays still. - - The 8 points shown above are pretty easy. For the points in between those 8 - we do some math to figure out what the percentages should be. Take 11.25 degrees - for example. We look at how the motors transition from 0 degrees to 45 degrees: - - the left motor is 1 so that is easy - - the right motor moves from -1 to 0 - - We determine how far we are between 0 and 45 degrees (11.25 is 25% of 45) so we - know that the right motor should be 25% of the way from -1 to 0...so -0.75 is the - percentage for the right motor at 11.25 degrees. - """ - - if angle >= 0 and angle <= 45: - - # left motor stays at 1 - left_speed_percentage = 1 - - # right motor transitions from -1 to 0 - right_speed_percentage = -1 + (angle/45.0) - - elif angle > 45 and angle <= 90: - - # left motor stays at 1 - left_speed_percentage = 1 - - # right motor transitions from 0 to 1 - percentage_from_45_to_90 = (angle - 45) / 45.0 - right_speed_percentage = percentage_from_45_to_90 - - elif angle > 90 and angle <= 135: - - # left motor transitions from 1 to 0 - percentage_from_90_to_135 = (angle - 90) / 45.0 - left_speed_percentage = 1 - percentage_from_90_to_135 - - # right motor stays at 1 - right_speed_percentage = 1 - - elif angle > 135 and angle <= 180: - - # left motor transitions from 0 to -1 - percentage_from_135_to_180 = (angle - 135) / 45.0 - left_speed_percentage = -1 * percentage_from_135_to_180 - - # right motor stays at 1 - right_speed_percentage = 1 - - elif angle > 180 and angle <= 225: - - # left motor transitions from -1 to 0 - percentage_from_180_to_225 = (angle - 180) / 45.0 - left_speed_percentage = -1 + percentage_from_180_to_225 - - # right motor transitions from 1 to -1 - # right motor transitions from 1 to 0 between 180 and 202.5 - if angle < 202.5: - percentage_from_180_to_202 = (angle - 180) / 22.5 - right_speed_percentage = 1 - percentage_from_180_to_202 - - # right motor is 0 at 202.5 - elif angle == 202.5: - right_speed_percentage = 0 - - # right motor transitions from 0 to -1 between 202.5 and 225 - else: - percentage_from_202_to_225 = (angle - 202.5) / 22.5 - right_speed_percentage = -1 * percentage_from_202_to_225 - - elif angle > 225 and angle <= 270: - - # left motor transitions from 0 to -1 - percentage_from_225_to_270 = (angle - 225) / 45.0 - left_speed_percentage = -1 * percentage_from_225_to_270 - - # right motor stays at -1 - right_speed_percentage = -1 - - elif angle > 270 and angle <= 315: - - # left motor stays at -1 - left_speed_percentage = -1 - - # right motor transitions from -1 to 0 - percentage_from_270_to_315 = (angle - 270) / 45.0 - right_speed_percentage = -1 + percentage_from_270_to_315 - - elif angle > 315 and angle <= 360: - - # left motor transitions from -1 to 1 - # left motor transitions from -1 to 0 between 315 and 337.5 - if angle < 337.5: - percentage_from_315_to_337 = (angle - 315) / 22.5 - left_speed_percentage = (1 - percentage_from_315_to_337) * -1 - - # left motor is 0 at 337.5 - elif angle == 337.5: - left_speed_percentage = 0 - - # left motor transitions from 0 to 1 between 337.5 and 360 - elif angle > 337.5: - percentage_from_337_to_360 = (angle - 337.5) / 22.5 - left_speed_percentage = percentage_from_337_to_360 - - # right motor transitions from 0 to -1 - percentage_from_315_to_360 = (angle - 315) / 45.0 - right_speed_percentage = -1 * percentage_from_315_to_360 - - else: - raise Exception('You created a circle with more than 360 degrees (%s)...that is quite the trick' % angle) - - return (left_speed_percentage, right_speed_percentage) - - -def xy_to_speed(x, y, max_speed, radius=100.0): - """ - Convert x,y joystick coordinates to left/right motor speed - """ - - vector_length = math.hypot(x, y) - angle = math.degrees(math.atan2(y, x)) - - if angle < 0: - angle += 360 - - # Should not happen but can happen (just by a hair) due to floating point math - if vector_length > radius: - vector_length = radius - - # print "radius : %s" % radius - # print "angle : %s" % angle - # print "vector length : %s" % vector_length - - (left_speed_percentage, right_speed_percentage) = angle_to_speed_percentage(angle) - # print "init left_speed_percentage: %s" % left_speed_percentage - # print "init right_speed_percentage: %s" % right_speed_percentage - - # scale the speed percentages based on vector_length vs. radius - left_speed_percentage = (left_speed_percentage * vector_length) / radius - right_speed_percentage = (right_speed_percentage * vector_length) / radius - # print "final left_speed_percentage: %s" % left_speed_percentage - # print "final right_speed_percentage: %s" % right_speed_percentage - - # calculate the motor speeds based on speed percentages and max_speed of the motors - left_speed = round(left_speed_percentage * max_speed) - right_speed = round(right_speed_percentage * max_speed) - - # safety net - if left_speed > max_speed: - left_speed = max_speed - - if right_speed > max_speed: - right_speed = max_speed - - return (left_speed, right_speed) - - -def test_xy_to_speed(): - """ - Used to test changes to xy_to_speed() and angle_to_speed_percentage() - """ - - # Move straight forward - assert xy_to_speed(0, 100, 400) == (400, 400), "FAILED" - - # Spin clockwise - assert xy_to_speed(100, 0, 400) == (400, -400), "FAILED" - - # Spin counter clockwise - assert xy_to_speed(-100, 0, 400) == (-400, 400), "FAILED" - - # Move straight back - assert xy_to_speed(0, -100, 400) == (-400, -400), "FAILED" - - # Test vector length to power percentages - # Move straight forward, 1/2 power - assert xy_to_speed(0, 50, 400) == (200, 200), "FAILED" - - # Test motor max_speed - # Move straight forward, 1/2 power with lower max_speed - assert xy_to_speed(0, 50, 200) == (100, 100), "FAILED" - - # http://www.pagetutor.com/trigcalc/trig.html - - # top right quadrant - # ================== - # 0 -> 45 degrees - assert xy_to_speed(98.07852804032305, 19.509032201612825, 400) == (400, -300), "FAILED" # 11.25 degrees - assert xy_to_speed(92.38795325112868, 38.26834323650898, 400) == (400, -200), "FAILED" # 22.5 degrees - assert xy_to_speed(83.14696123025452, 55.557023301960214, 400) == (400, -100), "FAILED" # 33.75 degrees - - # 45 degrees, only left motor should turn - assert xy_to_speed(70.71068, 70.71068, 400) == (400, 0), "FAILED" - - # 45 -> 90 degrees - assert xy_to_speed(55.55702330196023, 83.14696123025452, 400) == (400, 100), "FAILED" # 56.25 degrees - assert xy_to_speed(38.26834323650898, 92.38795325112868, 400) == (400, 200), "FAILED" # 67.5 degrees - assert xy_to_speed(19.509032201612833, 98.07852804032305, 400) == (400, 300), "FAILED" # 78.75 degrees - - - # top left quadrant - # ================= - # 90 -> 135 degrees - assert xy_to_speed(-19.509032201612833, 98.07852804032305, 400) == (300, 400), "FAILED" - assert xy_to_speed(-38.26834323650898, 92.38795325112868, 400) == (200, 400), "FAILED" - assert xy_to_speed(-55.55702330196023, 83.14696123025452, 400) == (100, 400), "FAILED" - - # 135 degrees, only right motor should turn - assert xy_to_speed(-70.71068, 70.71068, 400) == (0, 400), "FAILED" - - # 135 -> 180 degrees - assert xy_to_speed(-83.14696123025452, 55.55702330196023, 400) == (-100, 400), "FAILED" - assert xy_to_speed(-92.38795325112868, 38.26834323650898, 400) == (-200, 400), "FAILED" - assert xy_to_speed(-98.07852804032305, 19.509032201612833, 400) == (-300, 400), "FAILED" - - - # bottom left quadrant - # ==================== - # 180 -> 225 degrees - assert xy_to_speed(-98.07852804032305, -19.509032201612833, 400) == (-300, 200), "FAILED" - assert xy_to_speed(-92.38795325112868, -38.26834323650898, 400) == (-200, 0), "FAILED" - assert xy_to_speed(-83.14696123025452, -55.55702330196023, 400) == (-100, -200), "FAILED" - - # 225 degrees, only right motor should turn (backwards) - assert xy_to_speed(-70.71068, -70.71068, 400) == (0, -400), "FAILED" - - # 225 -> 270 degrees - assert xy_to_speed(-55.55702330196023, -83.14696123025452, 400) == (-100, -400), "FAILED" - assert xy_to_speed(-38.26834323650898, -92.38795325112868, 400) == (-200, -400), "FAILED" - assert xy_to_speed(-19.509032201612833, -98.07852804032305, 400) == (-300, -400), "FAILED" - - - # bottom right quadrant - # ===================== - # 270 -> 315 degrees - assert xy_to_speed(19.509032201612833, -98.07852804032305, 400) == (-400, -300), "FAILED" - assert xy_to_speed(38.26834323650898, -92.38795325112868, 400) == (-400, -200), "FAILED" - assert xy_to_speed(55.55702330196023, -83.14696123025452, 400) == (-400, -100), "FAILED" - - # 315 degrees, only left motor should turn (backwards) - assert xy_to_speed(70.71068, -70.71068, 400) == (-400, 0), "FAILED" - - # 315 -> 360 degrees - assert xy_to_speed(83.14696123025452, -55.557023301960214, 400) == (-200, -100), "FAILED" - assert xy_to_speed(92.38795325112868, -38.26834323650898, 400) == (0, -200), "FAILED" - assert xy_to_speed(98.07852804032305, -19.509032201612825, 400) == (200, -300), "FAILED" - - # ================== # Web Server classes # ================== @@ -372,7 +81,7 @@ def log_message(self, format, *args): max_move_xy_seq = 0 motor_max_speed = None medium_motor_max_speed = None -joystick_enaged = False +joystick_engaged = False class TankWebHandler(RobotWebHandler): @@ -426,8 +135,6 @@ def do_GET(self): We can also ignore any move-xy requests that show up late by tracking the max seq for any move-xy we service. ''' - # dwalton - fix this - path = self.path.split('/') seq = int(path[1]) action = path[2] @@ -502,19 +209,9 @@ def do_GET(self): if joystick_engaged: if seq > max_move_xy_seq: - (left_speed, right_speed) = xy_to_speed(x, y, motor_max_speed) - log.debug("seq %d: (x, y) %4d, %4d -> speed %d %d" % (seq, x, y, left_speed, right_speed)) + self.robot.on(x, y, motor_max_speed) max_move_xy_seq = seq - - if left_speed == 0: - self.robot.left_motor.stop() - else: - self.robot.left_motor.run_forever(speed_sp=left_speed) - - if right_speed == 0: - self.robot.right_motor.stop() - else: - self.robot.right_motor.run_forever(speed_sp=right_speed) + log.debug("seq %d: (x, y) (%4d, %4d)" % (seq, x, y)) else: log.debug("seq %d: (x, y) %4d, %4d (ignore, max seq %d)" % (seq, x, y, max_move_xy_seq)) @@ -576,13 +273,13 @@ def run(self): motor.stop() -class WebControlledTank(LargeMotorPair): +class WebControlledTank(MoveJoystick): """ A tank that is controlled via a web browser """ - def __init__(self, left_motor, right_motor, polarity='normal', port_number=8000): - LargeMotorPair.__init__(self, left_motor, right_motor, polarity) + def __init__(self, left_motor, right_motor, port_number=8000, desc=None, motor_class=LargeMotor): + MoveJoystick.__init__(self, left_motor, right_motor, desc, motor_class) self.www = RobotWebServer(self, TankWebHandler, port_number) def main(self): diff --git a/ev3dev/motor.py b/ev3dev/motor.py index 91e1013..612fc4d 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -30,9 +30,13 @@ import select import time +from logging import getLogger +from math import atan2, degrees as math_degrees, hypot from os.path import abspath from ev3dev import get_current_platform, Device, list_device_names +log = getLogger(__name__) + # The number of milliseconds we wait for the state of a motor to # update to 'running' in the "on_for_XYZ" methods of the Motor class WAIT_RUNNING_TIMEOUT = 100 @@ -1376,7 +1380,6 @@ def __init__(self, motor_specs, desc=None): self.motors[motor_port] = motor_class(motor_port) self.desc = desc - self.verify_connected() def __str__(self): @@ -1385,12 +1388,6 @@ def __str__(self): else: return self.__class__.__name__ - def verify_connected(self): - for motor in self.motors.values(): - if not motor.connected: - print("%s: %s is not connected" % (self, motor)) - sys.exit(1) - def set_args(self, **kwargs): motors = kwargs.get('motors', self.motors.values()) @@ -1695,3 +1692,199 @@ def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): def on(self, steering, speed_pct): (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) MoveTank.on(self, left_speed_pct, right_speed_pct) + + +class MoveJoystick(MoveTank): + """ + Used to control a pair of motors via a joystick + """ + + def angle_to_speed_percentage(self, angle): + """ + (1, 1) + . . . . . . . + . | . + . | . + (0, 1) . | . (1, 0) + . | . + . | . + . | . + . | . + . | . + . | x-axis . + (-1, 1) .---------------------------------------. (1, -1) + . | . + . | . + . | . + . | y-axis . + . | . + (0, -1) . | . (-1, 0) + . | . + . | . + . . . . . . . + (-1, -1) + + + The joystick is a circle within a circle where the (x, y) coordinates + of the joystick form an angle with the x-axis. Our job is to translate + this angle into the percentage of power that should be sent to each motor. + For instance if the joystick is moved all the way to the top of the circle + we want both motors to move forward with 100% power...that is represented + above by (1, 1). If the joystick is moved all the way to the right side of + the circle we want to rotate clockwise so we move the left motor forward 100% + and the right motor backwards 100%...so (1, -1). If the joystick is at + 45 degrees then we move apply (1, 0) to move the left motor forward 100% and + the right motor stays still. + + The 8 points shown above are pretty easy. For the points in between those 8 + we do some math to figure out what the percentages should be. Take 11.25 degrees + for example. We look at how the motors transition from 0 degrees to 45 degrees: + - the left motor is 1 so that is easy + - the right motor moves from -1 to 0 + + We determine how far we are between 0 and 45 degrees (11.25 is 25% of 45) so we + know that the right motor should be 25% of the way from -1 to 0...so -0.75 is the + percentage for the right motor at 11.25 degrees. + """ + + if 0 <= angle <= 45: + + # left motor stays at 1 + left_speed_percentage = 1 + + # right motor transitions from -1 to 0 + right_speed_percentage = -1 + (angle/45.0) + + elif 45 < angle <= 90: + + # left motor stays at 1 + left_speed_percentage = 1 + + # right motor transitions from 0 to 1 + percentage_from_45_to_90 = (angle - 45) / 45.0 + right_speed_percentage = percentage_from_45_to_90 + + elif 90 < angle <= 135: + + # left motor transitions from 1 to 0 + percentage_from_90_to_135 = (angle - 90) / 45.0 + left_speed_percentage = 1 - percentage_from_90_to_135 + + # right motor stays at 1 + right_speed_percentage = 1 + + elif 135 < angle <= 180: + + # left motor transitions from 0 to -1 + percentage_from_135_to_180 = (angle - 135) / 45.0 + left_speed_percentage = -1 * percentage_from_135_to_180 + + # right motor stays at 1 + right_speed_percentage = 1 + + elif 180 < angle <= 225: + + # left motor transitions from -1 to 0 + percentage_from_180_to_225 = (angle - 180) / 45.0 + left_speed_percentage = -1 + percentage_from_180_to_225 + + # right motor transitions from 1 to -1 + # right motor transitions from 1 to 0 between 180 and 202.5 + if angle < 202.5: + percentage_from_180_to_202 = (angle - 180) / 22.5 + right_speed_percentage = 1 - percentage_from_180_to_202 + + # right motor is 0 at 202.5 + elif angle == 202.5: + right_speed_percentage = 0 + + # right motor transitions from 0 to -1 between 202.5 and 225 + else: + percentage_from_202_to_225 = (angle - 202.5) / 22.5 + right_speed_percentage = -1 * percentage_from_202_to_225 + + elif 225 < angle <= 270: + + # left motor transitions from 0 to -1 + percentage_from_225_to_270 = (angle - 225) / 45.0 + left_speed_percentage = -1 * percentage_from_225_to_270 + + # right motor stays at -1 + right_speed_percentage = -1 + + elif 270 < angle <= 315: + + # left motor stays at -1 + left_speed_percentage = -1 + + # right motor transitions from -1 to 0 + percentage_from_270_to_315 = (angle - 270) / 45.0 + right_speed_percentage = -1 + percentage_from_270_to_315 + + elif 315 < angle <= 360: + + # left motor transitions from -1 to 1 + # left motor transitions from -1 to 0 between 315 and 337.5 + if angle < 337.5: + percentage_from_315_to_337 = (angle - 315) / 22.5 + left_speed_percentage = (1 - percentage_from_315_to_337) * -1 + + # left motor is 0 at 337.5 + elif angle == 337.5: + left_speed_percentage = 0 + + # left motor transitions from 0 to 1 between 337.5 and 360 + elif angle > 337.5: + percentage_from_337_to_360 = (angle - 337.5) / 22.5 + left_speed_percentage = percentage_from_337_to_360 + + # right motor transitions from 0 to -1 + percentage_from_315_to_360 = (angle - 315) / 45.0 + right_speed_percentage = -1 * percentage_from_315_to_360 + + else: + raise Exception('You created a circle with more than 360 degrees (%s)...that is quite the trick' % angle) + + return (left_speed_percentage * 100, right_speed_percentage * 100) + + def on(self, x, y, max_speed, radius=100.0): + """ + Convert x,y joystick coordinates to left/right motor speed percentages + and move the motors + """ + + # If joystick is in the middle stop the tank + if not x and not y: + MoveTank.off() + return + + vector_length = hypot(x, y) + angle = math_degrees(atan2(y, x)) + + if angle < 0: + angle += 360 + + # Should not happen but can happen (just by a hair) due to floating point math + if vector_length > radius: + vector_length = radius + + (init_left_speed_percentage, init_right_speed_percentage) = self.angle_to_speed_percentage(angle) + + # scale the speed percentages based on vector_length vs. radius + left_speed_percentage = (init_left_speed_percentage * vector_length) / radius + right_speed_percentage = (init_right_speed_percentage * vector_length) / radius + + log.debug(""" + x, y : %s, %s + radius : %s + angle : %s + vector length : %s + init left_speed_percentage : %s + init right_speed_percentage : %s + final left_speed_percentage : %s + final right_speed_percentage : %s + """ % (x, y, radius, angle, vector_length, + init_left_speed_percentage, init_right_speed_percentage, + left_speed_percentage, right_speed_percentage)) + + MoveTank.on(self, left_speed_percentage, right_speed_percentage) From d505bb855ba632e008512737aaeb31c60807ae34 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 10 Oct 2017 21:54:54 -0400 Subject: [PATCH 033/172] Add on_to_position(), on_for_XYZ accept additional speed inputs (#407) * return early for 'motor will not move' scenarios * Add on_to_position(), on_for_XYZ accept additional speed inputs * Add SpeedInteger classes * Calc speed percentages in SpeedInteger classes * Update fake-sys * assert SpeedInteger conversions are valid --- ev3dev/motor.py | 177 ++++++++++++++++++++++++++++++++++++++++++++---- tests/fake-sys | 2 +- 2 files changed, 165 insertions(+), 14 deletions(-) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index 612fc4d..288d97f 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -67,6 +67,74 @@ raise Exception("Unsupported platform '%s'" % platform) +class SpeedInteger(int): + pass + + +class SpeedRPS(SpeedInteger): + """ + Speed in rotations-per-second + """ + + def __str__(self): + return ("%d rps" % self) + + def get_speed_pct(self, motor): + """ + Return the motor speed percentage to achieve desired rotations-per-second + """ + assert self <= motor.max_rps, "%s max RPS is %s, %s was requested" % (motor, motor.max_rps, self) + return (self/motor.max_rps) * 100 + + +class SpeedRPM(SpeedInteger): + """ + Speed in rotations-per-minute + """ + + def __str__(self): + return ("%d rpm" % self) + + def get_speed_pct(self, motor): + """ + Return the motor speed percentage to achieve desired rotations-per-minute + """ + assert self <= motor.max_rpm, "%s max RPM is %s, %s was requested" % (motor, motor.max_rpm, self) + return (self/motor.max_rpm) * 100 + + +class SpeedDPS(SpeedInteger): + """ + Speed in degrees-per-second + """ + + def __str__(self): + return ("%d dps" % self) + + def get_speed_pct(self, motor): + """ + Return the motor speed percentage to achieve desired degrees-per-second + """ + assert self <= motor.max_dps, "%s max DPS is %s, %s was requested" % (motor, motor.max_dps, self) + return (self/motor.max_dps) * 100 + + +class SpeedDPM(SpeedInteger): + """ + Speed in degrees-per-minute + """ + + def __str__(self): + return ("%d dpm" % self) + + def get_speed_pct(self, motor): + """ + Return the motor speed percentage to achieve desired degrees-per-minute + """ + assert self <= motor.max_dps, "%s max DPM is %s, %s was requested" % (motor, motor.max_dpm, self) + return (self/motor.max_dpm) * 100 + + class Motor(Device): """ @@ -113,6 +181,10 @@ class Motor(Device): '_stop_actions', '_time_sp', '_poll', + 'max_rps', + 'max_rpm', + 'max_dps', + 'max_dpm', ] #: Run the motor until another command is sent. @@ -222,6 +294,10 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._stop_actions = None self._time_sp = None self._poll = None + self.max_rps = float(self.max_speed/self.count_per_rot) + self.max_rpm = self.max_rps * 60 + self.max_dps = self.max_rps * 360 + self.max_dpm = self.max_rpm * 360 @property def address(self): @@ -732,6 +808,18 @@ def wait_while(self, s, timeout=None): """ return self.wait(lambda state: s not in state, timeout) + def _speed_pct(self, speed_pct): + + # If speed_pct is SpeedInteger object we must convert + # SpeedRPS, etc to an actual speed percentage + if isinstance(speed_pct, SpeedInteger): + speed_pct = speed_pct.get_speed_pct(self) + + assert -100 <= speed_pct <= 100,\ + "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + + return speed_pct + def _set_position_rotations(self, speed_pct, rotations): # +/- speed is used to control direction, rotations must be positive @@ -760,10 +848,18 @@ def _set_brake(self, brake): def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): """ - Rotate the motor at 'speed' for 'rotations' + Rotate the motor at 'speed_pct' for 'rotations' + + 'speed_pct' can be an integer or a SpeedInteger object which will be + converted to an actual speed percentage in _speed_pct() """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + speed_pct = self._speed_pct(speed_pct) + + if not speed_pct or not rotations: + log.warning("%s speed_pct is %s but rotations is %s, motor will not move" % (self, speed_pct, rotations)) + self._set_brake(brake) + return + self.speed_sp = int((speed_pct * self.max_speed) / 100) self._set_position_rotations(speed_pct, rotations) self._set_brake(brake) @@ -775,10 +871,18 @@ def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): """ - Rotate the motor at 'speed' for 'degrees' + Rotate the motor at 'speed_pct' for 'degrees' + + 'speed_pct' can be an integer or a SpeedInteger object which will be + converted to an actual speed percentage in _speed_pct() """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + speed_pct = self._speed_pct(speed_pct) + + if not speed_pct or not degrees: + log.warning("%s speed_pct is %s but degrees is %s, motor will not move" % (self, speed_pct, degrees)) + self._set_brake(brake) + return + self.speed_sp = int((speed_pct * self.max_speed) / 100) self._set_position_degrees(speed_pct, degrees) self._set_brake(brake) @@ -788,12 +892,43 @@ def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) self.wait_until_not_moving() + def on_to_position(self, speed_pct, position, brake=True, block=True): + """ + Rotate the motor at 'speed_pct' to 'position' + + 'speed_pct' can be an integer or a SpeedInteger object which will be + converted to an actual speed percentage in _speed_pct() + """ + speed_pct = self._speed_pct(speed_pct) + + if not speed_pct: + log.warning("%s speed_pct is %s, motor will not move" % (self, speed_pct)) + self._set_brake(brake) + return + + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.position_sp = position + self._set_brake(brake) + self.run_to_abs_pos() + + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): """ - Rotate the motor at 'speed' for 'seconds' + Rotate the motor at 'speed_pct' for 'seconds' + + 'speed_pct' can be an integer or a SpeedInteger object which will be + converted to an actual speed percentage in _speed_pct() """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + speed_pct = self._speed_pct(speed_pct) + + if not speed_pct or not seconds: + log.warning("%s speed_pct is %s but seconds is %s, motor will not move" % (self, speed_pct, seconds)) + self._set_brake(brake) + return + self.speed_sp = int((speed_pct * self.max_speed) / 100) self.time_sp = int(seconds * 1000) self._set_brake(brake) @@ -803,15 +938,31 @@ def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) self.wait_until_not_moving() - def on(self, speed_pct): + def on(self, speed_pct, brake=True, block=False): """ - Rotate the motor at 'speed' for forever + Rotate the motor at 'speed_pct' for forever + + 'speed_pct' can be an integer or a SpeedInteger object which will be + converted to an actual speed percentage in _speed_pct() + + Note that `block` is False by default, this is different from the + other `on_for_XYZ` methods """ - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + speed_pct = self._speed_pct(speed_pct) + + if not speed_pct: + log.warning("%s speed_pct is %s, motor will not move" % (self, speed_pct)) + self._set_brake(brake) + return + self.speed_sp = int((speed_pct * self.max_speed) / 100) + self._set_brake(brake) self.run_forever() + if block: + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + def off(self, brake=True): self._set_brake(brake) self.stop() diff --git a/tests/fake-sys b/tests/fake-sys index 61d8ded..a006e99 160000 --- a/tests/fake-sys +++ b/tests/fake-sys @@ -1 +1 @@ -Subproject commit 61d8dedecd55614331aa0e6c1d6f180c4a4d949c +Subproject commit a006e999da6434bf094242847dd85593aaa0b3a0 From 081ea218c0ee6c6f943ccebc0009c1c9c271c6ef Mon Sep 17 00:00:00 2001 From: Greg Cowell Date: Thu, 12 Oct 2017 08:04:36 +1000 Subject: [PATCH 034/172] Add a wait_until_angle_changed_by() method to GyroSensor (#408) --- ev3dev/sensor/lego.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ev3dev/sensor/lego.py b/ev3dev/sensor/lego.py index 50d5143..cd16324 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev/sensor/lego.py @@ -430,6 +430,17 @@ def reset(self): self.mode = self.MODE_GYRO_ANG self._direct = self.set_attr_raw(self._direct, 'direct', 17) + def wait_until_angle_changed_by(self, delta): + """ + Wait until angle has changed by specified amount. + """ + assert self.mode in (self.MODE_GYRO_G_A, self.MODE_GYRO_ANG, + self.MODE_TILT_ANG),\ + 'Gyro mode should be MODE_GYRO_ANG, MODE_GYRO_G_A or MODE_TILT_ANG' + start_angle = self.value(0) + while abs(start_angle - self.value(0)) < delta: + time.sleep(0.01) + class InfraredSensor(Sensor, ButtonBase): """ From 8e57eeae1bc4193fba1de627b40eee2205366fc7 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Fri, 13 Oct 2017 14:16:25 -0400 Subject: [PATCH 035/172] EV3-G API Display (#410) * EV3-G API Display * EV3-G API Display - add text_grid() * Bring back original image() * Display: fix asserts in text_grid() * Display: fix asserts in text_grid() * Display: added doc strings --- ev3dev/display.py | 133 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 3 deletions(-) diff --git a/ev3dev/display.py b/ev3dev/display.py index 416be8b..4afde6f 100644 --- a/ev3dev/display.py +++ b/ev3dev/display.py @@ -31,6 +31,8 @@ import os import mmap import ctypes +import ev3dev.fonts as fonts +from PIL import Image, ImageDraw from struct import pack import fcntl @@ -179,14 +181,18 @@ def _map_fb_memory(fbfid, fix_info): ) -class Screen(FbMem): +class Display(FbMem): """ A convenience wrapper for the FbMem class. Provides drawing functions from the python imaging library (PIL). """ - def __init__(self): - from PIL import Image, ImageDraw + GRID_COLUMNS = 22 + GRID_COLUMN_PIXELS = 8 + GRID_ROWS = 12 + GRID_ROW_PIXELS = 10 + + def __init__(self, desc='Display'): FbMem.__init__(self) self._img = Image.new( @@ -195,6 +201,10 @@ def __init__(self): "white") self._draw = ImageDraw.Draw(self._img) + self.desc = desc + + def __str__(self): + return self.desc @property def xres(self): @@ -267,3 +277,120 @@ def update(self): self.mmap[:] = self._img_to_rgb565_bytes() else: raise Exception("Not supported") + + def image_filename(self, filename, clear_screen=True, x1=0, y1=0, x2=None, y2=None): + + if clear_screen: + self.clear() + + filename_im = Image.open(filename) + + if x2 is not None and y2 is not None: + return self._img.paste(filename_im, (x1, y1, x2, y2)) + else: + return self._img.paste(filename_im, (x1, y1)) + + def line(self, clear_screen=True, x1=10, y1=10, x2=50, y2=50, line_color='black', width=1): + """ + Draw a line from (x1, y1) to (x2, y2) + """ + + if clear_screen: + self.clear() + + return self.draw.line((x1, y1, x2, y2), fill=line_color, width=width) + + def circle(self, clear_screen=True, x=50, y=50, radius=40, fill_color='black', outline_color='black'): + """ + Draw a circle of 'radius' centered at (x, y) + """ + + if clear_screen: + self.clear() + + x1 = x - radius + y1 = y - radius + x2 = x + radius + y2 = y + radius + + return self.draw.ellipse((x1, y1, x2, y2), fill=fill_color, outline=outline_color) + + def rectangle(self, clear_screen=True, x=10, y=10, width=80, height=40, fill_color='black', outline_color='black'): + """ + Draw a rectangle 'width x height' where the top left corner is at (x, y) + """ + + if clear_screen: + self.clear() + + return self.draw.rectangle((x, y, width, height), fill=fill_color, outline=outline_color) + + def point(self, clear_screen=True, x=10, y=10, point_color='black'): + """ + Draw a single pixel at (x, y) + """ + + if clear_screen: + self.clear() + + return self.draw.point((x, y), fill=point_color) + + def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', font=None): + """ + Display `text` starting at pixel (x, y). + + The EV3 display is 178x128 pixels + - (0, 0) would be the top left corner of the display + - (89, 64) would be right in the middle of the display + + 'text_color' : PIL says it supports "common HTML color names". There + are 140 HTML color names listed here that are supported by all modern + browsers. This is probably a good list to start with. + https://www.w3schools.com/colors/colors_names.asp + + 'font' : can be any font displayed here + http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/stable/other.html#bitmap-fonts + """ + + if clear_screen: + self.clear() + + if font is not None: + assert font in fonts.available(), "%s is an invalid font" % font + return self.draw.text((x, y), text, fill=text_color, font=fonts.load(font)) + else: + return self.draw.text((x, y), text, fill=text_color) + + def text_grid(self, text, clear_screen=True, x=0, y=0, text_color='black', font=None): + """ + Display 'text' starting at grid (x, y) + + The EV3 display can be broken down in a grid that is 22 columns wide + and 12 rows tall. Each column is 8 pixels wide and each row is 10 + pixels tall. + + 'text_color' : PIL says it supports "common HTML color names". There + are 140 HTML color names listed here that are supported by all modern + browsers. This is probably a good list to start with. + https://www.w3schools.com/colors/colors_names.asp + + 'font' : can be any font displayed here + http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/stable/other.html#bitmap-fonts + """ + + assert 0 <= x < Display.GRID_COLUMNS,\ + "grid columns must be between 0 and %d, %d was requested" %\ + ((Display.GRID_COLUMNS - 1, x)) + + assert 0 <= y < Display.GRID_ROWS,\ + "grid rows must be between 0 and %d, %d was requested" %\ + ((Display.GRID_ROWS - 1), y) + + return self.text_pixels(text, clear_screen, + x * Display.GRID_COLUMN_PIXELS, + y * Display.GRID_ROW_PIXELS, + text_color, font) + + def reset_screen(self): + self.clear() + self.update() From 6e7b0ecbda8095262f41398352594bda1bdc6392 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Fri, 20 Oct 2017 09:24:40 +0300 Subject: [PATCH 036/172] Missing build dependency for python3-pillow --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index e80bae1..b2a7c16 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Maintainer: Ralph Hempel Section: python Priority: optional Standards-Version: 3.9.5 -Build-Depends: python3-setuptools (>= 0.6b3), python3-all (>= 3.4), debhelper (>= 9), dh-python +Build-Depends: python3-setuptools (>= 0.6b3), python3-all (>= 3.4), debhelper (>= 9), dh-python, python3-pillow VCS-Git: git://github.com/rhempel/ev3dev-lang-python.git VCS-Browser: https://github.com/rhempel/ev3dev-lang-python From 889fcd165d9cb5391cb08844b4691306e0defffb Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Fri, 20 Oct 2017 07:36:14 -0400 Subject: [PATCH 037/172] Added ColorSensor.lab(), hsv() and hls() (#417) --- ev3dev/sensor/lego.py | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/ev3dev/sensor/lego.py b/ev3dev/sensor/lego.py index cd16324..b659959 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev/sensor/lego.py @@ -261,6 +261,124 @@ def rgb(self): min(int((green * 255) / self.green_max), 255), min(int((blue * 255) / self.blue_max), 255)) + @property + def lab(self): + """ + Return colors in Lab color space + """ + RGB = [0, 0, 0] + XYZ = [0, 0, 0] + + for (num, value) in enumerate(self.rgb): + if value > 0.04045: + value = pow(((value + 0.055) / 1.055), 2.4) + else: + value = value / 12.92 + + RGB[num] = value * 100.0 + + # http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + # sRGB + # 0.4124564 0.3575761 0.1804375 + # 0.2126729 0.7151522 0.0721750 + # 0.0193339 0.1191920 0.9503041 + X = (RGB[0] * 0.4124564) + (RGB[1] * 0.3575761) + (RGB[2] * 0.1804375) + Y = (RGB[0] * 0.2126729) + (RGB[1] * 0.7151522) + (RGB[2] * 0.0721750) + Z = (RGB[0] * 0.0193339) + (RGB[1] * 0.1191920) + (RGB[2] * 0.9503041) + + XYZ[0] = X / 95.047 # ref_X = 95.047 + XYZ[1] = Y / 100.0 # ref_Y = 100.000 + XYZ[2] = Z / 108.883 # ref_Z = 108.883 + + for (num, value) in enumerate(XYZ): + if value > 0.008856: + value = pow(value, (1.0 / 3.0)) + else: + value = (7.787 * value) + (16 / 116.0) + + XYZ[num] = value + + L = (116.0 * XYZ[1]) - 16 + a = 500.0 * (XYZ[0] - XYZ[1]) + b = 200.0 * (XYZ[1] - XYZ[2]) + + L = round(L, 4) + a = round(a, 4) + b = round(b, 4) + + return (L, a, b) + + @property + def hsv(self): + """ + HSV: Hue, Saturation, Value + H: position in the spectrum + S: color saturation ("purity") + V: color brightness + """ + (r, g, b) = self.rgb + maxc = max(r, g, b) + minc = min(r, g, b) + v = maxc + + if minc == maxc: + return 0.0, 0.0, v + + s = (maxc-minc) / maxc + rc = (maxc-r) / (maxc-minc) + gc = (maxc-g) / (maxc-minc) + bc = (maxc-b) / (maxc-minc) + + if r == maxc: + h = bc-gc + elif g == maxc: + h = 2.0+rc-bc + else: + h = 4.0+gc-rc + + h = (h/6.0) % 1.0 + + return (h, s, v) + + @property + def hls(self): + """ + HLS: Hue, Luminance, Saturation + H: position in the spectrum + L: color lightness + S: color saturation + """ + (r, g, b) = self.rgb + maxc = max(r, g, b) + minc = min(r, g, b) + l = (minc+maxc)/2.0 + + if minc == maxc: + return 0.0, l, 0.0 + + if l <= 0.5: + s = (maxc-minc) / (maxc+minc) + else: + if 2.0-maxc-minc == 0: + s = 0 + else: + s = (maxc-minc) / (2.0-maxc-minc) + + rc = (maxc-r) / (maxc-minc) + gc = (maxc-g) / (maxc-minc) + bc = (maxc-b) / (maxc-minc) + + if r == maxc: + h = bc-gc + elif g == maxc: + h = 2.0+rc-bc + else: + h = 4.0+gc-rc + + h = (h/6.0) % 1.0 + + return (h, l, s) + @property def red(self): """ From a00032c3a5a6549d68d8277935fbd8057f11bebd Mon Sep 17 00:00:00 2001 From: Greg Cowell Date: Wed, 25 Oct 2017 23:28:02 +1000 Subject: [PATCH 038/172] Update GyroBalancer module (#421) --- ev3dev/control/GyroBalancer.py | 712 ++++++++++++++++++--------------- 1 file changed, 397 insertions(+), 315 deletions(-) diff --git a/ev3dev/control/GyroBalancer.py b/ev3dev/control/GyroBalancer.py index df28e50..73b08b0 100644 --- a/ev3dev/control/GyroBalancer.py +++ b/ev3dev/control/GyroBalancer.py @@ -1,4 +1,10 @@ -#!/usr/bin/env python3 +"""Module for a robot that stands on two wheels and uses a gyro sensor. + +The robot (eg. BALANC3R) will to keep its balance and move in response to +the remote control. This code was adapted from Laurens Valk's script at +https://github.com/laurensvalk/segway. + +""" # The MIT License (MIT) # # Copyright (c) 2016 Laurens Valk (laurensvalk@gmail.com) @@ -10,8 +16,8 @@ # 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 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, @@ -21,378 +27,454 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -""" -This is a class-based version of https://github.com/laurensvalk/segway -""" import logging -import math import time from collections import deque -from ev3dev.auto import * -from ev3dev.helper import LargeMotorPair - +from ev3dev.power import PowerSupply +from ev3dev.motor import LargeMotor, OUTPUT_A, OUTPUT_D +from ev3dev.sensor.lego import GyroSensor, InfraredSensor, TouchSensor log = logging.getLogger(__name__) - ######################################################################## -## -## File I/O functions -## +# File I/O functions ######################################################################## -# Function for fast reading from sensor files + def FastRead(infile): + """Function for fast reading from sensor files.""" infile.seek(0) - return int(infile.read().decode().strip()) + return(int(infile.read().decode().strip())) -# Function for fast writing to motor files def FastWrite(outfile, value): + """Function for fast writing to motor files.""" outfile.truncate(0) outfile.write(str(int(value))) outfile.flush() -# Function to set the duty cycle of the motors -def SetDuty(motorDutyFileHandle, duty): - # Clamp the value between -100 and 100 - duty = min(max(duty, -100), 100) +def SetDuty(motorDutyFileHandle, duty, frictionOffset, voltageCompensation): + """Function to set the duty cycle of the motors.""" + # Compensate for nominal voltage and round the input + dutyInt = int(round(duty*voltageCompensation)) + + # Add or subtract offset and clamp the value between -100 and 100 + if dutyInt > 0: + dutyInt = min(100, dutyInt + frictionOffset) + elif dutyInt < 0: + dutyInt = max(-100, dutyInt - frictionOffset) # Apply the signal to the motor - FastWrite(motorDutyFileHandle, duty) + FastWrite(motorDutyFileHandle, dutyInt) -class GyroBalancer(LargeMotorPair): +class GyroBalancer(object): """ - Base class for a robot that stands on two wheels and uses a gyro sensor - to keep its balance. + Base class for a robot that stands on two wheels and uses a gyro sensor. + + Robot will keep its balance. """ def __init__(self, - gainGyroAngle, # For every radian (57 degrees) we lean forward, apply this amount of duty cycle - gainGyroRate, # For every radian/s we fall forward, apply this amount of duty cycle - gainMotorAngle, # For every radian we are ahead of the reference, apply this amount of duty cycle - gainMotorAngularSpeed, # For every radian/s drive faster than the reference value, apply this amount of duty cycle - gainMotorAngleErrorAccumulated, # For every radian x s of accumulated motor angle, apply this amount of duty cycle - left_motor=OUTPUT_D, - right_motor=OUTPUT_A): - LargeMotorPair.__init__(self, left_motor, right_motor) - - # magic numbers - self.gainGyroAngle = gainGyroAngle - self.gainGyroRate = gainGyroRate - self.gainMotorAngle = gainMotorAngle - self.gainMotorAngularSpeed = gainMotorAngularSpeed - self.gainMotorAngleErrorAccumulated = gainMotorAngleErrorAccumulated - - # Sensor setup - self.gyro = GyroSensor() - self.gyro.mode = self.gyro.MODE_GYRO_RATE - self.touch = TouchSensor() - self.remote = RemoteControl(channel=1) - - if not self.remote.connected: - log.error("%s is not connected" % self.remote) - sys.exit(1) - - # Motor setup - self.left_motor.reset() - self.right_motor.reset() - self.left_motor.run_direct() - self.right_motor.run_direct() - - self.speed = 0 - self.steering = 0 - self.red_up = False - self.red_down = False - self.blue_up = False - self.blue_down = False - self.STEER_SPEED = 20 - self.remote.on_red_up = self.make_move('red_up') - self.remote.on_red_down = self.make_move('red_down') - self.remote.on_blue_up = self.make_move('blue_up') - self.remote.on_blue_down = self.make_move('blue_down') - - def make_move(self, button): - def move(state): - # button pressed - if state: - if button == 'red_up': - self.red_up = True - elif button == 'red_down': - self.red_down = True - elif button == 'blue_up': - self.blue_up = True - elif button == 'blue_down': - self.blue_down = True - - # button released - else: - if button == 'red_up': - self.red_up = False - elif button == 'red_down': - self.red_down = False - elif button == 'blue_up': - self.blue_up = False - elif button == 'blue_down': - self.blue_down = False - - # forward - if self.red_up and self.blue_up: - self.speed = self.STEER_SPEED - self.steering = 0 - - # backward - elif self.red_down and self.blue_down: - self.speed = -1 * self.STEER_SPEED - self.steering = 0 - - # turn sharp right - elif self.red_up and self.blue_down: - self.speed = 0 - self.steering = -1 * self.STEER_SPEED * 2 - - # turn right - elif self.red_up: - self.speed = 0 - self.steering = -1 * self.STEER_SPEED - - # turn sharp left - elif self.red_down and self.blue_up: - self.speed = 0 - self.steering = self.STEER_SPEED * 2 - - # turn left - elif self.blue_up: - self.speed = 0 - self.steering = self.STEER_SPEED - - else: - self.speed = 0 - self.steering = 0 - - # log.info("button %8s, state %5s, speed %d, steering %d" % (button, state, self.speed, self.steering)) - - return move + gain_gyro_angle=1700, + gain_gyro_rate=120, + gain_motor_angle=7, + gain_motor_angular_speed=9, + gain_motor_angle_error_accumulated=3, + power_voltage_nominal=8.0, + power_friction_offset_nominal=3, + timing_loop_time_msec=30, + timing_motor_angle_history_length=5, + timing_gyro_drift_compensation_factor=0.05, + left_motor_port=OUTPUT_D, + right_motor_port=OUTPUT_A): + """Create GyroBalancer.""" + # Gain parameters + self.gain_gyro_angle = gain_gyro_angle + self.gain_gyro_rate = gain_gyro_rate + self.gain_motor_angle = gain_motor_angle + self.gain_motor_angular_speed = gain_motor_angular_speed + self.gain_motor_angle_error_accumulated =\ + gain_motor_angle_error_accumulated + + # Power parameters + self.power_voltage_nominal = power_voltage_nominal + self.power_friction_offset_nominal = power_friction_offset_nominal + + # Timing parameters + self.timing_loop_time_msec = timing_loop_time_msec + self.timing_motor_angle_history_length =\ + timing_motor_angle_history_length + self.timing_gyro_drift_compensation_factor =\ + timing_gyro_drift_compensation_factor + + # EV3 Brick + self.powerSupply = PowerSupply() + # buttons = ev3.Button() + + # Gyro Sensor setup + self.gyroSensor = GyroSensor() + self.gyroSensor.mode = self.gyroSensor.MODE_GYRO_RATE + self.gyroSensorValueRaw = open(self.gyroSensor._path + "/value0", "rb") + + # Touch Sensor setup + self.touchSensor = TouchSensor() + self.touchSensorValueRaw = open(self.touchSensor._path + "/value0", + "rb") + + # IR Buttons setup + self.irRemote = InfraredSensor() + self.irRemote.mode = self.irRemote.MODE_IR_REMOTE + self.irRemoteValueRaw = open(self.irRemote._path + "/value0", "rb") + + # Configure the motors + self.motorLeft = LargeMotor(left_motor_port) + self.motorRight = LargeMotor(right_motor_port) def main(self): - + """Make the robot go.""" def shutdown(): - touchSensorValueRaw.close() - gyroSensorValueRaw.close() + """Close all file handles and stop all motors.""" + self.touchSensorValueRaw.close() + self.gyroSensorValueRaw.close() motorEncoderLeft.close() motorEncoderRight.close() motorDutyCycleLeft.close() motorDutyCycleRight.close() - - for motor in list_motors(): - motor.stop() + self.motorLeft.stop() + self.motorRight.stop() try: - - ######################################################################## - ## - ## Definitions and Initialization variables - ## - ######################################################################## - - # Timing settings for the program - loopTimeMilliSec = 10 # Time of each loop, measured in miliseconds. - loopTimeSec = loopTimeMilliSec/1000.0 # Time of each loop, measured in seconds. - motorAngleHistoryLength = 3 # Number of previous motor angles we keep track of. - - # Math constants - radiansPerDegree = math.pi/180 # The number of radians in a degree. - - # Platform specific constants and conversions - degPerSecondPerRawGyroUnit = 1 # For the LEGO EV3 Gyro in Rate mode, 1 unit = 1 deg/s - radiansPerSecondPerRawGyroUnit = degPerSecondPerRawGyroUnit*radiansPerDegree # Express the above as the rate in rad/s per gyro unit - degPerRawMotorUnit = 1 # For the LEGO EV3 Large Motor 1 unit = 1 deg - radiansPerRawMotorUnit = degPerRawMotorUnit*radiansPerDegree # Express the above as the angle in rad per motor unit - RPMperPerPercentSpeed = 1.7 # On the EV3, "1% speed" corresponds to 1.7 RPM (if speed control were enabled) - degPerSecPerPercentSpeed = RPMperPerPercentSpeed*360/60 # Convert this number to the speed in deg/s per "percent speed" - radPerSecPerPercentSpeed = degPerSecPerPercentSpeed * radiansPerDegree # Convert this number to the speed in rad/s per "percent speed" - - # The rate at which we'll update the gyro offset (precise definition given in docs) - gyroDriftCompensationRate = 0.1 * loopTimeSec * radiansPerSecondPerRawGyroUnit - - # A deque (a fifo array) which we'll use to keep track of previous motor positions, which we can use to calculate the rate of change (speed) - motorAngleHistory = deque([0], motorAngleHistoryLength) - - # State feedback control gains (aka the magic numbers) - gainGyroAngle = self.gainGyroAngle - gainGyroRate = self.gainGyroRate - gainMotorAngle = self.gainMotorAngle - gainMotorAngularSpeed = self.gainMotorAngularSpeed - gainMotorAngleErrorAccumulated = self.gainMotorAngleErrorAccumulated - - # Variables representing physical signals (more info on these in the docs) - # The angle of "the motor", measured in raw units (degrees for the - # EV3). We will take the average of both motor positions as "the motor" - # angle, wich is essentially how far the middle of the robot has traveled. - motorAngleRaw = 0 - - # The angle of the motor, converted to radians (2*pi radians equals 360 degrees). - motorAngle = 0 - - # The reference angle of the motor. The robot will attempt to drive - # forward or backward, such that its measured position equals this - # reference (or close enough). - motorAngleReference = 0 - - # The error: the deviation of the measured motor angle from the reference. - # The robot attempts to make this zero, by driving toward the reference. - motorAngleError = 0 - - # We add up all of the motor angle error in time. If this value gets out of - # hand, we can use it to drive the robot back to the reference position a bit quicker. - motorAngleErrorAccumulated = 0 - - # The motor speed, estimated by how far the motor has turned in a given amount of time - motorAngularSpeed = 0 - - # The reference speed during manouvers: how fast we would like to drive, measured in radians per second. - motorAngularSpeedReference = 0 - - # The error: the deviation of the motor speed from the reference speed. - motorAngularSpeedError = 0 - - # The 'voltage' signal we send to the motor. We calulate a new value each - # time, just right to keep the robot upright. - motorDutyCycle = 0 - - # The raw value from the gyro sensor in rate mode. - gyroRateRaw = 0 - - # The angular rate of the robot (how fast it is falling forward or backward), measured in radians per second. - gyroRate = 0 - - # The gyro doesn't measure the angle of the robot, but we can estimate - # this angle by keeping track of the gyroRate value in time - gyroEstimatedAngle = 0 - - # Over time, the gyro rate value can drift. This causes the sensor to think - # it is moving even when it is perfectly still. We keep track of this offset. - gyroOffset = 0 - - # filehandles for fast reads/writes - # ================================= - touchSensorValueRaw = open(self.touch._path + "/value0", "rb") - gyroSensorValueRaw = open(self.gyro._path + "/value0", "rb") - - # Open motor files for (fast) reading - motorEncoderLeft = open(self.left_motor._path + "/position", "rb") - motorEncoderRight = open(self.right_motor._path + "/position", "rb") - - # Open motor files for (fast) writing - motorDutyCycleLeft = open(self.left_motor._path + "/duty_cycle_sp", "w") - motorDutyCycleRight = open(self.right_motor._path + "/duty_cycle_sp", "w") - - ######################################################################## - ## - ## Calibrate Gyro - ## - ######################################################################## - print("-----------------------------------") - print("Calibrating...") - - #As you hold the robot still, determine the average sensor value of 100 samples - gyroRateCalibrateCount = 100 - for i in range(gyroRateCalibrateCount): - gyroOffset = gyroOffset + FastRead(gyroSensorValueRaw) - time.sleep(0.01) - gyroOffset = gyroOffset/gyroRateCalibrateCount - - # Print the result - print("GyroOffset: %s" % gyroOffset) - print("-----------------------------------") - print("GO!") - print("-----------------------------------") - - ######################################################################## - ## - ## MAIN LOOP (Press Touch Sensor to stop the program) - ## - ######################################################################## - - # Initial touch sensor value - touchSensorPressed = FastRead(touchSensorValueRaw) - - while not touchSensorPressed: + while True: ############################################################### - ## Loop info + # Hardware (Re-)Config ############################################################### - tLoopStart = time.clock() - ############################################################### - ## Reading the Remote Control - ############################################################### - self.remote.process() + # Reset the motors + self.motorLeft.reset() # Reset the encoder + self.motorRight.reset() + self.motorLeft.run_direct() # Set to run direct mode + self.motorRight.run_direct() - ############################################################### - ## Reading the Gyro. - ############################################################### - gyroRateRaw = FastRead(gyroSensorValueRaw) - gyroRate = (gyroRateRaw - gyroOffset)*radiansPerSecondPerRawGyroUnit + # Open sensor files for (fast) reading + motorEncoderLeft = open(self.motorLeft._path + "/position", + "rb") + motorEncoderRight = open(self.motorRight._path + "/position", + "rb") + + # Open motor files for (fast) writing + motorDutyCycleLeft = open(self.motorLeft._path + + "/duty_cycle_sp", "w") + motorDutyCycleRight = open(self.motorRight._path + + "/duty_cycle_sp", "w") ############################################################### - ## Reading the Motor Position + # Definitions and Initialization variables ############################################################### - motorAngleRaw = (FastRead(motorEncoderLeft) + FastRead(motorEncoderRight))/2 - motorAngle = motorAngleRaw*radiansPerRawMotorUnit - motorAngularSpeedReference = self.speed * radPerSecPerPercentSpeed - motorAngleReference = motorAngleReference + motorAngularSpeedReference * loopTimeSec + # Math constants - motorAngleError = motorAngle - motorAngleReference + # The number of radians in a degree. + radiansPerDegree = 3.14159/180 - ############################################################### - ## Computing Motor Speed - ############################################################### - motorAngularSpeed = (motorAngle - motorAngleHistory[0])/(motorAngleHistoryLength * loopTimeSec) - motorAngularSpeedError = motorAngularSpeed - motorAngularSpeedReference - motorAngleHistory.append(motorAngle) + # Platform specific constants and conversions - ############################################################### - ## Computing the motor duty cycle value - ############################################################### - motorDutyCycle =(gainGyroAngle * gyroEstimatedAngle - + gainGyroRate * gyroRate - + gainMotorAngle * motorAngleError - + gainMotorAngularSpeed * motorAngularSpeedError - + gainMotorAngleErrorAccumulated * motorAngleErrorAccumulated) + # For the LEGO EV3 Gyro in Rate mode, 1 unit = 1 deg/s + degPerSecondPerRawGyroUnit = 1 - ############################################################### - ## Apply the signal to the motor, and add steering - ############################################################### - SetDuty(motorDutyCycleRight, motorDutyCycle + self.steering) - SetDuty(motorDutyCycleLeft, motorDutyCycle - self.steering) + # Express the above as the rate in rad/s per gyro unit + radiansPerSecondPerRawGyroUnit = degPerSecondPerRawGyroUnit *\ + radiansPerDegree - ############################################################### - ## Update angle estimate and Gyro Offset Estimate - ############################################################### - gyroEstimatedAngle = gyroEstimatedAngle + gyroRate * loopTimeSec - gyroOffset = (1 - gyroDriftCompensationRate) * gyroOffset + gyroDriftCompensationRate * gyroRateRaw + # For the LEGO EV3 Large Motor 1 unit = 1 deg + degPerRawMotorUnit = 1 + + # Express the above as the angle in rad per motor unit + radiansPerRawMotorUnit = degPerRawMotorUnit * radiansPerDegree + + # On the EV3, "1% speed" corresponds to 1.7 RPM (if speed + # control were enabled). + RPMperPerPercentSpeed = 1.7 + + # Convert this number to the speed in deg/s per "percent speed" + degPerSecPerPercentSpeed = RPMperPerPercentSpeed * 360 / 60 + + # Convert this number to the speed in rad/s per "percent speed" + radPerSecPerPercentSpeed = degPerSecPerPercentSpeed *\ + radiansPerDegree + + # Variables representing physical signals + # (more info on these in the docs) + + # The angle of "the motor", measured in raw units, + # degrees for the EV3). + # We will take the average of both motor positions as + # "the motor" angle, which is essentially how far the middle + # of the robot has traveled. + motorAngleRaw = 0 + + # The angle of the motor, converted to radians (2*pi radians + # equals 360 degrees). + motorAngle = 0 + + # The reference angle of the motor. The robot will attempt to + # drive forward or backward, such that its measured position + motorAngleReference = 0 + # equals this reference (or close enough). + + # The error: the deviation of the measured motor angle from the + # reference. The robot attempts to make this zero, by driving + # toward the reference. + motorAngleError = 0 + + # We add up all of the motor angle error in time. If this value + # gets out of hand, we can use it to drive the robot back to + # the reference position a bit quicker. + motorAngleErrorAccumulated = 0 + + # The motor speed, estimated by how far the motor has turned in + # a given amount of time. + motorAngularSpeed = 0 + + # The reference speed during manouvers: how fast we would like + # to drive, measured in radians per second. + motorAngularSpeedReference = 0 + + # The error: the deviation of the motor speed from the + # reference speed. + motorAngularSpeedError = 0 + + # The 'voltage' signal we send to the motor. + # We calculate a new value each time, just right to keep the + # robot upright. + motorDutyCycle = 0 + + # The raw value from the gyro sensor in rate mode. + gyroRateRaw = 0 + + # The angular rate of the robot (how fast it is falling forward + # or backward), measured in radians per second. + gyroRate = 0 + + # The gyro doesn't measure the angle of the robot, but we can + # estimate this angle by keeping track of the gyroRate value in + # time. + gyroEstimatedAngle = 0 + + # Over time, the gyro rate value can drift. This causes the + # sensor to think it is moving even when it is perfectly still. + # We keep track of this offset. + gyroOffset = 0 + + log.info("Hold robot upright. Press Touch Sensor to start.") + + self.touchSensor.wait_for_bump() + + # Read battery voltage + voltageIdle = self.powerSupply.measured_volts + voltageCompensation = self.power_voltage_nominal/voltageIdle + + # Offset to limit friction deadlock + frictionOffset = int(round(self.power_friction_offset_nominal * + voltageCompensation)) + + # Timing settings for the program + # Time of each loop, measured in seconds. + loopTimeSec = self.timing_loop_time_msec / 1000 + loopCount = 0 # Loop counter, starting at 0 + + # A deque (a fifo array) which we'll use to keep track of + # previous motor positions, which we can use to calculate the + # rate of change (speed) + motorAngleHistory =\ + deque([0], self.timing_motor_angle_history_length) + + # The rate at which we'll update the gyro offset (precise + # definition given in docs) + gyroDriftCompensationRate =\ + self.timing_gyro_drift_compensation_factor *\ + loopTimeSec * radiansPerSecondPerRawGyroUnit ############################################################### - ## Update Accumulated Motor Error + # Calibrate Gyro ############################################################### - motorAngleErrorAccumulated = motorAngleErrorAccumulated + motorAngleError * loopTimeSec + + log.info("-----------------------------------") + log.info("Calibrating...") + + # As you hold the robot still, determine the average sensor + # value of 100 samples + gyroRateCalibrateCount = 100 + for i in range(gyroRateCalibrateCount): + gyroOffset = gyroOffset + FastRead(self.gyroSensorValueRaw) + time.sleep(0.01) + gyroOffset = gyroOffset/gyroRateCalibrateCount + + # Print the result + log.info("GyroOffset: " + str(gyroOffset)) + log.info("-----------------------------------") + log.info("GO!") + log.info("-----------------------------------") + log.info("Press Touch Sensor to re-start.") + log.info("-----------------------------------") ############################################################### - ## Read the touch sensor (the kill switch) + # Balancing Loop ############################################################### - touchSensorPressed = FastRead(touchSensorValueRaw) + + # Remember start time + tProgramStart = time.time() + + # Initial fast read touch sensor value + touchSensorPressed = False + + # Keep looping until Touch Sensor is pressed again + while not touchSensorPressed: + + ########################################################### + # Loop info + ########################################################### + loopCount = loopCount + 1 + tLoopStart = time.time() + + ########################################################### + # Driving and Steering. + ########################################################### + + # Just balance in place: + speed = 0 + steering = 0 + + # Control speed and steering based on the IR Remote + buttonCode = FastRead(self.irRemoteValueRaw) + + speed_max = 20 + steer_max_right = 8 + + if(buttonCode == 5): + speed = speed_max + steering = 0 + elif (buttonCode == 6): + speed = 0 + steering = -steer_max_right + elif (buttonCode == 7): + speed = 0 + steering = steer_max_right + elif (buttonCode == 8): + speed = -speed_max + steering = 0 + else: + speed = 0 + steering = 0 + + ########################################################### + # Reading the Gyro. + ########################################################### + gyroRateRaw = FastRead(self.gyroSensorValueRaw) + gyroRate = (gyroRateRaw - gyroOffset) *\ + radiansPerSecondPerRawGyroUnit + + ########################################################### + # Reading the Motor Position + ########################################################### + + motorAngleRaw = (FastRead(motorEncoderLeft) + + FastRead(motorEncoderRight)) / 2 + motorAngle = motorAngleRaw*radiansPerRawMotorUnit + + motorAngularSpeedReference = speed*radPerSecPerPercentSpeed + motorAngleReference = motorAngleReference +\ + motorAngularSpeedReference * loopTimeSec + + motorAngleError = motorAngle - motorAngleReference + + ########################################################### + # Computing Motor Speed + ########################################################### + + motorAngularSpeed = (motorAngle - motorAngleHistory[0]) /\ + (self.timing_motor_angle_history_length*loopTimeSec) + motorAngularSpeedError = motorAngularSpeed + motorAngleHistory.append(motorAngle) + + ########################################################### + # Computing the motor duty cycle value + ########################################################### + + motorDutyCycle =\ + (self.gain_gyro_angle * gyroEstimatedAngle + + self.gain_gyro_rate * gyroRate + + self.gain_motor_angle * motorAngleError + + self.gain_motor_angular_speed * + motorAngularSpeedError + + self.gain_motor_angle_error_accumulated * + motorAngleErrorAccumulated) + + ########################################################### + # Apply the signal to the motor, and add steering + ########################################################### + + SetDuty(motorDutyCycleRight, motorDutyCycle + + steering, frictionOffset, voltageCompensation) + SetDuty(motorDutyCycleLeft, motorDutyCycle - steering, + frictionOffset, voltageCompensation) + + ########################################################### + # Update angle estimate and Gyro Offset Estimate + ########################################################### + + gyroEstimatedAngle = gyroEstimatedAngle + gyroRate *\ + loopTimeSec + gyroOffset = (1 - gyroDriftCompensationRate) *\ + gyroOffset + gyroDriftCompensationRate * gyroRateRaw + + ########################################################### + # Update Accumulated Motor Error + ########################################################### + + motorAngleErrorAccumulated = motorAngleErrorAccumulated +\ + motorAngleError*loopTimeSec + + ########################################################### + # Read the touch sensor (the kill switch) + ########################################################### + + touchSensorPressed = FastRead(self.touchSensorValueRaw) + + ########################################################### + # Busy wait for the loop to complete + ########################################################### + + while(time.time() - tLoopStart < loopTimeSec): + time.sleep(0.0001) ############################################################### - ## Busy wait for the loop to complete + # + # Closing down & Cleaning up + # ############################################################### - while ((time.clock() - tLoopStart) < loopTimeSec): - time.sleep(0.0001) - shutdown() + # Loop end time, for stats + tProgramEnd = time.time() + + # Turn off the motors + FastWrite(motorDutyCycleLeft, 0) + FastWrite(motorDutyCycleRight, 0) + + # Wait for the Touch Sensor to be released + while self.touchSensor.is_pressed: + time.sleep(0.01) + + # Calculate loop time + tLoop = (tProgramEnd - tProgramStart)/loopCount + log.info("Loop time:" + str(tLoop*1000) + "ms") + + # Print a stop message + log.info("-----------------------------------") + log.info("STOP") + log.info("-----------------------------------") # Exit cleanly so that all motors are stopped except (KeyboardInterrupt, Exception) as e: From 779b92156f8c05f48a700c281e050c92e0332f6c Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Sat, 28 Oct 2017 22:25:28 +0300 Subject: [PATCH 039/172] Fix Display class on stretch. (#424) See #423 for more details. --- ev3dev/display.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ev3dev/display.py b/ev3dev/display.py index 4afde6f..7293738 100644 --- a/ev3dev/display.py +++ b/ev3dev/display.py @@ -272,7 +272,8 @@ def update(self): Nothing will be drawn on the screen until this function is called. """ if self.var_info.bits_per_pixel == 1: - self.mmap[:] = self._img.tobytes("raw", "1;IR") + b = self._img.tobytes("raw", "1;R") + self.mmap[:len(b)] = b elif self.var_info.bits_per_pixel == 16: self.mmap[:] = self._img_to_rgb565_bytes() else: From 7e2f71eb81059d1aadc932cc872462ff4bcc99f8 Mon Sep 17 00:00:00 2001 From: Alex Moriarty Date: Sun, 29 Oct 2017 13:04:18 +0100 Subject: [PATCH 040/172] fix travis link (#426) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ac3dc38..e73f8ef 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,8 @@ Python language bindings for ev3dev =================================== -.. image:: https://travis-ci.org/rhempel/ev3dev-lang-python.svg?branch=master - :target: https://travis-ci.org/rhempel/ev3dev-lang-python +.. image:: https://travis-ci.org/ev3dev/ev3dev-lang-python.svg?branch=master + :target: https://travis-ci.org/ev3dev/ev3dev-lang-python .. image:: https://readthedocs.org/projects/python-ev3dev/badge/?version=stable :target: http://python-ev3dev.readthedocs.org/en/stable/?badge=stable :alt: Documentation Status From bc8e5a03b866edb8cba613155e15305ea0df9a8c Mon Sep 17 00:00:00 2001 From: Alex Moriarty Date: Sun, 29 Oct 2017 16:23:20 +0100 Subject: [PATCH 041/172] Replaces print statement with built in function (#428) The print statement was removed with Python3, this change should work for both Python2 and Python3. --- tests/motor/ev3dev_port_logger.py | 2 +- tests/motor/plot_matplotlib.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/motor/ev3dev_port_logger.py b/tests/motor/ev3dev_port_logger.py index 9a5c637..1e19c3d 100644 --- a/tests/motor/ev3dev_port_logger.py +++ b/tests/motor/ev3dev_port_logger.py @@ -86,4 +86,4 @@ def execute_actions(actions): test['data'][p] = logs[p].results # Add a nice JSON formatter here - maybe? -print json.dumps( test, indent = 4 ) +print (json.dumps( test, indent = 4 )) diff --git a/tests/motor/plot_matplotlib.py b/tests/motor/plot_matplotlib.py index b7ebfc2..b6f7d62 100644 --- a/tests/motor/plot_matplotlib.py +++ b/tests/motor/plot_matplotlib.py @@ -67,7 +67,7 @@ # Clean up the chartjunk for i,ax in enumerate(axarr): - print i, ax + print(i, ax) # Remove the plot frame lines. They are unnecessary chartjunk. ax.spines["top"].set_visible(False) @@ -84,4 +84,4 @@ verticalalignment='center', transform = axarr[i].transAxes) - plt.savefig("{0}-{1}.png".format(args.infile,k), bbox_inches="tight") \ No newline at end of file + plt.savefig("{0}-{1}.png".format(args.infile,k), bbox_inches="tight") From 623ff399ff2c6aa3e3333e20ce58b1470ef07340 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Mon, 30 Oct 2017 07:25:30 +0300 Subject: [PATCH 042/172] Add gitter.im badge (#427) --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index e73f8ef..30b4ace 100644 --- a/README.rst +++ b/README.rst @@ -6,6 +6,9 @@ Python language bindings for ev3dev .. image:: https://readthedocs.org/projects/python-ev3dev/badge/?version=stable :target: http://python-ev3dev.readthedocs.org/en/stable/?badge=stable :alt: Documentation Status +.. image:: https://badges.gitter.im/ev3dev/chat.svg + :target: https://gitter.im/ev3dev/chat + :alt: Chat at https://gitter.im/ev3dev/chat A Python3 library implementing an interface for ev3dev_ devices, letting you control motors, sensors, hardware buttons, LCD From 923080bae8afd985a462f033728e605308fe860a Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Tue, 14 Nov 2017 05:08:46 -0800 Subject: [PATCH 043/172] Remove "connected" attribute (#402) * Remove "connected" attribute Fixes #327 * Fix things * I'm terrible at both staging and committing everything * Update error name and text * Fix things * Revert GyroBalancer changes * Strip exception context when device isn't connected --- ev3dev/__init__.py | 56 +++++++++++++++++++++------------------------ tests/api_tests.py | 17 ++++---------- utils/move_motor.py | 4 ---- 3 files changed, 30 insertions(+), 47 deletions(-) diff --git a/ev3dev/__init__.py b/ev3dev/__init__.py index 4a2eec2..74d26c6 100644 --- a/ev3dev/__init__.py +++ b/ev3dev/__init__.py @@ -124,13 +124,16 @@ def matches(attribute, pattern): yield f +class DeviceNotFound(Exception): + pass + # ----------------------------------------------------------------------------- # Define the base class from which all other ev3dev classes are defined. class Device(object): """The ev3dev device base class""" - __slots__ = ['_path', 'connected', '_device_index', 'kwargs'] + __slots__ = ['_path', '_device_index', 'kwargs'] DEVICE_ROOT_PATH = '/sys/class' @@ -158,7 +161,7 @@ def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs): d = ev3dev.Device('tacho-motor', address='outA') s = ev3dev.Device('lego-sensor', driver_name=['lego-ev3-us', 'lego-nxt-us']) - When connected succesfully, the `connected` attribute is set to True. + If there was no valid connected device, an error is thrown. """ classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) @@ -174,23 +177,24 @@ def get_index(file): if name_exact: self._path = classpath + '/' + name_pattern self._device_index = get_index(name_pattern) - self.connected = True else: try: name = next(list_device_names(classpath, name_pattern, **kwargs)) self._path = classpath + '/' + name self._device_index = get_index(name) - self.connected = True except StopIteration: self._path = None self._device_index = None - self.connected = False + raise DeviceNotFound("%s is not connected." % self) from None def __str__(self): if 'address' in self.kwargs: return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address')) else: return self.__class__.__name__ + + def __repr__(self): + return self.__str__() def _attribute_file_open(self, name): path = os.path.join(self._path, name) @@ -209,35 +213,27 @@ def _attribute_file_open(self, name): def _get_attribute(self, attribute, name): """Device attribute getter""" - if self.connected: + if attribute is None: + attribute = self._attribute_file_open( name ) + else: + attribute.seek(0) + return attribute, attribute.read().strip().decode() + + def _set_attribute(self, attribute, name, value): + """Device attribute setter""" + try: if attribute is None: attribute = self._attribute_file_open( name ) else: attribute.seek(0) - return attribute, attribute.read().strip().decode() - else: - #log.info("%s: path %s, attribute %s" % (self, self._path, name)) - raise Exception("%s is not connected" % self) - def _set_attribute(self, attribute, name, value): - """Device attribute setter""" - if self.connected: - try: - if attribute is None: - attribute = self._attribute_file_open( name ) - else: - attribute.seek(0) - - if isinstance(value, str): - value = value.encode() - attribute.write(value) - attribute.flush() - except Exception as ex: - self._raise_friendly_access_error(ex, name) - return attribute - else: - #log.info("%s: path %s, attribute %s" % (self, self._path, name)) - raise Exception("%s is not connected" % self) + if isinstance(value, str): + value = value.encode() + attribute.write(value) + attribute.flush() + except Exception as ex: + self._raise_friendly_access_error(ex, name) + return attribute def _raise_friendly_access_error(self, driver_error, attribute): if not isinstance(driver_error, OSError): @@ -256,7 +252,7 @@ def _raise_friendly_access_error(self, driver_error, attribute): # We will assume that a file-not-found error is the result of a disconnected device # rather than a library error. If that isn't the case, at a minimum the underlying # error info will be printed for debugging. - raise Exception("%s is no longer connected" % self) from driver_error + raise DeviceNotFound("%s is no longer connected" % self) from driver_error raise driver_error def get_attr_int(self, attribute, name): diff --git a/tests/api_tests.py b/tests/api_tests.py index 89b76d5..c67edd7 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -21,25 +21,20 @@ def test_device(self): populate_arena({'medium_motor' : [0, 'outA'], 'infrared_sensor' : [0, 'in1']}) d = ev3.Device('tacho-motor', 'motor*') - self.assertTrue(d.connected) d = ev3.Device('tacho-motor', 'motor0') - self.assertTrue(d.connected) d = ev3.Device('tacho-motor', 'motor*', driver_name='lego-ev3-m-motor') - self.assertTrue(d.connected) d = ev3.Device('tacho-motor', 'motor*', address='outA') - self.assertTrue(d.connected) - d = ev3.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') - self.assertTrue(not d.connected) + with self.assertRaises(ev3.DeviceNotFound): + d = ev3.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') d = ev3.Device('lego-sensor', 'sensor*') - self.assertTrue(d.connected) - d = ev3.Device('this-does-not-exist') - self.assertFalse(d.connected) + with self.assertRaises(ev3.DeviceNotFound): + d = ev3.Device('this-does-not-exist') def test_medium_motor(self): def dummy(self): @@ -53,8 +48,6 @@ def dummy(self): m = MediumMotor() - self.assertTrue(m.connected); - self.assertEqual(m.device_index, 0) # Check that reading twice works: @@ -86,8 +79,6 @@ def test_infrared_sensor(self): s = InfraredSensor() - self.assertTrue(s.connected) - self.assertEqual(s.device_index, 0) self.assertEqual(s.bin_data_format, 's8') self.assertEqual(s.bin_data(' Date: Thu, 23 Nov 2017 18:05:42 +0100 Subject: [PATCH 044/172] Proposed implementation for issue #403 (#431) * adds the play_note method and changes duration parameters to seconds * fixes invalid join() parameter * fixes wrong delay parameter sanity check and adds a couple of comments --- ev3dev/sound.py | 160 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 27 deletions(-) diff --git a/ev3dev/sound.py b/ev3dev/sound.py index 1dc75c8..b912dfc 100644 --- a/ev3dev/sound.py +++ b/ev3dev/sound.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') import os @@ -69,6 +69,10 @@ class Sound(object): ('G4', 'h'), ('D5', 'h') )) + + In order to mimic EV3-G API parameters, durations used in methods + exposed as EV3-G blocks for sound related operations are expressed + as a float number of seconds. """ channel = None @@ -85,7 +89,8 @@ class Sound(object): ) def _validate_play_type(self, play_type): - assert play_type in self.PLAY_TYPES, "Invalid play_type %s, must be one of %s" % (play_type, ','.join(self.PLAY_TYPES)) + assert play_type in self.PLAY_TYPES, \ + "Invalid play_type %s, must be one of %s" % (play_type, ','.join(str(t) for t in self.PLAY_TYPES)) def beep(self, args=''): """ @@ -131,6 +136,9 @@ def tone(self, *args): (392.00, 300, 150), (311.13, 250, 100), (466.16, 25, 100), (392, 700) ]).wait() + Have also a look at :py:meth:`play_song` for a more musician-friendly way of doing, which uses + the conventional notation for notes and durations. + .. rubric:: tone(frequency, duration) Play single tone of given frequency (Hz) and duration (milliseconds). @@ -138,9 +146,12 @@ def tone(self, *args): def play_tone_sequence(tone_sequence): def beep_args(frequency=None, duration=None, delay=None): args = '' - if frequency is not None: args += '-f %s ' % frequency - if duration is not None: args += '-l %s ' % duration - if delay is not None: args += '-D %s ' % delay + if frequency is not None: + args += '-f %s ' % frequency + if duration is not None: + args += '-l %s ' % duration + if delay is not None: + args += '-D %s ' % delay return args @@ -153,10 +164,37 @@ def beep_args(frequency=None, duration=None, delay=None): else: raise Exception("Unsupported number of parameters in Sound.tone()") - def play_tone(self, frequency, duration_ms, delay_ms=100, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): + def play_tone(self, frequency, duration, delay=0.0, volume=100, + play_type=PLAY_WAIT_FOR_COMPLETE): + """ Play a single tone, specified by its frequency, duration, volume and final delay. + + Args: + frequency (int): the tone frequency, in Hertz + duration (float): tone duration, in seconds + delay (float): delay after tone, in seconds (can be useful when chaining calls to ``play_tone``) + volume (int): sound volume in percent (between 0 and 100) + play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + + Returns: + the sound playing subprocess PID when no wait play type is selected, None otherwise + + Raises: + ValueError: if invalid value for parameter(s) + """ self._validate_play_type(play_type) + + if duration <= 0: + raise ValueError('invalid duration (%s)' % duration) + if delay < 0: + raise ValueError('invalid delay (%s)' % delay) + if not 0 < volume <= 100: + raise ValueError('invalid volume (%s)' % volume) + self.set_volume(volume) + duration_ms = int(duration * 1000) + delay_ms = int(delay * 1000) + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: play = self.tone([(frequency, duration_ms, delay_ms)]) play.wait() @@ -169,9 +207,43 @@ def play_tone(self, frequency, duration_ms, delay_ms=100, volume=100, play_type= play = self.tone([(frequency, duration_ms, delay_ms)]) play.wait() - def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): + def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): + """ Plays a note, given by its name as defined in ``_NOTE_FREQUENCIES``. + + Args: + note (str) the note symbol with its octave number + duration (float): tone duration, in seconds + volume (int) the play volume, in percent of maximum volume + play_type (int) the type of play (wait, no wait, loop), as defined + by the ``PLAY_xxx`` constants + + Returns: + the PID of the underlying beep command if no wait play type, None otherwise + + Raises: + ValueError: is invalid parameter (note, duration,...) """ - Play wav file. + self._validate_play_type(play_type) + try: + freq = self._NOTE_FREQUENCIES[note.upper()] + except KeyError: + raise ValueError('invalid note (%s)' % note) + if duration <= 0: + raise ValueError('invalid duration (%s)' % duration) + if not 0 < volume <= 100: + raise ValueError('invalid volume (%s)' % volume) + + return self.play_tone(freq, duration=duration, volume=volume, play_type=play_type) + + def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): + """ Play a sound file (wav format). + + Args: + wav_file (str): the sound file path + play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + + Returns: + subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise """ self._validate_play_type(play_type) @@ -191,18 +263,40 @@ def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): pid.wait() def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): + """ Play a sound file (wav format) at a given volume. + + Args: + wav_file (str): the sound file path + volume (int) the play volume, in percent of maximum volume + play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + + Returns: + subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise + """ self.set_volume(volume) self.play(wav_file, play_type) def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): - """ - Speak the given text aloud. + """ Speak the given text aloud. + + Uses the ``espeak`` external command. + + Args: + text (str): the text to speak + espeak_opts (str): espeak command options + volume (int) the play volume, in percent of maximum volume + play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + + Returns: + subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise """ self._validate_play_type(play_type) self.set_volume(volume) with open(os.devnull, 'w') as n: - cmd_line = '/usr/bin/espeak --stdout {0} "{1}" | /usr/bin/aplay -q'.format(espeak_opts, text) + cmd_line = '/usr/bin/espeak --stdout {0} "{1}" | /usr/bin/aplay -q'.format( + espeak_opts, text + ) if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: play = Popen(cmd_line, stdout=n, shell=True) @@ -218,8 +312,8 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA def _get_channel(self): """ - :return: the detected sound channel - :rtype: str + Returns: + str: the detected sound channel """ if self.channel is None: # Get default channel as the first one that pops up in @@ -229,7 +323,7 @@ def _get_channel(self): # Simple mixer control 'Master',0 # Simple mixer control 'Capture',0 out = check_output(['amixer', 'scontrols']).decode() - m = re.search("'(?P[^']+)'", out) + m = re.search(r"'(?P[^']+)'", out) if m: self.channel = m.group('channel') else: @@ -265,13 +359,13 @@ def get_volume(self, channel=None): channel = self._get_channel() out = check_output(['amixer', 'get', channel]).decode() - m = re.search('\[(?P\d+)%\]', out) + m = re.search(r'\[(?P\d+)%\]', out) if m: return int(m.group('volume')) else: raise Exception('Failed to parse output of `amixer get {}`'.format(channel)) - def play_song(self, song, tempo=120, delay=50): + def play_song(self, song, tempo=120, delay=0.05): """ Plays a song provided as a list of tuples containing the note name and its value using music conventional notation instead of numerical values for frequency and duration. @@ -329,12 +423,21 @@ def play_song(self, song, tempo=120, delay=50): Args: song (iterable[tuple(str, str)]): the song tempo (int): the song tempo, given in quarters per minute - delay (int): delay in ms between notes + delay (float): delay between notes (in seconds) Returns: subprocess.Popen: the spawn subprocess + + Raises: + ValueError: if invalid note in song or invalid play parameters """ - meas_duration = 60000 / tempo * 4 + if tempo <= 0: + raise ValueError('invalid tempo (%s)' % tempo) + if delay < 0: + raise ValueError('invalid delay (%s)' % delay) + + delay_ms = int(delay * 1000) + meas_duration_ms = 60000 / tempo * 4 # we only support 4/4 bars, hence "* 4" def beep_args(note, value): """ Builds the arguments string for producing a beep matching @@ -349,24 +452,27 @@ def beep_args(note, value): freq = self._NOTE_FREQUENCIES[note.upper()] if '/' in value: base, factor = value.split('/') - duration = meas_duration * self._NOTE_VALUES[base] / float(factor) + duration_ms = meas_duration_ms * self._NOTE_VALUES[base] / float(factor) elif '*' in value: base, factor = value.split('*') - duration = meas_duration * self._NOTE_VALUES[base] * float(factor) + duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * float(factor) elif value.endswith('.'): base = value[:-1] - duration = meas_duration * self._NOTE_VALUES[base] * 1.5 + duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * 1.5 elif value.endswith('3'): base = value[:-1] - duration = meas_duration * self._NOTE_VALUES[base] * 2 / 3 + duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * 2 / 3 else: - duration = meas_duration * self._NOTE_VALUES[value] + duration_ms = meas_duration_ms * self._NOTE_VALUES[value] - return '-f %d -l %d -D %d' % (freq, duration, delay) + return '-f %d -l %d -D %d' % (freq, duration_ms, delay_ms) - return self.beep(' -n '.join( - [beep_args(note, value) for (note, value) in song] - )) + try: + return self.beep(' -n '.join( + [beep_args(note, value) for (note, value) in song] + )) + except KeyError as e: + raise ValueError('invalid note (%s)' % e) #: Note frequencies. #: From 78470e5df3334b5f144f4c7a8dcd743bd71dfff9 Mon Sep 17 00:00:00 2001 From: vagoston Date: Fri, 8 Dec 2017 22:15:40 +0000 Subject: [PATCH 045/172] Fix description of EV3 color sensor's "ambient" mode (#436) https://github.com/ev3dev/ev3dev/issues/317 Indeed, ambient mode turns the led to blue. --- ev3dev/sensor/lego.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ev3dev/sensor/lego.py b/ev3dev/sensor/lego.py index b659959..581795f 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev/sensor/lego.py @@ -114,7 +114,7 @@ class ColorSensor(Sensor): #: Reflected light. Red LED on. MODE_COL_REFLECT = 'COL-REFLECT' - #: Ambient light. Red LEDs off. + #: Ambient light. Blue LEDs on. MODE_COL_AMBIENT = 'COL-AMBIENT' #: Color. All LEDs rapidly cycling, appears white. From 7578c1f123f84b4f6cf9553f058b8e64e4ac3e97 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Mon, 8 Jan 2018 19:23:15 +0300 Subject: [PATCH 046/172] Fix documentation for Motor.position_sp s/counts_per_rot/count_per_rot See #439 --- ev3dev/motor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ev3dev/motor.py b/ev3dev/motor.py index 288d97f..4b683d0 100644 --- a/ev3dev/motor.py +++ b/ev3dev/motor.py @@ -477,7 +477,7 @@ def position_sp(self): """ Writing specifies the target position for the `run-to-abs-pos` and `run-to-rel-pos` commands. Reading returns the current value. Units are in tacho counts. You - can use the value returned by `counts_per_rot` to convert tacho counts to/from + can use the value returned by `count_per_rot` to convert tacho counts to/from rotations or degrees. """ self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') From ee75c5c0489e98257eb7b5bfb07487cdbae6844b Mon Sep 17 00:00:00 2001 From: Greg Cowell Date: Fri, 12 Jan 2018 02:12:14 +1000 Subject: [PATCH 047/172] Separate GyroBalancer drive control loop from balance loop (#432) * Separate GyroBalancer drive control loop from balance loop * Add signal handlers and math.pi --- ev3dev/control/GyroBalancer.py | 821 +++++++++++++++++---------------- ev3dev/sensor/lego.py | 14 + 2 files changed, 437 insertions(+), 398 deletions(-) diff --git a/ev3dev/control/GyroBalancer.py b/ev3dev/control/GyroBalancer.py index 73b08b0..1d516e6 100644 --- a/ev3dev/control/GyroBalancer.py +++ b/ev3dev/control/GyroBalancer.py @@ -29,44 +29,50 @@ import logging import time +import json +import queue +import threading +import math +import signal from collections import deque from ev3dev.power import PowerSupply from ev3dev.motor import LargeMotor, OUTPUT_A, OUTPUT_D -from ev3dev.sensor.lego import GyroSensor, InfraredSensor, TouchSensor +from ev3dev.sensor.lego import GyroSensor, TouchSensor +from ev3dev.sound import Sound +from collections import OrderedDict log = logging.getLogger(__name__) -######################################################################## -# File I/O functions -######################################################################## +# Constants +RAD_PER_DEG = math.pi / 180 +# EV3 Platform specific constants -def FastRead(infile): - """Function for fast reading from sensor files.""" - infile.seek(0) - return(int(infile.read().decode().strip())) +# For the LEGO EV3 Gyro in Rate mode, 1 unit = 1 deg/s +DEG_PER_SEC_PER_RAW_GYRO_UNIT = 1 +# Express the above as the rate in rad/s per gyro unit +RAD_PER_SEC_PER_RAW_GYRO_UNIT = DEG_PER_SEC_PER_RAW_GYRO_UNIT * RAD_PER_DEG -def FastWrite(outfile, value): - """Function for fast writing to motor files.""" - outfile.truncate(0) - outfile.write(str(int(value))) - outfile.flush() +# For the LEGO EV3 Large Motor 1 unit = 1 deg +DEG_PER_RAW_MOTOR_UNIT = 1 +# Express the above as the angle in rad per motor unit +RAD_PER_RAW_MOTOR_UNIT = DEG_PER_RAW_MOTOR_UNIT * RAD_PER_DEG -def SetDuty(motorDutyFileHandle, duty, frictionOffset, voltageCompensation): - """Function to set the duty cycle of the motors.""" - # Compensate for nominal voltage and round the input - dutyInt = int(round(duty*voltageCompensation)) +# On the EV3, "1% speed" corresponds to 1.7 RPM (if speed +# control were enabled). +RPM_PER_PERCENT_SPEED = 1.7 - # Add or subtract offset and clamp the value between -100 and 100 - if dutyInt > 0: - dutyInt = min(100, dutyInt + frictionOffset) - elif dutyInt < 0: - dutyInt = max(-100, dutyInt - frictionOffset) +# Convert this number to the speed in deg/s per "percent speed" +DEG_PER_SEC_PER_PERCENT_SPEED = RPM_PER_PERCENT_SPEED * 360 / 60 - # Apply the signal to the motor - FastWrite(motorDutyFileHandle, dutyInt) +# Convert this number to the speed in rad/s per "percent speed" +RAD_PER_SEC_PER_PERCENT_SPEED = DEG_PER_SEC_PER_PERCENT_SPEED * RAD_PER_DEG + +# Speed and steering limits +SPEED_MAX = 20 +STEER_MAX = 8 class GyroBalancer(object): @@ -83,12 +89,13 @@ def __init__(self, gain_motor_angular_speed=9, gain_motor_angle_error_accumulated=3, power_voltage_nominal=8.0, - power_friction_offset_nominal=3, - timing_loop_time_msec=30, - timing_motor_angle_history_length=5, - timing_gyro_drift_compensation_factor=0.05, + pwr_friction_offset_nom=3, + timing_loop_msec=30, + motor_angle_history_length=5, + gyro_drift_compensation_factor=0.05, left_motor_port=OUTPUT_D, - right_motor_port=OUTPUT_A): + right_motor_port=OUTPUT_A, + debug=False): """Create GyroBalancer.""" # Gain parameters self.gain_gyro_angle = gain_gyro_angle @@ -100,383 +107,401 @@ def __init__(self, # Power parameters self.power_voltage_nominal = power_voltage_nominal - self.power_friction_offset_nominal = power_friction_offset_nominal + self.pwr_friction_offset_nom = pwr_friction_offset_nom # Timing parameters - self.timing_loop_time_msec = timing_loop_time_msec - self.timing_motor_angle_history_length =\ - timing_motor_angle_history_length - self.timing_gyro_drift_compensation_factor =\ - timing_gyro_drift_compensation_factor + self.timing_loop_msec = timing_loop_msec + self.motor_angle_history_length = motor_angle_history_length + self.gyro_drift_compensation_factor = gyro_drift_compensation_factor - # EV3 Brick - self.powerSupply = PowerSupply() - # buttons = ev3.Button() + # Power supply setup + self.power_supply = PowerSupply() # Gyro Sensor setup - self.gyroSensor = GyroSensor() - self.gyroSensor.mode = self.gyroSensor.MODE_GYRO_RATE - self.gyroSensorValueRaw = open(self.gyroSensor._path + "/value0", "rb") + self.gyro = GyroSensor() + self.gyro.mode = self.gyro.MODE_GYRO_RATE # Touch Sensor setup - self.touchSensor = TouchSensor() - self.touchSensorValueRaw = open(self.touchSensor._path + "/value0", - "rb") + self.touch = TouchSensor() # IR Buttons setup - self.irRemote = InfraredSensor() - self.irRemote.mode = self.irRemote.MODE_IR_REMOTE - self.irRemoteValueRaw = open(self.irRemote._path + "/value0", "rb") + # self.remote = InfraredSensor() + # self.remote.mode = self.remote.MODE_IR_REMOTE # Configure the motors - self.motorLeft = LargeMotor(left_motor_port) - self.motorRight = LargeMotor(right_motor_port) - - def main(self): - """Make the robot go.""" - def shutdown(): - """Close all file handles and stop all motors.""" - self.touchSensorValueRaw.close() - self.gyroSensorValueRaw.close() - motorEncoderLeft.close() - motorEncoderRight.close() - motorDutyCycleLeft.close() - motorDutyCycleRight.close() - self.motorLeft.stop() - self.motorRight.stop() - - try: - while True: - - ############################################################### - # Hardware (Re-)Config - ############################################################### - - # Reset the motors - self.motorLeft.reset() # Reset the encoder - self.motorRight.reset() - self.motorLeft.run_direct() # Set to run direct mode - self.motorRight.run_direct() - - # Open sensor files for (fast) reading - motorEncoderLeft = open(self.motorLeft._path + "/position", - "rb") - motorEncoderRight = open(self.motorRight._path + "/position", - "rb") - - # Open motor files for (fast) writing - motorDutyCycleLeft = open(self.motorLeft._path + - "/duty_cycle_sp", "w") - motorDutyCycleRight = open(self.motorRight._path + - "/duty_cycle_sp", "w") - - ############################################################### - # Definitions and Initialization variables - ############################################################### - - # Math constants - - # The number of radians in a degree. - radiansPerDegree = 3.14159/180 - - # Platform specific constants and conversions - - # For the LEGO EV3 Gyro in Rate mode, 1 unit = 1 deg/s - degPerSecondPerRawGyroUnit = 1 - - # Express the above as the rate in rad/s per gyro unit - radiansPerSecondPerRawGyroUnit = degPerSecondPerRawGyroUnit *\ - radiansPerDegree - - # For the LEGO EV3 Large Motor 1 unit = 1 deg - degPerRawMotorUnit = 1 - - # Express the above as the angle in rad per motor unit - radiansPerRawMotorUnit = degPerRawMotorUnit * radiansPerDegree - - # On the EV3, "1% speed" corresponds to 1.7 RPM (if speed - # control were enabled). - RPMperPerPercentSpeed = 1.7 - - # Convert this number to the speed in deg/s per "percent speed" - degPerSecPerPercentSpeed = RPMperPerPercentSpeed * 360 / 60 - - # Convert this number to the speed in rad/s per "percent speed" - radPerSecPerPercentSpeed = degPerSecPerPercentSpeed *\ - radiansPerDegree - - # Variables representing physical signals - # (more info on these in the docs) - - # The angle of "the motor", measured in raw units, - # degrees for the EV3). - # We will take the average of both motor positions as - # "the motor" angle, which is essentially how far the middle - # of the robot has traveled. - motorAngleRaw = 0 - - # The angle of the motor, converted to radians (2*pi radians - # equals 360 degrees). - motorAngle = 0 - - # The reference angle of the motor. The robot will attempt to - # drive forward or backward, such that its measured position - motorAngleReference = 0 - # equals this reference (or close enough). - - # The error: the deviation of the measured motor angle from the - # reference. The robot attempts to make this zero, by driving - # toward the reference. - motorAngleError = 0 - - # We add up all of the motor angle error in time. If this value - # gets out of hand, we can use it to drive the robot back to - # the reference position a bit quicker. - motorAngleErrorAccumulated = 0 - - # The motor speed, estimated by how far the motor has turned in - # a given amount of time. - motorAngularSpeed = 0 - - # The reference speed during manouvers: how fast we would like - # to drive, measured in radians per second. - motorAngularSpeedReference = 0 - - # The error: the deviation of the motor speed from the - # reference speed. - motorAngularSpeedError = 0 - - # The 'voltage' signal we send to the motor. - # We calculate a new value each time, just right to keep the - # robot upright. - motorDutyCycle = 0 - - # The raw value from the gyro sensor in rate mode. - gyroRateRaw = 0 - - # The angular rate of the robot (how fast it is falling forward - # or backward), measured in radians per second. - gyroRate = 0 - - # The gyro doesn't measure the angle of the robot, but we can - # estimate this angle by keeping track of the gyroRate value in - # time. - gyroEstimatedAngle = 0 - - # Over time, the gyro rate value can drift. This causes the - # sensor to think it is moving even when it is perfectly still. - # We keep track of this offset. - gyroOffset = 0 - - log.info("Hold robot upright. Press Touch Sensor to start.") - - self.touchSensor.wait_for_bump() - - # Read battery voltage - voltageIdle = self.powerSupply.measured_volts - voltageCompensation = self.power_voltage_nominal/voltageIdle - - # Offset to limit friction deadlock - frictionOffset = int(round(self.power_friction_offset_nominal * - voltageCompensation)) - - # Timing settings for the program - # Time of each loop, measured in seconds. - loopTimeSec = self.timing_loop_time_msec / 1000 - loopCount = 0 # Loop counter, starting at 0 - - # A deque (a fifo array) which we'll use to keep track of - # previous motor positions, which we can use to calculate the - # rate of change (speed) - motorAngleHistory =\ - deque([0], self.timing_motor_angle_history_length) - - # The rate at which we'll update the gyro offset (precise - # definition given in docs) - gyroDriftCompensationRate =\ - self.timing_gyro_drift_compensation_factor *\ - loopTimeSec * radiansPerSecondPerRawGyroUnit - - ############################################################### - # Calibrate Gyro - ############################################################### - - log.info("-----------------------------------") - log.info("Calibrating...") - - # As you hold the robot still, determine the average sensor - # value of 100 samples - gyroRateCalibrateCount = 100 - for i in range(gyroRateCalibrateCount): - gyroOffset = gyroOffset + FastRead(self.gyroSensorValueRaw) - time.sleep(0.01) - gyroOffset = gyroOffset/gyroRateCalibrateCount - - # Print the result - log.info("GyroOffset: " + str(gyroOffset)) - log.info("-----------------------------------") - log.info("GO!") - log.info("-----------------------------------") - log.info("Press Touch Sensor to re-start.") - log.info("-----------------------------------") - - ############################################################### - # Balancing Loop - ############################################################### - - # Remember start time - tProgramStart = time.time() - - # Initial fast read touch sensor value - touchSensorPressed = False - - # Keep looping until Touch Sensor is pressed again - while not touchSensorPressed: - - ########################################################### - # Loop info - ########################################################### - loopCount = loopCount + 1 - tLoopStart = time.time() - - ########################################################### - # Driving and Steering. - ########################################################### - - # Just balance in place: - speed = 0 - steering = 0 - - # Control speed and steering based on the IR Remote - buttonCode = FastRead(self.irRemoteValueRaw) - - speed_max = 20 - steer_max_right = 8 - - if(buttonCode == 5): - speed = speed_max - steering = 0 - elif (buttonCode == 6): - speed = 0 - steering = -steer_max_right - elif (buttonCode == 7): - speed = 0 - steering = steer_max_right - elif (buttonCode == 8): - speed = -speed_max - steering = 0 - else: - speed = 0 - steering = 0 - - ########################################################### - # Reading the Gyro. - ########################################################### - gyroRateRaw = FastRead(self.gyroSensorValueRaw) - gyroRate = (gyroRateRaw - gyroOffset) *\ - radiansPerSecondPerRawGyroUnit - - ########################################################### - # Reading the Motor Position - ########################################################### - - motorAngleRaw = (FastRead(motorEncoderLeft) + - FastRead(motorEncoderRight)) / 2 - motorAngle = motorAngleRaw*radiansPerRawMotorUnit - - motorAngularSpeedReference = speed*radPerSecPerPercentSpeed - motorAngleReference = motorAngleReference +\ - motorAngularSpeedReference * loopTimeSec - - motorAngleError = motorAngle - motorAngleReference - - ########################################################### - # Computing Motor Speed - ########################################################### - - motorAngularSpeed = (motorAngle - motorAngleHistory[0]) /\ - (self.timing_motor_angle_history_length*loopTimeSec) - motorAngularSpeedError = motorAngularSpeed - motorAngleHistory.append(motorAngle) - - ########################################################### - # Computing the motor duty cycle value - ########################################################### - - motorDutyCycle =\ - (self.gain_gyro_angle * gyroEstimatedAngle + - self.gain_gyro_rate * gyroRate + - self.gain_motor_angle * motorAngleError + - self.gain_motor_angular_speed * - motorAngularSpeedError + - self.gain_motor_angle_error_accumulated * - motorAngleErrorAccumulated) - - ########################################################### - # Apply the signal to the motor, and add steering - ########################################################### - - SetDuty(motorDutyCycleRight, motorDutyCycle + - steering, frictionOffset, voltageCompensation) - SetDuty(motorDutyCycleLeft, motorDutyCycle - steering, - frictionOffset, voltageCompensation) - - ########################################################### - # Update angle estimate and Gyro Offset Estimate - ########################################################### - - gyroEstimatedAngle = gyroEstimatedAngle + gyroRate *\ - loopTimeSec - gyroOffset = (1 - gyroDriftCompensationRate) *\ - gyroOffset + gyroDriftCompensationRate * gyroRateRaw - - ########################################################### - # Update Accumulated Motor Error - ########################################################### - - motorAngleErrorAccumulated = motorAngleErrorAccumulated +\ - motorAngleError*loopTimeSec - - ########################################################### - # Read the touch sensor (the kill switch) - ########################################################### - - touchSensorPressed = FastRead(self.touchSensorValueRaw) - - ########################################################### - # Busy wait for the loop to complete - ########################################################### - - while(time.time() - tLoopStart < loopTimeSec): - time.sleep(0.0001) - - ############################################################### - # - # Closing down & Cleaning up - # - ############################################################### - - # Loop end time, for stats - tProgramEnd = time.time() - - # Turn off the motors - FastWrite(motorDutyCycleLeft, 0) - FastWrite(motorDutyCycleRight, 0) - - # Wait for the Touch Sensor to be released - while self.touchSensor.is_pressed: - time.sleep(0.01) - - # Calculate loop time - tLoop = (tProgramEnd - tProgramStart)/loopCount - log.info("Loop time:" + str(tLoop*1000) + "ms") - - # Print a stop message - log.info("-----------------------------------") - log.info("STOP") - log.info("-----------------------------------") - - # Exit cleanly so that all motors are stopped - except (KeyboardInterrupt, Exception) as e: - log.exception(e) - shutdown() + self.motor_left = LargeMotor(left_motor_port) + self.motor_right = LargeMotor(right_motor_port) + + # Sound setup + self.sound = Sound() + + # Open sensor and motor files + self.gyro_file = open(self.gyro._path + "/value0", "rb") + self.touch_file = open(self.touch._path + "/value0", "rb") + self.encoder_left_file = open(self.motor_left._path + "/position", + "rb") + self.encoder_right_file = open(self.motor_right._path + "/position", + "rb") + self.dc_left_file = open(self.motor_left._path + "/duty_cycle_sp", "w") + self.dc_right_file = open(self.motor_right._path + "/duty_cycle_sp", + "w") + + # Drive queue + self.drive_queue = queue.Queue() + + # Stop event for balance thread + self.stop_balance = threading.Event() + + # Debugging + self.debug = debug + + # Handlers for SIGINT and SIGTERM + signal.signal(signal.SIGINT, self.signal_int_handler) + signal.signal(signal.SIGTERM, self.signal_term_handler) + + def shutdown(self): + """Close all file handles and stop all motors.""" + self.stop_balance.set() # Stop balance thread + self.motor_left.stop() + self.motor_right.stop() + self.gyro_file.close() + self.touch_file.close() + self.encoder_left_file.close() + self.encoder_right_file.close() + self.dc_left_file.close() + self.dc_right_file.close() + + def _fast_read(self, infile): + """Function for fast reading from sensor files.""" + infile.seek(0) + return(int(infile.read().decode().strip())) + + def _fast_write(self, outfile, value): + """Function for fast writing to motor files.""" + outfile.truncate(0) + outfile.write(str(int(value))) + outfile.flush() + + def _set_duty(self, motor_duty_file, duty, friction_offset, + voltage_comp): + """Function to set the duty cycle of the motors.""" + # Compensate for nominal voltage and round the input + duty_int = int(round(duty*voltage_comp)) + + # Add or subtract offset and clamp the value between -100 and 100 + if duty_int > 0: + duty_int = min(100, duty_int + friction_offset) + elif duty_int < 0: + duty_int = max(-100, duty_int - friction_offset) + + # Apply the signal to the motor + self._fast_write(motor_duty_file, duty_int) + + def signal_int_handler(self, signum, frame): + """Signal handler for SIGINT.""" + log.info('"Caught SIGINT') + self.shutdown() + raise GracefulShutdown() + + def signal_term_handler(self, signum, frame): + """Signal handler for SIGTERM.""" + log.info('"Caught SIGTERM') + self.shutdown() + raise GracefulShutdown() + + def balance(self): + """Run the _balance method as a thread.""" + balance_thread = threading.Thread(target=self._balance) + balance_thread.start() + + def _balance(self): + """Make the robot balance.""" + while True and not self.stop_balance.is_set(): + + # Reset the motors + self.motor_left.reset() # Reset the encoder + self.motor_right.reset() + self.motor_left.run_direct() # Set to run direct mode + self.motor_right.run_direct() + + # Initialize variables representing physical signals + # (more info on these in the docs) + + # The angle of "the motor", measured in raw units, + # degrees for the EV3). + # We will take the average of both motor positions as + # "the motor" angle, which is essentially how far the middle + # of the robot has travelled. + motor_angle_raw = 0 + + # The angle of the motor, converted to RAD (2*pi RAD + # equals 360 degrees). + motor_angle = 0 + + # The reference angle of the motor. The robot will attempt to + # drive forward or backward, such that its measured position + motor_angle_ref = 0 + # equals this reference (or close enough). + + # The error: the deviation of the measured motor angle from the + # reference. The robot attempts to make this zero, by driving + # toward the reference. + motor_angle_error = 0 + + # We add up all of the motor angle error in time. If this value + # gets out of hand, we can use it to drive the robot back to + # the reference position a bit quicker. + motor_angle_error_acc = 0 + + # The motor speed, estimated by how far the motor has turned in + # a given amount of time. + motor_angular_speed = 0 + + # The reference speed during manouvers: how fast we would like + # to drive, measured in RAD per second. + motor_angular_speed_ref = 0 + + # The error: the deviation of the motor speed from the + # reference speed. + motor_angular_speed_error = 0 + + # The 'voltage' signal we send to the motor. + # We calculate a new value each time, just right to keep the + # robot upright. + motor_duty_cycle = 0 + + # The raw value from the gyro sensor in rate mode. + gyro_rate_raw = 0 + + # The angular rate of the robot (how fast it is falling forward + # or backward), measured in RAD per second. + gyro_rate = 0 + + # The gyro doesn't measure the angle of the robot, but we can + # estimate this angle by keeping track of the gyro_rate value + # in time. + gyro_est_angle = 0 + + # Over time, the gyro rate value can drift. This causes the + # sensor to think it is moving even when it is perfectly still. + # We keep track of this offset. + gyro_offset = 0 + + # Start + log.info("Hold robot upright. Press touch sensor to start.") + self.sound.speak("Press touch sensor to start.") + + self.touch.wait_for_bump() + + # Read battery voltage + voltage_idle = self.power_supply.measured_volts + voltage_comp = self.power_voltage_nominal / voltage_idle + + # Offset to limit friction deadlock + friction_offset = int(round(self.pwr_friction_offset_nom * + voltage_comp)) + + # Timing settings for the program + # Time of each loop, measured in seconds. + loop_time_target = self.timing_loop_msec / 1000 + loop_count = 0 # Loop counter, starting at 0 + + # A deque (a fifo array) which we'll use to keep track of + # previous motor positions, which we can use to calculate the + # rate of change (speed) + motor_angle_hist =\ + deque([0], self.motor_angle_history_length) + + # The rate at which we'll update the gyro offset (precise + # definition given in docs) + gyro_drift_comp_rate =\ + self.gyro_drift_compensation_factor *\ + loop_time_target * RAD_PER_SEC_PER_RAW_GYRO_UNIT + + # Calibrate Gyro + log.info("-----------------------------------") + log.info("Calibrating...") + + # As you hold the robot still, determine the average sensor + # value of 100 samples + gyro_calibrate_count = 100 + for i in range(gyro_calibrate_count): + gyro_offset = gyro_offset + self._fast_read(self.gyro_file) + time.sleep(0.01) + gyro_offset = gyro_offset / gyro_calibrate_count + + # Print the result + log.info("gyro_offset: " + str(gyro_offset)) + log.info("-----------------------------------") + log.info("GO!") + log.info("-----------------------------------") + log.info("Press Touch Sensor to re-start.") + log.info("-----------------------------------") + self.sound.beep() + + # Remember start time + prog_start_time = time.time() + + if self.debug: + # Data logging + data = OrderedDict() + loop_times = OrderedDict() + data['loop_times'] = loop_times + gyro_readings = OrderedDict() + data['gyro_readings'] = gyro_readings + + # Initial fast read touch sensor value + touch_pressed = False + + # Driving and Steering + speed, steering = (0, 0) + + # Record start time of loop + loop_start_time = time.time() + + # Balancing Loop + while not touch_pressed and not self.stop_balance.is_set(): + + loop_count += 1 + + # Check for drive instructions and set speed / steering + try: + speed, steering = self.drive_queue.get_nowait() + self.drive_queue.task_done() + except queue.Empty: + pass + + # Read the touch sensor (the kill switch) + touch_pressed = self._fast_read(self.touch_file) + + # Read the Motor Position + motor_angle_raw = ((self._fast_read(self.encoder_left_file) + + self._fast_read(self.encoder_right_file)) / + 2.0) + motor_angle = motor_angle_raw * RAD_PER_RAW_MOTOR_UNIT + + # Read the Gyro + gyro_rate_raw = self._fast_read(self.gyro_file) + + # Busy wait for the loop to reach target time length + loop_time = 0 + while(loop_time < loop_time_target): + loop_time = time.time() - loop_start_time + time.sleep(0.001) + + # Calculate most recent loop time + loop_time = time.time() - loop_start_time + + # Set start time of next loop + loop_start_time = time.time() + + if self.debug: + # Log gyro data and loop time + time_of_sample = time.time() - prog_start_time + gyro_readings[time_of_sample] = gyro_rate_raw + loop_times[time_of_sample] = loop_time * 1000.0 + + # Calculate gyro rate + gyro_rate = (gyro_rate_raw - gyro_offset) *\ + RAD_PER_SEC_PER_RAW_GYRO_UNIT + + # Calculate Motor Parameters + motor_angular_speed_ref =\ + speed * RAD_PER_SEC_PER_PERCENT_SPEED + motor_angle_ref = motor_angle_ref +\ + motor_angular_speed_ref * loop_time_target + motor_angle_error = motor_angle - motor_angle_ref + + # Compute Motor Speed + motor_angular_speed =\ + ((motor_angle - motor_angle_hist[0]) / + (self.motor_angle_history_length * loop_time_target)) + motor_angular_speed_error = motor_angular_speed + motor_angle_hist.append(motor_angle) + + # Compute the motor duty cycle value + motor_duty_cycle =\ + (self.gain_gyro_angle * gyro_est_angle + + self.gain_gyro_rate * gyro_rate + + self.gain_motor_angle * motor_angle_error + + self.gain_motor_angular_speed * + motor_angular_speed_error + + self.gain_motor_angle_error_accumulated * + motor_angle_error_acc) + + # Apply the signal to the motor, and add steering + self._set_duty(self.dc_right_file, motor_duty_cycle + steering, + friction_offset, voltage_comp) + self._set_duty(self.dc_left_file, motor_duty_cycle - steering, + friction_offset, voltage_comp) + + # Update angle estimate and gyro offset estimate + gyro_est_angle = gyro_est_angle + gyro_rate *\ + loop_time_target + gyro_offset = (1 - gyro_drift_comp_rate) *\ + gyro_offset + gyro_drift_comp_rate * gyro_rate_raw + + # Update Accumulated Motor Error + motor_angle_error_acc = motor_angle_error_acc +\ + motor_angle_error * loop_time_target + + # Closing down & Cleaning up + + # Loop end time, for stats + prog_end_time = time.time() + + # Turn off the motors + self._fast_write(self.dc_left_file, 0) + self._fast_write(self.dc_right_file, 0) + + # Wait for the Touch Sensor to be released + while self.touch.is_pressed: + time.sleep(0.01) + + # Calculate loop time + avg_loop_time = (prog_end_time - prog_start_time) / loop_count + log.info("Loop time:" + str(avg_loop_time * 1000) + "ms") + + # Print a stop message + log.info("-----------------------------------") + log.info("STOP") + log.info("-----------------------------------") + + if self.debug: + # Dump logged data to file + with open("data.txt", 'w') as data_file: + json.dump(data, data_file) + + def _move(self, speed=0, steering=0, seconds=None): + """Move robot.""" + self.drive_queue.put((speed, steering)) + if seconds is not None: + time.sleep(seconds) + self.drive_queue.put((0, 0)) + self.drive_queue.join() + + def move_forward(self, seconds=None): + """Move robot forward.""" + self._move(speed=SPEED_MAX, steering=0, seconds=seconds) + + def move_backward(self, seconds=None): + """Move robot backward.""" + self._move(speed=-SPEED_MAX, steering=0, seconds=seconds) + + def rotate_left(self, seconds=None): + """Rotate robot left.""" + self._move(speed=0, steering=STEER_MAX, seconds=seconds) + + def rotate_right(self, seconds=None): + """Rotate robot right.""" + self._move(speed=0, steering=-STEER_MAX, seconds=seconds) + + def stop(self): + """Stop robot (balancing will continue).""" + self._move(speed=0, steering=0) + + +class GracefulShutdown(Exception): + """Custom exception for SIGINT and SIGTERM.""" + + pass diff --git a/ev3dev/sensor/lego.py b/ev3dev/sensor/lego.py index 581795f..08bafc3 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev/sensor/lego.py @@ -612,6 +612,20 @@ class InfraredSensor(Sensor, ButtonBase): _BUTTONS = ('top_left', 'bottom_left', 'top_right', 'bottom_right', 'beacon') + # Button codes for doing rapid check of remote status + NO_BUTTON = 0 + TOP_LEFT = 1 + BOTTOM_LEFT = 2 + TOP_RIGHT = 3 + BOTTOM_RIGHT = 4 + TOP_LEFT_TOP_RIGHT = 5 + TOP_LEFT_BOTTOM_RIGHT = 6 + BOTTOM_LEFT_TOP_RIGHT = 7 + BOTTOM_LEFT_BOTTOM_RIGHT = 8 + BEACON = 9 + TOP_LEFT_BOTTOM_LEFT = 10 + TOP_RIGHT_BOTTOM_RIGHT = 11 + # See process() for an explanation on how to use these #: Handles ``Red Up``, etc events on channel 1 on_channel1_top_left = None From fb7b88574207e1f83276358009b467cd8405cd1b Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Mon, 8 Jan 2018 21:02:12 +0300 Subject: [PATCH 048/172] Bring documentation up-to-date with the develop branch Uses the new project structure in sphinx docs, fixes a couple of docstring issues. Fixes #441 --- docs/motors.rst | 2 +- docs/other.rst | 46 ++++++++------------------------------- docs/sensors.rst | 18 +++++++-------- docs/spec.rst | 10 ++++----- ev3dev/display.py | 4 ++-- ev3dev/port.py | 1 + ev3dev/sensor/__init__.py | 2 +- ev3dev/sound.py | 2 +- 8 files changed, 28 insertions(+), 57 deletions(-) diff --git a/docs/motors.rst b/docs/motors.rst index 7017160..927cee1 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -1,7 +1,7 @@ Motor classes ============= -.. currentmodule:: ev3dev.core +.. currentmodule:: ev3dev.motor Tacho motor ----------- diff --git a/docs/other.rst b/docs/other.rst index 4583ce9..3c68071 100644 --- a/docs/other.rst +++ b/docs/other.rst @@ -1,38 +1,10 @@ Other classes ============= -.. currentmodule:: ev3dev.core - -Remote Control --------------- - -.. autoclass:: RemoteControl - :members: - :inherited-members: - - .. rubric:: Event handlers - - These will be called when state of the corresponding button is changed: - - .. py:data:: on_red_up - .. py:data:: on_red_down - .. py:data:: on_blue_up - .. py:data:: on_blue_down - .. py:data:: on_beacon - - .. rubric:: Member functions and properties - -Beacon Seeker -------------- - -.. autoclass:: BeaconSeeker - :members: - :inherited-members: - Button ------ -.. autoclass:: ev3dev.ev3.Button +.. autoclass:: ev3dev.button.Button :members: :inherited-members: @@ -52,10 +24,10 @@ Button Leds ---- -.. autoclass:: Led +.. autoclass:: ev3dev.led.Led :members: -.. autoclass:: ev3dev.ev3.Leds +.. autoclass:: ev3dev.led.Leds :members: .. rubric:: EV3 platform @@ -87,26 +59,26 @@ Leds Power Supply ------------ -.. autoclass:: PowerSupply +.. autoclass:: ev3dev.power.PowerSupply :members: Sound ----- -.. autoclass:: Sound +.. autoclass:: ev3dev.sound.Sound :members: Screen ------ -.. autoclass:: Screen +.. autoclass:: ev3dev.display.Display :members: :show-inheritance: Bitmap fonts ^^^^^^^^^^^^ -The :py:class:`Screen` class allows to write text on the LCD using python +The :py:class:`Display` class allows to write text on the LCD using python imaging library (PIL) interface (see description of the ``text()`` method `here `_). The ``ev3dev.fonts`` module contains bitmap fonts in PIL format that should @@ -115,7 +87,7 @@ look good on a tiny EV3 screen: .. code-block:: py import ev3dev.fonts as fonts - screen.draw.text((10,10), 'Hello World!', font=fonts.load('luBS14')) + display.draw.text((10,10), 'Hello World!', font=fonts.load('luBS14')) .. autofunction:: ev3dev.fonts.available @@ -129,5 +101,5 @@ to EV3 screen size: Lego Port --------- -.. autoclass:: LegoPort +.. autoclass:: ev3dev.port.LegoPort :members: diff --git a/docs/sensors.rst b/docs/sensors.rst index dbc7a17..de1d421 100644 --- a/docs/sensors.rst +++ b/docs/sensors.rst @@ -6,9 +6,7 @@ Sensor This is the base class all the other sensor classes are derived from. -.. currentmodule:: ev3dev.core - -.. autoclass:: Sensor +.. autoclass:: ev3dev.sensor.Sensor :members: Special sensor classes @@ -23,7 +21,7 @@ sure the sensor is in the required mode and then returns the specified value. Touch Sensor ######################## -.. autoclass:: TouchSensor +.. autoclass:: ev3dev.sensor.lego.TouchSensor :members: :show-inheritance: @@ -32,7 +30,7 @@ Touch Sensor Color Sensor ######################## -.. autoclass:: ColorSensor +.. autoclass:: ev3dev.sensor.lego.ColorSensor :members: :show-inheritance: @@ -41,7 +39,7 @@ Color Sensor Ultrasonic Sensor ######################## -.. autoclass:: UltrasonicSensor +.. autoclass:: ev3dev.sensor.lego.UltrasonicSensor :members: :show-inheritance: @@ -50,7 +48,7 @@ Ultrasonic Sensor Gyro Sensor ######################## -.. autoclass:: GyroSensor +.. autoclass:: ev3dev.sensor.lego.GyroSensor :members: :show-inheritance: @@ -59,7 +57,7 @@ Gyro Sensor Infrared Sensor ######################## -.. autoclass:: InfraredSensor +.. autoclass:: ev3dev.sensor.lego.InfraredSensor :members: :show-inheritance: @@ -68,7 +66,7 @@ Infrared Sensor Sound Sensor ######################## -.. autoclass:: SoundSensor +.. autoclass:: ev3dev.sensor.lego.SoundSensor :members: :show-inheritance: @@ -77,7 +75,7 @@ Sound Sensor Light Sensor ######################## -.. autoclass:: LightSensor +.. autoclass:: ev3dev.sensor.lego.LightSensor :members: :show-inheritance: diff --git a/docs/spec.rst b/docs/spec.rst index 8fa4880..5519ac2 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -3,15 +3,15 @@ API reference Each class in ev3dev module inherits from the base :py:class:`Device` class. -.. autoclass:: ev3dev.core.Device +.. autoclass:: ev3dev.Device -.. autofunction:: ev3dev.core.list_device_names +.. autofunction:: ev3dev.list_device_names -.. autofunction:: ev3dev.core.list_devices +.. autofunction:: ev3dev.list_devices -.. autofunction:: ev3dev.core.list_motors +.. autofunction:: ev3dev.motor.list_motors -.. autofunction:: ev3dev.core.list_sensors +.. autofunction:: ev3dev.sensor.list_sensors .. rubric:: Contents: diff --git a/ev3dev/display.py b/ev3dev/display.py index 7293738..8412e19 100644 --- a/ev3dev/display.py +++ b/ev3dev/display.py @@ -347,7 +347,7 @@ def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', fon 'text_color' : PIL says it supports "common HTML color names". There are 140 HTML color names listed here that are supported by all modern browsers. This is probably a good list to start with. - https://www.w3schools.com/colors/colors_names.asp + https://www.w3schools.com/colors/colors_names.asp 'font' : can be any font displayed here http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/stable/other.html#bitmap-fonts @@ -373,7 +373,7 @@ def text_grid(self, text, clear_screen=True, x=0, y=0, text_color='black', font= 'text_color' : PIL says it supports "common HTML color names". There are 140 HTML color names listed here that are supported by all modern browsers. This is probably a good list to start with. - https://www.w3schools.com/colors/colors_names.asp + https://www.w3schools.com/colors/colors_names.asp 'font' : can be any font displayed here http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/stable/other.html#bitmap-fonts diff --git a/ev3dev/port.py b/ev3dev/port.py index 0ac71fb..08c078a 100644 --- a/ev3dev/port.py +++ b/ev3dev/port.py @@ -24,6 +24,7 @@ # ----------------------------------------------------------------------------- import sys +from . import Device if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') diff --git a/ev3dev/sensor/__init__.py b/ev3dev/sensor/__init__.py index dc224fe..dce5a3c 100644 --- a/ev3dev/sensor/__init__.py +++ b/ev3dev/sensor/__init__.py @@ -299,7 +299,7 @@ def list_sensors(name_pattern=Sensor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): keyword arguments: used for matching the corresponding device attributes. For example, driver_name='lego-ev3-touch', or address=['in1', 'in3']. When argument value is a list, - then a match against any entry of the list is enough. + then a match against any entry of the list is enough. """ class_path = abspath(Device.DEVICE_ROOT_PATH + '/' + Sensor.SYSTEM_CLASS_NAME) return (Sensor(name_pattern=name, name_exact=True) diff --git a/ev3dev/sound.py b/ev3dev/sound.py index b912dfc..49a7fc6 100644 --- a/ev3dev/sound.py +++ b/ev3dev/sound.py @@ -215,7 +215,7 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE duration (float): tone duration, in seconds volume (int) the play volume, in percent of maximum volume play_type (int) the type of play (wait, no wait, loop), as defined - by the ``PLAY_xxx`` constants + by the ``PLAY_xxx`` constants Returns: the PID of the underlying beep command if no wait play type, None otherwise From 30252d95f41a6b53dfeabda59004b8efc588da1a Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Mon, 8 Jan 2018 21:15:43 +0300 Subject: [PATCH 049/172] Spell out evdev dependency for readthedocs --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 77efd5c..014f8ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,6 +26,7 @@ import pip pip.main(['install', 'sphinx_bootstrap_theme']) pip.main(['install', 'recommonmark']) + pip.main(['install', 'evdev']) import sphinx_bootstrap_theme from recommonmark.parser import CommonMarkParser From 916ecec4e86236930f83076ff3993fda4bd52f49 Mon Sep 17 00:00:00 2001 From: Stefan Sauer Date: Fri, 12 Jan 2018 20:12:16 +0100 Subject: [PATCH 050/172] Don't use a list if we look for a single driver. (#444) The driver_name arg can be a single value or a list. The list is only needed if we look for one of many options. --- ev3dev/sensor/lego.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ev3dev/sensor/lego.py b/ev3dev/sensor/lego.py index 08bafc3..488ec1e 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev/sensor/lego.py @@ -170,7 +170,7 @@ class ColorSensor(Sensor): ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-color'], **kwargs) + super(ColorSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-ev3-color', **kwargs) # See calibrate_white() for more details self.red_max = 300 @@ -506,7 +506,7 @@ class GyroSensor(Sensor): ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-gyro'], **kwargs) + super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-ev3-gyro', **kwargs) self._direct = None @property @@ -656,7 +656,7 @@ class InfraredSensor(Sensor, ButtonBase): on_channel4_beacon = None def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-ir'], **kwargs) + super(InfraredSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-ev3-ir', **kwargs) def _normalize_channel(self, channel): assert channel >= 1 and channel <= 4, "channel is %s, it must be 1, 2, 3, or 4" % channel @@ -808,7 +808,7 @@ class SoundSensor(Sensor): ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(SoundSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-sound'], **kwargs) + super(SoundSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-nxt-sound', **kwargs) @property def sound_pressure(self): @@ -849,7 +849,7 @@ class LightSensor(Sensor): ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(LightSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-nxt-light'], **kwargs) + super(LightSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-nxt-light', **kwargs) @property def reflected_light_intensity(self): From 8e1bd88c8dbdb03bbe0d21397cc185414987e9d6 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 28 Jan 2018 16:46:04 -0800 Subject: [PATCH 051/172] Rename namespace: ev3dev -> ev3dev2 (#412) * Rename namespace: ev3dev -> ev3dev2 * Update api_tests.py * Update sensor imports * Fix all the things * Clean up imports * Fix reference to updated module name --- .gitignore | 2 +- {ev3dev => ev3dev2}/__init__.py | 0 {ev3dev => ev3dev2}/_platform/__init__.py | 0 {ev3dev => ev3dev2}/_platform/brickpi.py | 0 {ev3dev => ev3dev2}/_platform/brickpi3.py | 0 {ev3dev => ev3dev2}/_platform/ev3.py | 0 {ev3dev => ev3dev2}/_platform/evb.py | 0 {ev3dev => ev3dev2}/_platform/fake.py | 0 {ev3dev => ev3dev2}/_platform/pistorms.py | 0 {ev3dev => ev3dev2}/button.py | 14 +++++------ {ev3dev => ev3dev2}/control/GyroBalancer.py | 0 {ev3dev => ev3dev2}/control/__init__.py | 0 {ev3dev => ev3dev2}/control/rc_tank.py | 4 +-- {ev3dev => ev3dev2}/control/webserver.py | 2 +- {ev3dev => ev3dev2}/display.py | 0 {ev3dev => ev3dev2}/fonts/__init__.py | 0 {ev3dev => ev3dev2}/fonts/charB08.pbm | Bin {ev3dev => ev3dev2}/fonts/charB08.pil | Bin {ev3dev => ev3dev2}/fonts/charB10.pbm | Bin {ev3dev => ev3dev2}/fonts/charB10.pil | Bin {ev3dev => ev3dev2}/fonts/charB12.pbm | Bin {ev3dev => ev3dev2}/fonts/charB12.pil | Bin {ev3dev => ev3dev2}/fonts/charB14.pbm | Bin {ev3dev => ev3dev2}/fonts/charB14.pil | Bin {ev3dev => ev3dev2}/fonts/charB18.pbm | Bin {ev3dev => ev3dev2}/fonts/charB18.pil | Bin {ev3dev => ev3dev2}/fonts/charB24.pbm | Bin {ev3dev => ev3dev2}/fonts/charB24.pil | Bin {ev3dev => ev3dev2}/fonts/charBI08.pbm | Bin {ev3dev => ev3dev2}/fonts/charBI08.pil | Bin {ev3dev => ev3dev2}/fonts/charBI10.pbm | Bin {ev3dev => ev3dev2}/fonts/charBI10.pil | Bin {ev3dev => ev3dev2}/fonts/charBI12.pbm | Bin {ev3dev => ev3dev2}/fonts/charBI12.pil | Bin {ev3dev => ev3dev2}/fonts/charBI14.pbm | Bin {ev3dev => ev3dev2}/fonts/charBI14.pil | Bin {ev3dev => ev3dev2}/fonts/charBI18.pbm | Bin {ev3dev => ev3dev2}/fonts/charBI18.pil | Bin {ev3dev => ev3dev2}/fonts/charBI24.pbm | Bin {ev3dev => ev3dev2}/fonts/charBI24.pil | Bin {ev3dev => ev3dev2}/fonts/charI08.pbm | Bin {ev3dev => ev3dev2}/fonts/charI08.pil | Bin {ev3dev => ev3dev2}/fonts/charI10.pbm | Bin {ev3dev => ev3dev2}/fonts/charI10.pil | Bin {ev3dev => ev3dev2}/fonts/charI12.pbm | Bin {ev3dev => ev3dev2}/fonts/charI12.pil | Bin {ev3dev => ev3dev2}/fonts/charI14.pbm | Bin {ev3dev => ev3dev2}/fonts/charI14.pil | Bin {ev3dev => ev3dev2}/fonts/charI18.pbm | Bin {ev3dev => ev3dev2}/fonts/charI18.pil | Bin {ev3dev => ev3dev2}/fonts/charI24.pbm | Bin {ev3dev => ev3dev2}/fonts/charI24.pil | Bin {ev3dev => ev3dev2}/fonts/charR08.pbm | Bin {ev3dev => ev3dev2}/fonts/charR08.pil | Bin {ev3dev => ev3dev2}/fonts/charR10.pbm | Bin {ev3dev => ev3dev2}/fonts/charR10.pil | Bin {ev3dev => ev3dev2}/fonts/charR12.pbm | Bin {ev3dev => ev3dev2}/fonts/charR12.pil | Bin {ev3dev => ev3dev2}/fonts/charR14.pbm | Bin {ev3dev => ev3dev2}/fonts/charR14.pil | Bin {ev3dev => ev3dev2}/fonts/charR18.pbm | Bin {ev3dev => ev3dev2}/fonts/charR18.pil | Bin {ev3dev => ev3dev2}/fonts/charR24.pbm | Bin {ev3dev => ev3dev2}/fonts/charR24.pil | Bin {ev3dev => ev3dev2}/fonts/courB08.pbm | Bin {ev3dev => ev3dev2}/fonts/courB08.pil | Bin {ev3dev => ev3dev2}/fonts/courB10.pbm | Bin {ev3dev => ev3dev2}/fonts/courB10.pil | Bin {ev3dev => ev3dev2}/fonts/courB12.pbm | Bin {ev3dev => ev3dev2}/fonts/courB12.pil | Bin {ev3dev => ev3dev2}/fonts/courB14.pbm | Bin {ev3dev => ev3dev2}/fonts/courB14.pil | Bin {ev3dev => ev3dev2}/fonts/courB18.pbm | Bin {ev3dev => ev3dev2}/fonts/courB18.pil | Bin {ev3dev => ev3dev2}/fonts/courB24.pbm | Bin {ev3dev => ev3dev2}/fonts/courB24.pil | Bin {ev3dev => ev3dev2}/fonts/courBO08.pbm | Bin {ev3dev => ev3dev2}/fonts/courBO08.pil | Bin {ev3dev => ev3dev2}/fonts/courBO10.pbm | Bin {ev3dev => ev3dev2}/fonts/courBO10.pil | Bin {ev3dev => ev3dev2}/fonts/courBO12.pbm | Bin {ev3dev => ev3dev2}/fonts/courBO12.pil | Bin {ev3dev => ev3dev2}/fonts/courBO14.pbm | Bin {ev3dev => ev3dev2}/fonts/courBO14.pil | Bin {ev3dev => ev3dev2}/fonts/courBO18.pbm | Bin {ev3dev => ev3dev2}/fonts/courBO18.pil | Bin {ev3dev => ev3dev2}/fonts/courBO24.pbm | Bin {ev3dev => ev3dev2}/fonts/courBO24.pil | Bin {ev3dev => ev3dev2}/fonts/courO08.pbm | Bin {ev3dev => ev3dev2}/fonts/courO08.pil | Bin {ev3dev => ev3dev2}/fonts/courO10.pbm | Bin {ev3dev => ev3dev2}/fonts/courO10.pil | Bin {ev3dev => ev3dev2}/fonts/courO12.pbm | Bin {ev3dev => ev3dev2}/fonts/courO12.pil | Bin {ev3dev => ev3dev2}/fonts/courO14.pbm | Bin {ev3dev => ev3dev2}/fonts/courO14.pil | Bin {ev3dev => ev3dev2}/fonts/courO18.pbm | Bin {ev3dev => ev3dev2}/fonts/courO18.pil | Bin {ev3dev => ev3dev2}/fonts/courO24.pbm | Bin {ev3dev => ev3dev2}/fonts/courO24.pil | Bin {ev3dev => ev3dev2}/fonts/courR08.pbm | Bin {ev3dev => ev3dev2}/fonts/courR08.pil | Bin {ev3dev => ev3dev2}/fonts/courR10.pbm | Bin {ev3dev => ev3dev2}/fonts/courR10.pil | Bin {ev3dev => ev3dev2}/fonts/courR12.pbm | Bin {ev3dev => ev3dev2}/fonts/courR12.pil | Bin {ev3dev => ev3dev2}/fonts/courR14.pbm | Bin {ev3dev => ev3dev2}/fonts/courR14.pil | Bin {ev3dev => ev3dev2}/fonts/courR18.pbm | Bin {ev3dev => ev3dev2}/fonts/courR18.pil | Bin {ev3dev => ev3dev2}/fonts/courR24.pbm | Bin {ev3dev => ev3dev2}/fonts/courR24.pil | Bin {ev3dev => ev3dev2}/fonts/helvB08.pbm | Bin {ev3dev => ev3dev2}/fonts/helvB08.pil | Bin {ev3dev => ev3dev2}/fonts/helvB10.pbm | Bin {ev3dev => ev3dev2}/fonts/helvB10.pil | Bin {ev3dev => ev3dev2}/fonts/helvB12.pbm | Bin {ev3dev => ev3dev2}/fonts/helvB12.pil | Bin {ev3dev => ev3dev2}/fonts/helvB14.pbm | Bin {ev3dev => ev3dev2}/fonts/helvB14.pil | Bin {ev3dev => ev3dev2}/fonts/helvB18.pbm | Bin {ev3dev => ev3dev2}/fonts/helvB18.pil | Bin {ev3dev => ev3dev2}/fonts/helvB24.pbm | Bin {ev3dev => ev3dev2}/fonts/helvB24.pil | Bin {ev3dev => ev3dev2}/fonts/helvBO08.pbm | Bin {ev3dev => ev3dev2}/fonts/helvBO08.pil | Bin {ev3dev => ev3dev2}/fonts/helvBO10.pbm | Bin {ev3dev => ev3dev2}/fonts/helvBO10.pil | Bin {ev3dev => ev3dev2}/fonts/helvBO12.pbm | Bin {ev3dev => ev3dev2}/fonts/helvBO12.pil | Bin {ev3dev => ev3dev2}/fonts/helvBO14.pbm | Bin {ev3dev => ev3dev2}/fonts/helvBO14.pil | Bin {ev3dev => ev3dev2}/fonts/helvBO18.pbm | Bin {ev3dev => ev3dev2}/fonts/helvBO18.pil | Bin {ev3dev => ev3dev2}/fonts/helvBO24.pbm | Bin {ev3dev => ev3dev2}/fonts/helvBO24.pil | Bin {ev3dev => ev3dev2}/fonts/helvO08.pbm | Bin {ev3dev => ev3dev2}/fonts/helvO08.pil | Bin {ev3dev => ev3dev2}/fonts/helvO10.pbm | Bin {ev3dev => ev3dev2}/fonts/helvO10.pil | Bin {ev3dev => ev3dev2}/fonts/helvO12.pbm | Bin {ev3dev => ev3dev2}/fonts/helvO12.pil | Bin {ev3dev => ev3dev2}/fonts/helvO14.pbm | Bin {ev3dev => ev3dev2}/fonts/helvO14.pil | Bin {ev3dev => ev3dev2}/fonts/helvO18.pbm | Bin {ev3dev => ev3dev2}/fonts/helvO18.pil | Bin {ev3dev => ev3dev2}/fonts/helvO24.pbm | Bin {ev3dev => ev3dev2}/fonts/helvO24.pil | Bin {ev3dev => ev3dev2}/fonts/helvR08.pbm | Bin {ev3dev => ev3dev2}/fonts/helvR08.pil | Bin {ev3dev => ev3dev2}/fonts/helvR10.pbm | Bin {ev3dev => ev3dev2}/fonts/helvR10.pil | Bin {ev3dev => ev3dev2}/fonts/helvR12.pbm | Bin {ev3dev => ev3dev2}/fonts/helvR12.pil | Bin {ev3dev => ev3dev2}/fonts/helvR14.pbm | Bin {ev3dev => ev3dev2}/fonts/helvR14.pil | Bin {ev3dev => ev3dev2}/fonts/helvR18.pbm | Bin {ev3dev => ev3dev2}/fonts/helvR18.pil | Bin {ev3dev => ev3dev2}/fonts/helvR24.pbm | Bin {ev3dev => ev3dev2}/fonts/helvR24.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS08.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS08.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS10.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS10.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS12.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS12.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS14.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS14.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS18.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS18.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS19.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS19.pil | Bin {ev3dev => ev3dev2}/fonts/luBIS24.pbm | Bin {ev3dev => ev3dev2}/fonts/luBIS24.pil | Bin {ev3dev => ev3dev2}/fonts/luBS08.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS08.pil | Bin {ev3dev => ev3dev2}/fonts/luBS10.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS10.pil | Bin {ev3dev => ev3dev2}/fonts/luBS12.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS12.pil | Bin {ev3dev => ev3dev2}/fonts/luBS14.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS14.pil | Bin {ev3dev => ev3dev2}/fonts/luBS18.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS18.pil | Bin {ev3dev => ev3dev2}/fonts/luBS19.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS19.pil | Bin {ev3dev => ev3dev2}/fonts/luBS24.pbm | Bin {ev3dev => ev3dev2}/fonts/luBS24.pil | Bin {ev3dev => ev3dev2}/fonts/luIS08.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS08.pil | Bin {ev3dev => ev3dev2}/fonts/luIS10.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS10.pil | Bin {ev3dev => ev3dev2}/fonts/luIS12.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS12.pil | Bin {ev3dev => ev3dev2}/fonts/luIS14.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS14.pil | Bin {ev3dev => ev3dev2}/fonts/luIS18.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS18.pil | Bin {ev3dev => ev3dev2}/fonts/luIS19.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS19.pil | Bin {ev3dev => ev3dev2}/fonts/luIS24.pbm | Bin {ev3dev => ev3dev2}/fonts/luIS24.pil | Bin {ev3dev => ev3dev2}/fonts/luRS08.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS08.pil | Bin {ev3dev => ev3dev2}/fonts/luRS10.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS10.pil | Bin {ev3dev => ev3dev2}/fonts/luRS12.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS12.pil | Bin {ev3dev => ev3dev2}/fonts/luRS14.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS14.pil | Bin {ev3dev => ev3dev2}/fonts/luRS18.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS18.pil | Bin {ev3dev => ev3dev2}/fonts/luRS19.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS19.pil | Bin {ev3dev => ev3dev2}/fonts/luRS24.pbm | Bin {ev3dev => ev3dev2}/fonts/luRS24.pil | Bin {ev3dev => ev3dev2}/fonts/lubB08.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB08.pil | Bin {ev3dev => ev3dev2}/fonts/lubB10.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB10.pil | Bin {ev3dev => ev3dev2}/fonts/lubB12.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB12.pil | Bin {ev3dev => ev3dev2}/fonts/lubB14.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB14.pil | Bin {ev3dev => ev3dev2}/fonts/lubB18.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB18.pil | Bin {ev3dev => ev3dev2}/fonts/lubB19.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB19.pil | Bin {ev3dev => ev3dev2}/fonts/lubB24.pbm | Bin {ev3dev => ev3dev2}/fonts/lubB24.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI08.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI08.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI10.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI10.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI12.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI12.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI14.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI14.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI18.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI18.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI19.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI19.pil | Bin {ev3dev => ev3dev2}/fonts/lubBI24.pbm | Bin {ev3dev => ev3dev2}/fonts/lubBI24.pil | Bin {ev3dev => ev3dev2}/fonts/lubI08.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI08.pil | Bin {ev3dev => ev3dev2}/fonts/lubI10.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI10.pil | Bin {ev3dev => ev3dev2}/fonts/lubI12.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI12.pil | Bin {ev3dev => ev3dev2}/fonts/lubI14.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI14.pil | Bin {ev3dev => ev3dev2}/fonts/lubI18.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI18.pil | Bin {ev3dev => ev3dev2}/fonts/lubI19.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI19.pil | Bin {ev3dev => ev3dev2}/fonts/lubI24.pbm | Bin {ev3dev => ev3dev2}/fonts/lubI24.pil | Bin {ev3dev => ev3dev2}/fonts/lubR08.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR08.pil | Bin {ev3dev => ev3dev2}/fonts/lubR10.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR10.pil | Bin {ev3dev => ev3dev2}/fonts/lubR12.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR12.pil | Bin {ev3dev => ev3dev2}/fonts/lubR14.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR14.pil | Bin {ev3dev => ev3dev2}/fonts/lubR18.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR18.pil | Bin {ev3dev => ev3dev2}/fonts/lubR19.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR19.pil | Bin {ev3dev => ev3dev2}/fonts/lubR24.pbm | Bin {ev3dev => ev3dev2}/fonts/lubR24.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS08.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS08.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS10.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS10.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS12.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS12.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS14.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS14.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS18.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS18.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS19.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS19.pil | Bin {ev3dev => ev3dev2}/fonts/lutBS24.pbm | Bin {ev3dev => ev3dev2}/fonts/lutBS24.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS08.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS08.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS10.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS10.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS12.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS12.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS14.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS14.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS18.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS18.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS19.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS19.pil | Bin {ev3dev => ev3dev2}/fonts/lutRS24.pbm | Bin {ev3dev => ev3dev2}/fonts/lutRS24.pil | Bin {ev3dev => ev3dev2}/fonts/ncenB08.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenB08.pil | Bin {ev3dev => ev3dev2}/fonts/ncenB10.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenB10.pil | Bin {ev3dev => ev3dev2}/fonts/ncenB12.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenB12.pil | Bin {ev3dev => ev3dev2}/fonts/ncenB14.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenB14.pil | Bin {ev3dev => ev3dev2}/fonts/ncenB18.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenB18.pil | Bin {ev3dev => ev3dev2}/fonts/ncenB24.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenB24.pil | Bin {ev3dev => ev3dev2}/fonts/ncenBI08.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenBI08.pil | Bin {ev3dev => ev3dev2}/fonts/ncenBI10.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenBI10.pil | Bin {ev3dev => ev3dev2}/fonts/ncenBI12.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenBI12.pil | Bin {ev3dev => ev3dev2}/fonts/ncenBI14.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenBI14.pil | Bin {ev3dev => ev3dev2}/fonts/ncenBI18.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenBI18.pil | Bin {ev3dev => ev3dev2}/fonts/ncenBI24.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenBI24.pil | Bin {ev3dev => ev3dev2}/fonts/ncenI08.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenI08.pil | Bin {ev3dev => ev3dev2}/fonts/ncenI10.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenI10.pil | Bin {ev3dev => ev3dev2}/fonts/ncenI12.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenI12.pil | Bin {ev3dev => ev3dev2}/fonts/ncenI14.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenI14.pil | Bin {ev3dev => ev3dev2}/fonts/ncenI18.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenI18.pil | Bin {ev3dev => ev3dev2}/fonts/ncenI24.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenI24.pil | Bin {ev3dev => ev3dev2}/fonts/ncenR08.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenR08.pil | Bin {ev3dev => ev3dev2}/fonts/ncenR10.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenR10.pil | Bin {ev3dev => ev3dev2}/fonts/ncenR12.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenR12.pil | Bin {ev3dev => ev3dev2}/fonts/ncenR14.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenR14.pil | Bin {ev3dev => ev3dev2}/fonts/ncenR18.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenR18.pil | Bin {ev3dev => ev3dev2}/fonts/ncenR24.pbm | Bin {ev3dev => ev3dev2}/fonts/ncenR24.pil | Bin {ev3dev => ev3dev2}/fonts/symb08.pbm | Bin {ev3dev => ev3dev2}/fonts/symb08.pil | Bin {ev3dev => ev3dev2}/fonts/symb10.pbm | Bin {ev3dev => ev3dev2}/fonts/symb10.pil | Bin {ev3dev => ev3dev2}/fonts/symb12.pbm | Bin {ev3dev => ev3dev2}/fonts/symb12.pil | Bin {ev3dev => ev3dev2}/fonts/symb14.pbm | Bin {ev3dev => ev3dev2}/fonts/symb14.pil | Bin {ev3dev => ev3dev2}/fonts/symb18.pbm | Bin {ev3dev => ev3dev2}/fonts/symb18.pil | Bin {ev3dev => ev3dev2}/fonts/symb24.pbm | Bin {ev3dev => ev3dev2}/fonts/symb24.pil | Bin {ev3dev => ev3dev2}/fonts/tech14.pbm | Bin {ev3dev => ev3dev2}/fonts/tech14.pil | Bin {ev3dev => ev3dev2}/fonts/techB14.pbm | Bin {ev3dev => ev3dev2}/fonts/techB14.pil | Bin {ev3dev => ev3dev2}/fonts/term14.pbm | Bin {ev3dev => ev3dev2}/fonts/term14.pil | Bin {ev3dev => ev3dev2}/fonts/termB14.pbm | Bin {ev3dev => ev3dev2}/fonts/termB14.pil | Bin {ev3dev => ev3dev2}/fonts/timB08.pbm | Bin {ev3dev => ev3dev2}/fonts/timB08.pil | Bin {ev3dev => ev3dev2}/fonts/timB10.pbm | Bin {ev3dev => ev3dev2}/fonts/timB10.pil | Bin {ev3dev => ev3dev2}/fonts/timB12.pbm | Bin {ev3dev => ev3dev2}/fonts/timB12.pil | Bin {ev3dev => ev3dev2}/fonts/timB14.pbm | Bin {ev3dev => ev3dev2}/fonts/timB14.pil | Bin {ev3dev => ev3dev2}/fonts/timB18.pbm | Bin {ev3dev => ev3dev2}/fonts/timB18.pil | Bin {ev3dev => ev3dev2}/fonts/timB24.pbm | Bin {ev3dev => ev3dev2}/fonts/timB24.pil | Bin {ev3dev => ev3dev2}/fonts/timBI08.pbm | Bin {ev3dev => ev3dev2}/fonts/timBI08.pil | Bin {ev3dev => ev3dev2}/fonts/timBI10.pbm | Bin {ev3dev => ev3dev2}/fonts/timBI10.pil | Bin {ev3dev => ev3dev2}/fonts/timBI12.pbm | Bin {ev3dev => ev3dev2}/fonts/timBI12.pil | Bin {ev3dev => ev3dev2}/fonts/timBI14.pbm | Bin {ev3dev => ev3dev2}/fonts/timBI14.pil | Bin {ev3dev => ev3dev2}/fonts/timBI18.pbm | Bin {ev3dev => ev3dev2}/fonts/timBI18.pil | Bin {ev3dev => ev3dev2}/fonts/timBI24.pbm | Bin {ev3dev => ev3dev2}/fonts/timBI24.pil | Bin {ev3dev => ev3dev2}/fonts/timI08.pbm | Bin {ev3dev => ev3dev2}/fonts/timI08.pil | Bin {ev3dev => ev3dev2}/fonts/timI10.pbm | Bin {ev3dev => ev3dev2}/fonts/timI10.pil | Bin {ev3dev => ev3dev2}/fonts/timI12.pbm | Bin {ev3dev => ev3dev2}/fonts/timI12.pil | Bin {ev3dev => ev3dev2}/fonts/timI14.pbm | Bin {ev3dev => ev3dev2}/fonts/timI14.pil | Bin {ev3dev => ev3dev2}/fonts/timI18.pbm | Bin {ev3dev => ev3dev2}/fonts/timI18.pil | Bin {ev3dev => ev3dev2}/fonts/timI24.pbm | Bin {ev3dev => ev3dev2}/fonts/timI24.pil | Bin {ev3dev => ev3dev2}/fonts/timR08.pbm | Bin {ev3dev => ev3dev2}/fonts/timR08.pil | Bin {ev3dev => ev3dev2}/fonts/timR10.pbm | Bin {ev3dev => ev3dev2}/fonts/timR10.pil | Bin {ev3dev => ev3dev2}/fonts/timR12.pbm | Bin {ev3dev => ev3dev2}/fonts/timR12.pil | Bin {ev3dev => ev3dev2}/fonts/timR14.pbm | Bin {ev3dev => ev3dev2}/fonts/timR14.pil | Bin {ev3dev => ev3dev2}/fonts/timR18.pbm | Bin {ev3dev => ev3dev2}/fonts/timR18.pil | Bin {ev3dev => ev3dev2}/fonts/timR24.pbm | Bin {ev3dev => ev3dev2}/fonts/timR24.pil | Bin {ev3dev => ev3dev2}/led.py | 14 +++++------ {ev3dev => ev3dev2}/motor.py | 14 +++++------ {ev3dev => ev3dev2}/port.py | 0 {ev3dev => ev3dev2}/power.py | 2 +- {ev3dev => ev3dev2}/sensor/__init__.py | 16 ++++++------ {ev3dev => ev3dev2}/sensor/lego.py | 4 +-- {ev3dev => ev3dev2}/sound.py | 0 setup.py | 10 ++++---- tests/api_tests.py | 26 ++++++++++---------- utils/move_motor.py | 2 +- utils/stop_all_motors.py | 2 +- 427 files changed, 56 insertions(+), 56 deletions(-) rename {ev3dev => ev3dev2}/__init__.py (100%) rename {ev3dev => ev3dev2}/_platform/__init__.py (100%) rename {ev3dev => ev3dev2}/_platform/brickpi.py (100%) rename {ev3dev => ev3dev2}/_platform/brickpi3.py (100%) rename {ev3dev => ev3dev2}/_platform/ev3.py (100%) rename {ev3dev => ev3dev2}/_platform/evb.py (100%) rename {ev3dev => ev3dev2}/_platform/fake.py (100%) rename {ev3dev => ev3dev2}/_platform/pistorms.py (100%) rename {ev3dev => ev3dev2}/button.py (95%) rename {ev3dev => ev3dev2}/control/GyroBalancer.py (100%) rename {ev3dev => ev3dev2}/control/__init__.py (100%) rename {ev3dev => ev3dev2}/control/rc_tank.py (94%) rename {ev3dev => ev3dev2}/control/webserver.py (99%) rename {ev3dev => ev3dev2}/display.py (100%) rename {ev3dev => ev3dev2}/fonts/__init__.py (100%) rename {ev3dev => ev3dev2}/fonts/charB08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charB08.pil (100%) rename {ev3dev => ev3dev2}/fonts/charB10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charB10.pil (100%) rename {ev3dev => ev3dev2}/fonts/charB12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charB12.pil (100%) rename {ev3dev => ev3dev2}/fonts/charB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/charB18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charB18.pil (100%) rename {ev3dev => ev3dev2}/fonts/charB24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charB24.pil (100%) rename {ev3dev => ev3dev2}/fonts/charBI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charBI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/charBI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charBI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/charBI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charBI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/charBI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charBI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/charBI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charBI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/charBI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charBI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/charI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/charI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/charI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/charI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/charI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/charI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/charR08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charR08.pil (100%) rename {ev3dev => ev3dev2}/fonts/charR10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charR10.pil (100%) rename {ev3dev => ev3dev2}/fonts/charR12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charR12.pil (100%) rename {ev3dev => ev3dev2}/fonts/charR14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charR14.pil (100%) rename {ev3dev => ev3dev2}/fonts/charR18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charR18.pil (100%) rename {ev3dev => ev3dev2}/fonts/charR24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/charR24.pil (100%) rename {ev3dev => ev3dev2}/fonts/courB08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courB08.pil (100%) rename {ev3dev => ev3dev2}/fonts/courB10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courB10.pil (100%) rename {ev3dev => ev3dev2}/fonts/courB12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courB12.pil (100%) rename {ev3dev => ev3dev2}/fonts/courB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/courB18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courB18.pil (100%) rename {ev3dev => ev3dev2}/fonts/courB24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courB24.pil (100%) rename {ev3dev => ev3dev2}/fonts/courBO08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courBO08.pil (100%) rename {ev3dev => ev3dev2}/fonts/courBO10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courBO10.pil (100%) rename {ev3dev => ev3dev2}/fonts/courBO12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courBO12.pil (100%) rename {ev3dev => ev3dev2}/fonts/courBO14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courBO14.pil (100%) rename {ev3dev => ev3dev2}/fonts/courBO18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courBO18.pil (100%) rename {ev3dev => ev3dev2}/fonts/courBO24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courBO24.pil (100%) rename {ev3dev => ev3dev2}/fonts/courO08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courO08.pil (100%) rename {ev3dev => ev3dev2}/fonts/courO10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courO10.pil (100%) rename {ev3dev => ev3dev2}/fonts/courO12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courO12.pil (100%) rename {ev3dev => ev3dev2}/fonts/courO14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courO14.pil (100%) rename {ev3dev => ev3dev2}/fonts/courO18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courO18.pil (100%) rename {ev3dev => ev3dev2}/fonts/courO24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courO24.pil (100%) rename {ev3dev => ev3dev2}/fonts/courR08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courR08.pil (100%) rename {ev3dev => ev3dev2}/fonts/courR10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courR10.pil (100%) rename {ev3dev => ev3dev2}/fonts/courR12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courR12.pil (100%) rename {ev3dev => ev3dev2}/fonts/courR14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courR14.pil (100%) rename {ev3dev => ev3dev2}/fonts/courR18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courR18.pil (100%) rename {ev3dev => ev3dev2}/fonts/courR24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/courR24.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvB08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvB08.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvB10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvB10.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvB12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvB12.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvB18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvB18.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvB24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvB24.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvBO08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvBO08.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvBO10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvBO10.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvBO12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvBO12.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvBO14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvBO14.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvBO18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvBO18.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvBO24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvBO24.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvO08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvO08.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvO10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvO10.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvO12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvO12.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvO14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvO14.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvO18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvO18.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvO24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvO24.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvR08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvR08.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvR10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvR10.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvR12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvR12.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvR14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvR14.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvR18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvR18.pil (100%) rename {ev3dev => ev3dev2}/fonts/helvR24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/helvR24.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS08.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS10.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS12.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS14.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS18.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS19.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBIS24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBIS24.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS08.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS10.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS12.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS14.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS18.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS19.pil (100%) rename {ev3dev => ev3dev2}/fonts/luBS24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luBS24.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS08.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS10.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS12.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS14.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS18.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS19.pil (100%) rename {ev3dev => ev3dev2}/fonts/luIS24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luIS24.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS08.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS10.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS12.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS14.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS18.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS19.pil (100%) rename {ev3dev => ev3dev2}/fonts/luRS24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/luRS24.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB08.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB10.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB12.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB18.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB19.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubB24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubB24.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI19.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubBI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubBI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI19.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR08.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR10.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR12.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR14.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR18.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR19.pil (100%) rename {ev3dev => ev3dev2}/fonts/lubR24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lubR24.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS08.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS10.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS12.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS14.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS18.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS19.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutBS24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutBS24.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS08.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS10.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS12.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS14.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS18.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS19.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS19.pil (100%) rename {ev3dev => ev3dev2}/fonts/lutRS24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/lutRS24.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenB08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenB08.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenB10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenB10.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenB12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenB12.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenB18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenB18.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenB24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenB24.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenBI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenR08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenR08.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenR10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenR10.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenR12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenR12.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenR14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenR14.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenR18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenR18.pil (100%) rename {ev3dev => ev3dev2}/fonts/ncenR24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/ncenR24.pil (100%) rename {ev3dev => ev3dev2}/fonts/symb08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/symb08.pil (100%) rename {ev3dev => ev3dev2}/fonts/symb10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/symb10.pil (100%) rename {ev3dev => ev3dev2}/fonts/symb12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/symb12.pil (100%) rename {ev3dev => ev3dev2}/fonts/symb14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/symb14.pil (100%) rename {ev3dev => ev3dev2}/fonts/symb18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/symb18.pil (100%) rename {ev3dev => ev3dev2}/fonts/symb24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/symb24.pil (100%) rename {ev3dev => ev3dev2}/fonts/tech14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/tech14.pil (100%) rename {ev3dev => ev3dev2}/fonts/techB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/techB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/term14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/term14.pil (100%) rename {ev3dev => ev3dev2}/fonts/termB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/termB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/timB08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timB08.pil (100%) rename {ev3dev => ev3dev2}/fonts/timB10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timB10.pil (100%) rename {ev3dev => ev3dev2}/fonts/timB12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timB12.pil (100%) rename {ev3dev => ev3dev2}/fonts/timB14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timB14.pil (100%) rename {ev3dev => ev3dev2}/fonts/timB18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timB18.pil (100%) rename {ev3dev => ev3dev2}/fonts/timB24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timB24.pil (100%) rename {ev3dev => ev3dev2}/fonts/timBI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timBI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/timBI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timBI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/timBI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timBI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/timBI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timBI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/timBI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timBI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/timBI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timBI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/timI08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timI08.pil (100%) rename {ev3dev => ev3dev2}/fonts/timI10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timI10.pil (100%) rename {ev3dev => ev3dev2}/fonts/timI12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timI12.pil (100%) rename {ev3dev => ev3dev2}/fonts/timI14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timI14.pil (100%) rename {ev3dev => ev3dev2}/fonts/timI18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timI18.pil (100%) rename {ev3dev => ev3dev2}/fonts/timI24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timI24.pil (100%) rename {ev3dev => ev3dev2}/fonts/timR08.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timR08.pil (100%) rename {ev3dev => ev3dev2}/fonts/timR10.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timR10.pil (100%) rename {ev3dev => ev3dev2}/fonts/timR12.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timR12.pil (100%) rename {ev3dev => ev3dev2}/fonts/timR14.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timR14.pil (100%) rename {ev3dev => ev3dev2}/fonts/timR18.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timR18.pil (100%) rename {ev3dev => ev3dev2}/fonts/timR24.pbm (100%) rename {ev3dev => ev3dev2}/fonts/timR24.pil (100%) rename {ev3dev => ev3dev2}/led.py (96%) rename {ev3dev => ev3dev2}/motor.py (99%) rename {ev3dev => ev3dev2}/port.py (100%) rename {ev3dev => ev3dev2}/power.py (99%) rename {ev3dev => ev3dev2}/sensor/__init__.py (95%) rename {ev3dev => ev3dev2}/sensor/lego.py (99%) rename {ev3dev => ev3dev2}/sound.py (100%) diff --git a/.gitignore b/.gitignore index 27a178b..3d0d13b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ __pycache__ dist *.egg-info RELEASE-VERSION -ev3dev/version.py +ev3dev2/version.py build .idea diff --git a/ev3dev/__init__.py b/ev3dev2/__init__.py similarity index 100% rename from ev3dev/__init__.py rename to ev3dev2/__init__.py diff --git a/ev3dev/_platform/__init__.py b/ev3dev2/_platform/__init__.py similarity index 100% rename from ev3dev/_platform/__init__.py rename to ev3dev2/_platform/__init__.py diff --git a/ev3dev/_platform/brickpi.py b/ev3dev2/_platform/brickpi.py similarity index 100% rename from ev3dev/_platform/brickpi.py rename to ev3dev2/_platform/brickpi.py diff --git a/ev3dev/_platform/brickpi3.py b/ev3dev2/_platform/brickpi3.py similarity index 100% rename from ev3dev/_platform/brickpi3.py rename to ev3dev2/_platform/brickpi3.py diff --git a/ev3dev/_platform/ev3.py b/ev3dev2/_platform/ev3.py similarity index 100% rename from ev3dev/_platform/ev3.py rename to ev3dev2/_platform/ev3.py diff --git a/ev3dev/_platform/evb.py b/ev3dev2/_platform/evb.py similarity index 100% rename from ev3dev/_platform/evb.py rename to ev3dev2/_platform/evb.py diff --git a/ev3dev/_platform/fake.py b/ev3dev2/_platform/fake.py similarity index 100% rename from ev3dev/_platform/fake.py rename to ev3dev2/_platform/fake.py diff --git a/ev3dev/_platform/pistorms.py b/ev3dev2/_platform/pistorms.py similarity index 100% rename from ev3dev/_platform/pistorms.py rename to ev3dev2/_platform/pistorms.py diff --git a/ev3dev/button.py b/ev3dev2/button.py similarity index 95% rename from ev3dev/button.py rename to ev3dev2/button.py index 892365e..4d69f83 100644 --- a/ev3dev/button.py +++ b/ev3dev2/button.py @@ -31,7 +31,7 @@ import array import time import evdev -from ev3dev import get_current_platform +from . import get_current_platform try: # This is a linux-specific module. @@ -46,22 +46,22 @@ platform = get_current_platform() if platform == 'ev3': - from ev3dev._platform.ev3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + from ._platform.ev3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME elif platform == 'evb': - from ev3dev._platform.evb import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + from ._platform.evb import BUTTONS_FILENAME, EVDEV_DEVICE_NAME elif platform == 'pistorms': - from ev3dev._platform.pistorms import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + from ._platform.pistorms import BUTTONS_FILENAME, EVDEV_DEVICE_NAME elif platform == 'brickpi': - from ev3dev._platform.brickpi import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + from ._platform.brickpi import BUTTONS_FILENAME, EVDEV_DEVICE_NAME elif platform == 'brickpi3': - from ev3dev._platform.brickpi3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + from ._platform.brickpi3 import BUTTONS_FILENAME, EVDEV_DEVICE_NAME elif platform == 'fake': - from ev3dev._platform.fake import BUTTONS_FILENAME, EVDEV_DEVICE_NAME + from ._platform.fake import BUTTONS_FILENAME, EVDEV_DEVICE_NAME else: raise Exception("Unsupported platform '%s'" % platform) diff --git a/ev3dev/control/GyroBalancer.py b/ev3dev2/control/GyroBalancer.py similarity index 100% rename from ev3dev/control/GyroBalancer.py rename to ev3dev2/control/GyroBalancer.py diff --git a/ev3dev/control/__init__.py b/ev3dev2/control/__init__.py similarity index 100% rename from ev3dev/control/__init__.py rename to ev3dev2/control/__init__.py diff --git a/ev3dev/control/rc_tank.py b/ev3dev2/control/rc_tank.py similarity index 94% rename from ev3dev/control/rc_tank.py rename to ev3dev2/control/rc_tank.py index d3a7ee5..a498ba6 100644 --- a/ev3dev/control/rc_tank.py +++ b/ev3dev2/control/rc_tank.py @@ -1,7 +1,7 @@ import logging -from ev3dev.motor import MoveTank -from ev3dev.sensor.lego import InfraredSensor +from ev3dev2.motor import MoveTank +from ev3dev2.sensor.lego import InfraredSensor from time import sleep log = logging.getLogger(__name__) diff --git a/ev3dev/control/webserver.py b/ev3dev2/control/webserver.py similarity index 99% rename from ev3dev/control/webserver.py rename to ev3dev2/control/webserver.py index bbfcce3..b9996db 100644 --- a/ev3dev/control/webserver.py +++ b/ev3dev2/control/webserver.py @@ -3,7 +3,7 @@ import logging import os import re -from ev3dev.motor import MoveJoystick, list_motors, LargeMotor +from ev3dev2.motor import MoveJoystick, list_motors, LargeMotor from http.server import BaseHTTPRequestHandler, HTTPServer log = logging.getLogger(__name__) diff --git a/ev3dev/display.py b/ev3dev2/display.py similarity index 100% rename from ev3dev/display.py rename to ev3dev2/display.py diff --git a/ev3dev/fonts/__init__.py b/ev3dev2/fonts/__init__.py similarity index 100% rename from ev3dev/fonts/__init__.py rename to ev3dev2/fonts/__init__.py diff --git a/ev3dev/fonts/charB08.pbm b/ev3dev2/fonts/charB08.pbm similarity index 100% rename from ev3dev/fonts/charB08.pbm rename to ev3dev2/fonts/charB08.pbm diff --git a/ev3dev/fonts/charB08.pil b/ev3dev2/fonts/charB08.pil similarity index 100% rename from ev3dev/fonts/charB08.pil rename to ev3dev2/fonts/charB08.pil diff --git a/ev3dev/fonts/charB10.pbm b/ev3dev2/fonts/charB10.pbm similarity index 100% rename from ev3dev/fonts/charB10.pbm rename to ev3dev2/fonts/charB10.pbm diff --git a/ev3dev/fonts/charB10.pil b/ev3dev2/fonts/charB10.pil similarity index 100% rename from ev3dev/fonts/charB10.pil rename to ev3dev2/fonts/charB10.pil diff --git a/ev3dev/fonts/charB12.pbm b/ev3dev2/fonts/charB12.pbm similarity index 100% rename from ev3dev/fonts/charB12.pbm rename to ev3dev2/fonts/charB12.pbm diff --git a/ev3dev/fonts/charB12.pil b/ev3dev2/fonts/charB12.pil similarity index 100% rename from ev3dev/fonts/charB12.pil rename to ev3dev2/fonts/charB12.pil diff --git a/ev3dev/fonts/charB14.pbm b/ev3dev2/fonts/charB14.pbm similarity index 100% rename from ev3dev/fonts/charB14.pbm rename to ev3dev2/fonts/charB14.pbm diff --git a/ev3dev/fonts/charB14.pil b/ev3dev2/fonts/charB14.pil similarity index 100% rename from ev3dev/fonts/charB14.pil rename to ev3dev2/fonts/charB14.pil diff --git a/ev3dev/fonts/charB18.pbm b/ev3dev2/fonts/charB18.pbm similarity index 100% rename from ev3dev/fonts/charB18.pbm rename to ev3dev2/fonts/charB18.pbm diff --git a/ev3dev/fonts/charB18.pil b/ev3dev2/fonts/charB18.pil similarity index 100% rename from ev3dev/fonts/charB18.pil rename to ev3dev2/fonts/charB18.pil diff --git a/ev3dev/fonts/charB24.pbm b/ev3dev2/fonts/charB24.pbm similarity index 100% rename from ev3dev/fonts/charB24.pbm rename to ev3dev2/fonts/charB24.pbm diff --git a/ev3dev/fonts/charB24.pil b/ev3dev2/fonts/charB24.pil similarity index 100% rename from ev3dev/fonts/charB24.pil rename to ev3dev2/fonts/charB24.pil diff --git a/ev3dev/fonts/charBI08.pbm b/ev3dev2/fonts/charBI08.pbm similarity index 100% rename from ev3dev/fonts/charBI08.pbm rename to ev3dev2/fonts/charBI08.pbm diff --git a/ev3dev/fonts/charBI08.pil b/ev3dev2/fonts/charBI08.pil similarity index 100% rename from ev3dev/fonts/charBI08.pil rename to ev3dev2/fonts/charBI08.pil diff --git a/ev3dev/fonts/charBI10.pbm b/ev3dev2/fonts/charBI10.pbm similarity index 100% rename from ev3dev/fonts/charBI10.pbm rename to ev3dev2/fonts/charBI10.pbm diff --git a/ev3dev/fonts/charBI10.pil b/ev3dev2/fonts/charBI10.pil similarity index 100% rename from ev3dev/fonts/charBI10.pil rename to ev3dev2/fonts/charBI10.pil diff --git a/ev3dev/fonts/charBI12.pbm b/ev3dev2/fonts/charBI12.pbm similarity index 100% rename from ev3dev/fonts/charBI12.pbm rename to ev3dev2/fonts/charBI12.pbm diff --git a/ev3dev/fonts/charBI12.pil b/ev3dev2/fonts/charBI12.pil similarity index 100% rename from ev3dev/fonts/charBI12.pil rename to ev3dev2/fonts/charBI12.pil diff --git a/ev3dev/fonts/charBI14.pbm b/ev3dev2/fonts/charBI14.pbm similarity index 100% rename from ev3dev/fonts/charBI14.pbm rename to ev3dev2/fonts/charBI14.pbm diff --git a/ev3dev/fonts/charBI14.pil b/ev3dev2/fonts/charBI14.pil similarity index 100% rename from ev3dev/fonts/charBI14.pil rename to ev3dev2/fonts/charBI14.pil diff --git a/ev3dev/fonts/charBI18.pbm b/ev3dev2/fonts/charBI18.pbm similarity index 100% rename from ev3dev/fonts/charBI18.pbm rename to ev3dev2/fonts/charBI18.pbm diff --git a/ev3dev/fonts/charBI18.pil b/ev3dev2/fonts/charBI18.pil similarity index 100% rename from ev3dev/fonts/charBI18.pil rename to ev3dev2/fonts/charBI18.pil diff --git a/ev3dev/fonts/charBI24.pbm b/ev3dev2/fonts/charBI24.pbm similarity index 100% rename from ev3dev/fonts/charBI24.pbm rename to ev3dev2/fonts/charBI24.pbm diff --git a/ev3dev/fonts/charBI24.pil b/ev3dev2/fonts/charBI24.pil similarity index 100% rename from ev3dev/fonts/charBI24.pil rename to ev3dev2/fonts/charBI24.pil diff --git a/ev3dev/fonts/charI08.pbm b/ev3dev2/fonts/charI08.pbm similarity index 100% rename from ev3dev/fonts/charI08.pbm rename to ev3dev2/fonts/charI08.pbm diff --git a/ev3dev/fonts/charI08.pil b/ev3dev2/fonts/charI08.pil similarity index 100% rename from ev3dev/fonts/charI08.pil rename to ev3dev2/fonts/charI08.pil diff --git a/ev3dev/fonts/charI10.pbm b/ev3dev2/fonts/charI10.pbm similarity index 100% rename from ev3dev/fonts/charI10.pbm rename to ev3dev2/fonts/charI10.pbm diff --git a/ev3dev/fonts/charI10.pil b/ev3dev2/fonts/charI10.pil similarity index 100% rename from ev3dev/fonts/charI10.pil rename to ev3dev2/fonts/charI10.pil diff --git a/ev3dev/fonts/charI12.pbm b/ev3dev2/fonts/charI12.pbm similarity index 100% rename from ev3dev/fonts/charI12.pbm rename to ev3dev2/fonts/charI12.pbm diff --git a/ev3dev/fonts/charI12.pil b/ev3dev2/fonts/charI12.pil similarity index 100% rename from ev3dev/fonts/charI12.pil rename to ev3dev2/fonts/charI12.pil diff --git a/ev3dev/fonts/charI14.pbm b/ev3dev2/fonts/charI14.pbm similarity index 100% rename from ev3dev/fonts/charI14.pbm rename to ev3dev2/fonts/charI14.pbm diff --git a/ev3dev/fonts/charI14.pil b/ev3dev2/fonts/charI14.pil similarity index 100% rename from ev3dev/fonts/charI14.pil rename to ev3dev2/fonts/charI14.pil diff --git a/ev3dev/fonts/charI18.pbm b/ev3dev2/fonts/charI18.pbm similarity index 100% rename from ev3dev/fonts/charI18.pbm rename to ev3dev2/fonts/charI18.pbm diff --git a/ev3dev/fonts/charI18.pil b/ev3dev2/fonts/charI18.pil similarity index 100% rename from ev3dev/fonts/charI18.pil rename to ev3dev2/fonts/charI18.pil diff --git a/ev3dev/fonts/charI24.pbm b/ev3dev2/fonts/charI24.pbm similarity index 100% rename from ev3dev/fonts/charI24.pbm rename to ev3dev2/fonts/charI24.pbm diff --git a/ev3dev/fonts/charI24.pil b/ev3dev2/fonts/charI24.pil similarity index 100% rename from ev3dev/fonts/charI24.pil rename to ev3dev2/fonts/charI24.pil diff --git a/ev3dev/fonts/charR08.pbm b/ev3dev2/fonts/charR08.pbm similarity index 100% rename from ev3dev/fonts/charR08.pbm rename to ev3dev2/fonts/charR08.pbm diff --git a/ev3dev/fonts/charR08.pil b/ev3dev2/fonts/charR08.pil similarity index 100% rename from ev3dev/fonts/charR08.pil rename to ev3dev2/fonts/charR08.pil diff --git a/ev3dev/fonts/charR10.pbm b/ev3dev2/fonts/charR10.pbm similarity index 100% rename from ev3dev/fonts/charR10.pbm rename to ev3dev2/fonts/charR10.pbm diff --git a/ev3dev/fonts/charR10.pil b/ev3dev2/fonts/charR10.pil similarity index 100% rename from ev3dev/fonts/charR10.pil rename to ev3dev2/fonts/charR10.pil diff --git a/ev3dev/fonts/charR12.pbm b/ev3dev2/fonts/charR12.pbm similarity index 100% rename from ev3dev/fonts/charR12.pbm rename to ev3dev2/fonts/charR12.pbm diff --git a/ev3dev/fonts/charR12.pil b/ev3dev2/fonts/charR12.pil similarity index 100% rename from ev3dev/fonts/charR12.pil rename to ev3dev2/fonts/charR12.pil diff --git a/ev3dev/fonts/charR14.pbm b/ev3dev2/fonts/charR14.pbm similarity index 100% rename from ev3dev/fonts/charR14.pbm rename to ev3dev2/fonts/charR14.pbm diff --git a/ev3dev/fonts/charR14.pil b/ev3dev2/fonts/charR14.pil similarity index 100% rename from ev3dev/fonts/charR14.pil rename to ev3dev2/fonts/charR14.pil diff --git a/ev3dev/fonts/charR18.pbm b/ev3dev2/fonts/charR18.pbm similarity index 100% rename from ev3dev/fonts/charR18.pbm rename to ev3dev2/fonts/charR18.pbm diff --git a/ev3dev/fonts/charR18.pil b/ev3dev2/fonts/charR18.pil similarity index 100% rename from ev3dev/fonts/charR18.pil rename to ev3dev2/fonts/charR18.pil diff --git a/ev3dev/fonts/charR24.pbm b/ev3dev2/fonts/charR24.pbm similarity index 100% rename from ev3dev/fonts/charR24.pbm rename to ev3dev2/fonts/charR24.pbm diff --git a/ev3dev/fonts/charR24.pil b/ev3dev2/fonts/charR24.pil similarity index 100% rename from ev3dev/fonts/charR24.pil rename to ev3dev2/fonts/charR24.pil diff --git a/ev3dev/fonts/courB08.pbm b/ev3dev2/fonts/courB08.pbm similarity index 100% rename from ev3dev/fonts/courB08.pbm rename to ev3dev2/fonts/courB08.pbm diff --git a/ev3dev/fonts/courB08.pil b/ev3dev2/fonts/courB08.pil similarity index 100% rename from ev3dev/fonts/courB08.pil rename to ev3dev2/fonts/courB08.pil diff --git a/ev3dev/fonts/courB10.pbm b/ev3dev2/fonts/courB10.pbm similarity index 100% rename from ev3dev/fonts/courB10.pbm rename to ev3dev2/fonts/courB10.pbm diff --git a/ev3dev/fonts/courB10.pil b/ev3dev2/fonts/courB10.pil similarity index 100% rename from ev3dev/fonts/courB10.pil rename to ev3dev2/fonts/courB10.pil diff --git a/ev3dev/fonts/courB12.pbm b/ev3dev2/fonts/courB12.pbm similarity index 100% rename from ev3dev/fonts/courB12.pbm rename to ev3dev2/fonts/courB12.pbm diff --git a/ev3dev/fonts/courB12.pil b/ev3dev2/fonts/courB12.pil similarity index 100% rename from ev3dev/fonts/courB12.pil rename to ev3dev2/fonts/courB12.pil diff --git a/ev3dev/fonts/courB14.pbm b/ev3dev2/fonts/courB14.pbm similarity index 100% rename from ev3dev/fonts/courB14.pbm rename to ev3dev2/fonts/courB14.pbm diff --git a/ev3dev/fonts/courB14.pil b/ev3dev2/fonts/courB14.pil similarity index 100% rename from ev3dev/fonts/courB14.pil rename to ev3dev2/fonts/courB14.pil diff --git a/ev3dev/fonts/courB18.pbm b/ev3dev2/fonts/courB18.pbm similarity index 100% rename from ev3dev/fonts/courB18.pbm rename to ev3dev2/fonts/courB18.pbm diff --git a/ev3dev/fonts/courB18.pil b/ev3dev2/fonts/courB18.pil similarity index 100% rename from ev3dev/fonts/courB18.pil rename to ev3dev2/fonts/courB18.pil diff --git a/ev3dev/fonts/courB24.pbm b/ev3dev2/fonts/courB24.pbm similarity index 100% rename from ev3dev/fonts/courB24.pbm rename to ev3dev2/fonts/courB24.pbm diff --git a/ev3dev/fonts/courB24.pil b/ev3dev2/fonts/courB24.pil similarity index 100% rename from ev3dev/fonts/courB24.pil rename to ev3dev2/fonts/courB24.pil diff --git a/ev3dev/fonts/courBO08.pbm b/ev3dev2/fonts/courBO08.pbm similarity index 100% rename from ev3dev/fonts/courBO08.pbm rename to ev3dev2/fonts/courBO08.pbm diff --git a/ev3dev/fonts/courBO08.pil b/ev3dev2/fonts/courBO08.pil similarity index 100% rename from ev3dev/fonts/courBO08.pil rename to ev3dev2/fonts/courBO08.pil diff --git a/ev3dev/fonts/courBO10.pbm b/ev3dev2/fonts/courBO10.pbm similarity index 100% rename from ev3dev/fonts/courBO10.pbm rename to ev3dev2/fonts/courBO10.pbm diff --git a/ev3dev/fonts/courBO10.pil b/ev3dev2/fonts/courBO10.pil similarity index 100% rename from ev3dev/fonts/courBO10.pil rename to ev3dev2/fonts/courBO10.pil diff --git a/ev3dev/fonts/courBO12.pbm b/ev3dev2/fonts/courBO12.pbm similarity index 100% rename from ev3dev/fonts/courBO12.pbm rename to ev3dev2/fonts/courBO12.pbm diff --git a/ev3dev/fonts/courBO12.pil b/ev3dev2/fonts/courBO12.pil similarity index 100% rename from ev3dev/fonts/courBO12.pil rename to ev3dev2/fonts/courBO12.pil diff --git a/ev3dev/fonts/courBO14.pbm b/ev3dev2/fonts/courBO14.pbm similarity index 100% rename from ev3dev/fonts/courBO14.pbm rename to ev3dev2/fonts/courBO14.pbm diff --git a/ev3dev/fonts/courBO14.pil b/ev3dev2/fonts/courBO14.pil similarity index 100% rename from ev3dev/fonts/courBO14.pil rename to ev3dev2/fonts/courBO14.pil diff --git a/ev3dev/fonts/courBO18.pbm b/ev3dev2/fonts/courBO18.pbm similarity index 100% rename from ev3dev/fonts/courBO18.pbm rename to ev3dev2/fonts/courBO18.pbm diff --git a/ev3dev/fonts/courBO18.pil b/ev3dev2/fonts/courBO18.pil similarity index 100% rename from ev3dev/fonts/courBO18.pil rename to ev3dev2/fonts/courBO18.pil diff --git a/ev3dev/fonts/courBO24.pbm b/ev3dev2/fonts/courBO24.pbm similarity index 100% rename from ev3dev/fonts/courBO24.pbm rename to ev3dev2/fonts/courBO24.pbm diff --git a/ev3dev/fonts/courBO24.pil b/ev3dev2/fonts/courBO24.pil similarity index 100% rename from ev3dev/fonts/courBO24.pil rename to ev3dev2/fonts/courBO24.pil diff --git a/ev3dev/fonts/courO08.pbm b/ev3dev2/fonts/courO08.pbm similarity index 100% rename from ev3dev/fonts/courO08.pbm rename to ev3dev2/fonts/courO08.pbm diff --git a/ev3dev/fonts/courO08.pil b/ev3dev2/fonts/courO08.pil similarity index 100% rename from ev3dev/fonts/courO08.pil rename to ev3dev2/fonts/courO08.pil diff --git a/ev3dev/fonts/courO10.pbm b/ev3dev2/fonts/courO10.pbm similarity index 100% rename from ev3dev/fonts/courO10.pbm rename to ev3dev2/fonts/courO10.pbm diff --git a/ev3dev/fonts/courO10.pil b/ev3dev2/fonts/courO10.pil similarity index 100% rename from ev3dev/fonts/courO10.pil rename to ev3dev2/fonts/courO10.pil diff --git a/ev3dev/fonts/courO12.pbm b/ev3dev2/fonts/courO12.pbm similarity index 100% rename from ev3dev/fonts/courO12.pbm rename to ev3dev2/fonts/courO12.pbm diff --git a/ev3dev/fonts/courO12.pil b/ev3dev2/fonts/courO12.pil similarity index 100% rename from ev3dev/fonts/courO12.pil rename to ev3dev2/fonts/courO12.pil diff --git a/ev3dev/fonts/courO14.pbm b/ev3dev2/fonts/courO14.pbm similarity index 100% rename from ev3dev/fonts/courO14.pbm rename to ev3dev2/fonts/courO14.pbm diff --git a/ev3dev/fonts/courO14.pil b/ev3dev2/fonts/courO14.pil similarity index 100% rename from ev3dev/fonts/courO14.pil rename to ev3dev2/fonts/courO14.pil diff --git a/ev3dev/fonts/courO18.pbm b/ev3dev2/fonts/courO18.pbm similarity index 100% rename from ev3dev/fonts/courO18.pbm rename to ev3dev2/fonts/courO18.pbm diff --git a/ev3dev/fonts/courO18.pil b/ev3dev2/fonts/courO18.pil similarity index 100% rename from ev3dev/fonts/courO18.pil rename to ev3dev2/fonts/courO18.pil diff --git a/ev3dev/fonts/courO24.pbm b/ev3dev2/fonts/courO24.pbm similarity index 100% rename from ev3dev/fonts/courO24.pbm rename to ev3dev2/fonts/courO24.pbm diff --git a/ev3dev/fonts/courO24.pil b/ev3dev2/fonts/courO24.pil similarity index 100% rename from ev3dev/fonts/courO24.pil rename to ev3dev2/fonts/courO24.pil diff --git a/ev3dev/fonts/courR08.pbm b/ev3dev2/fonts/courR08.pbm similarity index 100% rename from ev3dev/fonts/courR08.pbm rename to ev3dev2/fonts/courR08.pbm diff --git a/ev3dev/fonts/courR08.pil b/ev3dev2/fonts/courR08.pil similarity index 100% rename from ev3dev/fonts/courR08.pil rename to ev3dev2/fonts/courR08.pil diff --git a/ev3dev/fonts/courR10.pbm b/ev3dev2/fonts/courR10.pbm similarity index 100% rename from ev3dev/fonts/courR10.pbm rename to ev3dev2/fonts/courR10.pbm diff --git a/ev3dev/fonts/courR10.pil b/ev3dev2/fonts/courR10.pil similarity index 100% rename from ev3dev/fonts/courR10.pil rename to ev3dev2/fonts/courR10.pil diff --git a/ev3dev/fonts/courR12.pbm b/ev3dev2/fonts/courR12.pbm similarity index 100% rename from ev3dev/fonts/courR12.pbm rename to ev3dev2/fonts/courR12.pbm diff --git a/ev3dev/fonts/courR12.pil b/ev3dev2/fonts/courR12.pil similarity index 100% rename from ev3dev/fonts/courR12.pil rename to ev3dev2/fonts/courR12.pil diff --git a/ev3dev/fonts/courR14.pbm b/ev3dev2/fonts/courR14.pbm similarity index 100% rename from ev3dev/fonts/courR14.pbm rename to ev3dev2/fonts/courR14.pbm diff --git a/ev3dev/fonts/courR14.pil b/ev3dev2/fonts/courR14.pil similarity index 100% rename from ev3dev/fonts/courR14.pil rename to ev3dev2/fonts/courR14.pil diff --git a/ev3dev/fonts/courR18.pbm b/ev3dev2/fonts/courR18.pbm similarity index 100% rename from ev3dev/fonts/courR18.pbm rename to ev3dev2/fonts/courR18.pbm diff --git a/ev3dev/fonts/courR18.pil b/ev3dev2/fonts/courR18.pil similarity index 100% rename from ev3dev/fonts/courR18.pil rename to ev3dev2/fonts/courR18.pil diff --git a/ev3dev/fonts/courR24.pbm b/ev3dev2/fonts/courR24.pbm similarity index 100% rename from ev3dev/fonts/courR24.pbm rename to ev3dev2/fonts/courR24.pbm diff --git a/ev3dev/fonts/courR24.pil b/ev3dev2/fonts/courR24.pil similarity index 100% rename from ev3dev/fonts/courR24.pil rename to ev3dev2/fonts/courR24.pil diff --git a/ev3dev/fonts/helvB08.pbm b/ev3dev2/fonts/helvB08.pbm similarity index 100% rename from ev3dev/fonts/helvB08.pbm rename to ev3dev2/fonts/helvB08.pbm diff --git a/ev3dev/fonts/helvB08.pil b/ev3dev2/fonts/helvB08.pil similarity index 100% rename from ev3dev/fonts/helvB08.pil rename to ev3dev2/fonts/helvB08.pil diff --git a/ev3dev/fonts/helvB10.pbm b/ev3dev2/fonts/helvB10.pbm similarity index 100% rename from ev3dev/fonts/helvB10.pbm rename to ev3dev2/fonts/helvB10.pbm diff --git a/ev3dev/fonts/helvB10.pil b/ev3dev2/fonts/helvB10.pil similarity index 100% rename from ev3dev/fonts/helvB10.pil rename to ev3dev2/fonts/helvB10.pil diff --git a/ev3dev/fonts/helvB12.pbm b/ev3dev2/fonts/helvB12.pbm similarity index 100% rename from ev3dev/fonts/helvB12.pbm rename to ev3dev2/fonts/helvB12.pbm diff --git a/ev3dev/fonts/helvB12.pil b/ev3dev2/fonts/helvB12.pil similarity index 100% rename from ev3dev/fonts/helvB12.pil rename to ev3dev2/fonts/helvB12.pil diff --git a/ev3dev/fonts/helvB14.pbm b/ev3dev2/fonts/helvB14.pbm similarity index 100% rename from ev3dev/fonts/helvB14.pbm rename to ev3dev2/fonts/helvB14.pbm diff --git a/ev3dev/fonts/helvB14.pil b/ev3dev2/fonts/helvB14.pil similarity index 100% rename from ev3dev/fonts/helvB14.pil rename to ev3dev2/fonts/helvB14.pil diff --git a/ev3dev/fonts/helvB18.pbm b/ev3dev2/fonts/helvB18.pbm similarity index 100% rename from ev3dev/fonts/helvB18.pbm rename to ev3dev2/fonts/helvB18.pbm diff --git a/ev3dev/fonts/helvB18.pil b/ev3dev2/fonts/helvB18.pil similarity index 100% rename from ev3dev/fonts/helvB18.pil rename to ev3dev2/fonts/helvB18.pil diff --git a/ev3dev/fonts/helvB24.pbm b/ev3dev2/fonts/helvB24.pbm similarity index 100% rename from ev3dev/fonts/helvB24.pbm rename to ev3dev2/fonts/helvB24.pbm diff --git a/ev3dev/fonts/helvB24.pil b/ev3dev2/fonts/helvB24.pil similarity index 100% rename from ev3dev/fonts/helvB24.pil rename to ev3dev2/fonts/helvB24.pil diff --git a/ev3dev/fonts/helvBO08.pbm b/ev3dev2/fonts/helvBO08.pbm similarity index 100% rename from ev3dev/fonts/helvBO08.pbm rename to ev3dev2/fonts/helvBO08.pbm diff --git a/ev3dev/fonts/helvBO08.pil b/ev3dev2/fonts/helvBO08.pil similarity index 100% rename from ev3dev/fonts/helvBO08.pil rename to ev3dev2/fonts/helvBO08.pil diff --git a/ev3dev/fonts/helvBO10.pbm b/ev3dev2/fonts/helvBO10.pbm similarity index 100% rename from ev3dev/fonts/helvBO10.pbm rename to ev3dev2/fonts/helvBO10.pbm diff --git a/ev3dev/fonts/helvBO10.pil b/ev3dev2/fonts/helvBO10.pil similarity index 100% rename from ev3dev/fonts/helvBO10.pil rename to ev3dev2/fonts/helvBO10.pil diff --git a/ev3dev/fonts/helvBO12.pbm b/ev3dev2/fonts/helvBO12.pbm similarity index 100% rename from ev3dev/fonts/helvBO12.pbm rename to ev3dev2/fonts/helvBO12.pbm diff --git a/ev3dev/fonts/helvBO12.pil b/ev3dev2/fonts/helvBO12.pil similarity index 100% rename from ev3dev/fonts/helvBO12.pil rename to ev3dev2/fonts/helvBO12.pil diff --git a/ev3dev/fonts/helvBO14.pbm b/ev3dev2/fonts/helvBO14.pbm similarity index 100% rename from ev3dev/fonts/helvBO14.pbm rename to ev3dev2/fonts/helvBO14.pbm diff --git a/ev3dev/fonts/helvBO14.pil b/ev3dev2/fonts/helvBO14.pil similarity index 100% rename from ev3dev/fonts/helvBO14.pil rename to ev3dev2/fonts/helvBO14.pil diff --git a/ev3dev/fonts/helvBO18.pbm b/ev3dev2/fonts/helvBO18.pbm similarity index 100% rename from ev3dev/fonts/helvBO18.pbm rename to ev3dev2/fonts/helvBO18.pbm diff --git a/ev3dev/fonts/helvBO18.pil b/ev3dev2/fonts/helvBO18.pil similarity index 100% rename from ev3dev/fonts/helvBO18.pil rename to ev3dev2/fonts/helvBO18.pil diff --git a/ev3dev/fonts/helvBO24.pbm b/ev3dev2/fonts/helvBO24.pbm similarity index 100% rename from ev3dev/fonts/helvBO24.pbm rename to ev3dev2/fonts/helvBO24.pbm diff --git a/ev3dev/fonts/helvBO24.pil b/ev3dev2/fonts/helvBO24.pil similarity index 100% rename from ev3dev/fonts/helvBO24.pil rename to ev3dev2/fonts/helvBO24.pil diff --git a/ev3dev/fonts/helvO08.pbm b/ev3dev2/fonts/helvO08.pbm similarity index 100% rename from ev3dev/fonts/helvO08.pbm rename to ev3dev2/fonts/helvO08.pbm diff --git a/ev3dev/fonts/helvO08.pil b/ev3dev2/fonts/helvO08.pil similarity index 100% rename from ev3dev/fonts/helvO08.pil rename to ev3dev2/fonts/helvO08.pil diff --git a/ev3dev/fonts/helvO10.pbm b/ev3dev2/fonts/helvO10.pbm similarity index 100% rename from ev3dev/fonts/helvO10.pbm rename to ev3dev2/fonts/helvO10.pbm diff --git a/ev3dev/fonts/helvO10.pil b/ev3dev2/fonts/helvO10.pil similarity index 100% rename from ev3dev/fonts/helvO10.pil rename to ev3dev2/fonts/helvO10.pil diff --git a/ev3dev/fonts/helvO12.pbm b/ev3dev2/fonts/helvO12.pbm similarity index 100% rename from ev3dev/fonts/helvO12.pbm rename to ev3dev2/fonts/helvO12.pbm diff --git a/ev3dev/fonts/helvO12.pil b/ev3dev2/fonts/helvO12.pil similarity index 100% rename from ev3dev/fonts/helvO12.pil rename to ev3dev2/fonts/helvO12.pil diff --git a/ev3dev/fonts/helvO14.pbm b/ev3dev2/fonts/helvO14.pbm similarity index 100% rename from ev3dev/fonts/helvO14.pbm rename to ev3dev2/fonts/helvO14.pbm diff --git a/ev3dev/fonts/helvO14.pil b/ev3dev2/fonts/helvO14.pil similarity index 100% rename from ev3dev/fonts/helvO14.pil rename to ev3dev2/fonts/helvO14.pil diff --git a/ev3dev/fonts/helvO18.pbm b/ev3dev2/fonts/helvO18.pbm similarity index 100% rename from ev3dev/fonts/helvO18.pbm rename to ev3dev2/fonts/helvO18.pbm diff --git a/ev3dev/fonts/helvO18.pil b/ev3dev2/fonts/helvO18.pil similarity index 100% rename from ev3dev/fonts/helvO18.pil rename to ev3dev2/fonts/helvO18.pil diff --git a/ev3dev/fonts/helvO24.pbm b/ev3dev2/fonts/helvO24.pbm similarity index 100% rename from ev3dev/fonts/helvO24.pbm rename to ev3dev2/fonts/helvO24.pbm diff --git a/ev3dev/fonts/helvO24.pil b/ev3dev2/fonts/helvO24.pil similarity index 100% rename from ev3dev/fonts/helvO24.pil rename to ev3dev2/fonts/helvO24.pil diff --git a/ev3dev/fonts/helvR08.pbm b/ev3dev2/fonts/helvR08.pbm similarity index 100% rename from ev3dev/fonts/helvR08.pbm rename to ev3dev2/fonts/helvR08.pbm diff --git a/ev3dev/fonts/helvR08.pil b/ev3dev2/fonts/helvR08.pil similarity index 100% rename from ev3dev/fonts/helvR08.pil rename to ev3dev2/fonts/helvR08.pil diff --git a/ev3dev/fonts/helvR10.pbm b/ev3dev2/fonts/helvR10.pbm similarity index 100% rename from ev3dev/fonts/helvR10.pbm rename to ev3dev2/fonts/helvR10.pbm diff --git a/ev3dev/fonts/helvR10.pil b/ev3dev2/fonts/helvR10.pil similarity index 100% rename from ev3dev/fonts/helvR10.pil rename to ev3dev2/fonts/helvR10.pil diff --git a/ev3dev/fonts/helvR12.pbm b/ev3dev2/fonts/helvR12.pbm similarity index 100% rename from ev3dev/fonts/helvR12.pbm rename to ev3dev2/fonts/helvR12.pbm diff --git a/ev3dev/fonts/helvR12.pil b/ev3dev2/fonts/helvR12.pil similarity index 100% rename from ev3dev/fonts/helvR12.pil rename to ev3dev2/fonts/helvR12.pil diff --git a/ev3dev/fonts/helvR14.pbm b/ev3dev2/fonts/helvR14.pbm similarity index 100% rename from ev3dev/fonts/helvR14.pbm rename to ev3dev2/fonts/helvR14.pbm diff --git a/ev3dev/fonts/helvR14.pil b/ev3dev2/fonts/helvR14.pil similarity index 100% rename from ev3dev/fonts/helvR14.pil rename to ev3dev2/fonts/helvR14.pil diff --git a/ev3dev/fonts/helvR18.pbm b/ev3dev2/fonts/helvR18.pbm similarity index 100% rename from ev3dev/fonts/helvR18.pbm rename to ev3dev2/fonts/helvR18.pbm diff --git a/ev3dev/fonts/helvR18.pil b/ev3dev2/fonts/helvR18.pil similarity index 100% rename from ev3dev/fonts/helvR18.pil rename to ev3dev2/fonts/helvR18.pil diff --git a/ev3dev/fonts/helvR24.pbm b/ev3dev2/fonts/helvR24.pbm similarity index 100% rename from ev3dev/fonts/helvR24.pbm rename to ev3dev2/fonts/helvR24.pbm diff --git a/ev3dev/fonts/helvR24.pil b/ev3dev2/fonts/helvR24.pil similarity index 100% rename from ev3dev/fonts/helvR24.pil rename to ev3dev2/fonts/helvR24.pil diff --git a/ev3dev/fonts/luBIS08.pbm b/ev3dev2/fonts/luBIS08.pbm similarity index 100% rename from ev3dev/fonts/luBIS08.pbm rename to ev3dev2/fonts/luBIS08.pbm diff --git a/ev3dev/fonts/luBIS08.pil b/ev3dev2/fonts/luBIS08.pil similarity index 100% rename from ev3dev/fonts/luBIS08.pil rename to ev3dev2/fonts/luBIS08.pil diff --git a/ev3dev/fonts/luBIS10.pbm b/ev3dev2/fonts/luBIS10.pbm similarity index 100% rename from ev3dev/fonts/luBIS10.pbm rename to ev3dev2/fonts/luBIS10.pbm diff --git a/ev3dev/fonts/luBIS10.pil b/ev3dev2/fonts/luBIS10.pil similarity index 100% rename from ev3dev/fonts/luBIS10.pil rename to ev3dev2/fonts/luBIS10.pil diff --git a/ev3dev/fonts/luBIS12.pbm b/ev3dev2/fonts/luBIS12.pbm similarity index 100% rename from ev3dev/fonts/luBIS12.pbm rename to ev3dev2/fonts/luBIS12.pbm diff --git a/ev3dev/fonts/luBIS12.pil b/ev3dev2/fonts/luBIS12.pil similarity index 100% rename from ev3dev/fonts/luBIS12.pil rename to ev3dev2/fonts/luBIS12.pil diff --git a/ev3dev/fonts/luBIS14.pbm b/ev3dev2/fonts/luBIS14.pbm similarity index 100% rename from ev3dev/fonts/luBIS14.pbm rename to ev3dev2/fonts/luBIS14.pbm diff --git a/ev3dev/fonts/luBIS14.pil b/ev3dev2/fonts/luBIS14.pil similarity index 100% rename from ev3dev/fonts/luBIS14.pil rename to ev3dev2/fonts/luBIS14.pil diff --git a/ev3dev/fonts/luBIS18.pbm b/ev3dev2/fonts/luBIS18.pbm similarity index 100% rename from ev3dev/fonts/luBIS18.pbm rename to ev3dev2/fonts/luBIS18.pbm diff --git a/ev3dev/fonts/luBIS18.pil b/ev3dev2/fonts/luBIS18.pil similarity index 100% rename from ev3dev/fonts/luBIS18.pil rename to ev3dev2/fonts/luBIS18.pil diff --git a/ev3dev/fonts/luBIS19.pbm b/ev3dev2/fonts/luBIS19.pbm similarity index 100% rename from ev3dev/fonts/luBIS19.pbm rename to ev3dev2/fonts/luBIS19.pbm diff --git a/ev3dev/fonts/luBIS19.pil b/ev3dev2/fonts/luBIS19.pil similarity index 100% rename from ev3dev/fonts/luBIS19.pil rename to ev3dev2/fonts/luBIS19.pil diff --git a/ev3dev/fonts/luBIS24.pbm b/ev3dev2/fonts/luBIS24.pbm similarity index 100% rename from ev3dev/fonts/luBIS24.pbm rename to ev3dev2/fonts/luBIS24.pbm diff --git a/ev3dev/fonts/luBIS24.pil b/ev3dev2/fonts/luBIS24.pil similarity index 100% rename from ev3dev/fonts/luBIS24.pil rename to ev3dev2/fonts/luBIS24.pil diff --git a/ev3dev/fonts/luBS08.pbm b/ev3dev2/fonts/luBS08.pbm similarity index 100% rename from ev3dev/fonts/luBS08.pbm rename to ev3dev2/fonts/luBS08.pbm diff --git a/ev3dev/fonts/luBS08.pil b/ev3dev2/fonts/luBS08.pil similarity index 100% rename from ev3dev/fonts/luBS08.pil rename to ev3dev2/fonts/luBS08.pil diff --git a/ev3dev/fonts/luBS10.pbm b/ev3dev2/fonts/luBS10.pbm similarity index 100% rename from ev3dev/fonts/luBS10.pbm rename to ev3dev2/fonts/luBS10.pbm diff --git a/ev3dev/fonts/luBS10.pil b/ev3dev2/fonts/luBS10.pil similarity index 100% rename from ev3dev/fonts/luBS10.pil rename to ev3dev2/fonts/luBS10.pil diff --git a/ev3dev/fonts/luBS12.pbm b/ev3dev2/fonts/luBS12.pbm similarity index 100% rename from ev3dev/fonts/luBS12.pbm rename to ev3dev2/fonts/luBS12.pbm diff --git a/ev3dev/fonts/luBS12.pil b/ev3dev2/fonts/luBS12.pil similarity index 100% rename from ev3dev/fonts/luBS12.pil rename to ev3dev2/fonts/luBS12.pil diff --git a/ev3dev/fonts/luBS14.pbm b/ev3dev2/fonts/luBS14.pbm similarity index 100% rename from ev3dev/fonts/luBS14.pbm rename to ev3dev2/fonts/luBS14.pbm diff --git a/ev3dev/fonts/luBS14.pil b/ev3dev2/fonts/luBS14.pil similarity index 100% rename from ev3dev/fonts/luBS14.pil rename to ev3dev2/fonts/luBS14.pil diff --git a/ev3dev/fonts/luBS18.pbm b/ev3dev2/fonts/luBS18.pbm similarity index 100% rename from ev3dev/fonts/luBS18.pbm rename to ev3dev2/fonts/luBS18.pbm diff --git a/ev3dev/fonts/luBS18.pil b/ev3dev2/fonts/luBS18.pil similarity index 100% rename from ev3dev/fonts/luBS18.pil rename to ev3dev2/fonts/luBS18.pil diff --git a/ev3dev/fonts/luBS19.pbm b/ev3dev2/fonts/luBS19.pbm similarity index 100% rename from ev3dev/fonts/luBS19.pbm rename to ev3dev2/fonts/luBS19.pbm diff --git a/ev3dev/fonts/luBS19.pil b/ev3dev2/fonts/luBS19.pil similarity index 100% rename from ev3dev/fonts/luBS19.pil rename to ev3dev2/fonts/luBS19.pil diff --git a/ev3dev/fonts/luBS24.pbm b/ev3dev2/fonts/luBS24.pbm similarity index 100% rename from ev3dev/fonts/luBS24.pbm rename to ev3dev2/fonts/luBS24.pbm diff --git a/ev3dev/fonts/luBS24.pil b/ev3dev2/fonts/luBS24.pil similarity index 100% rename from ev3dev/fonts/luBS24.pil rename to ev3dev2/fonts/luBS24.pil diff --git a/ev3dev/fonts/luIS08.pbm b/ev3dev2/fonts/luIS08.pbm similarity index 100% rename from ev3dev/fonts/luIS08.pbm rename to ev3dev2/fonts/luIS08.pbm diff --git a/ev3dev/fonts/luIS08.pil b/ev3dev2/fonts/luIS08.pil similarity index 100% rename from ev3dev/fonts/luIS08.pil rename to ev3dev2/fonts/luIS08.pil diff --git a/ev3dev/fonts/luIS10.pbm b/ev3dev2/fonts/luIS10.pbm similarity index 100% rename from ev3dev/fonts/luIS10.pbm rename to ev3dev2/fonts/luIS10.pbm diff --git a/ev3dev/fonts/luIS10.pil b/ev3dev2/fonts/luIS10.pil similarity index 100% rename from ev3dev/fonts/luIS10.pil rename to ev3dev2/fonts/luIS10.pil diff --git a/ev3dev/fonts/luIS12.pbm b/ev3dev2/fonts/luIS12.pbm similarity index 100% rename from ev3dev/fonts/luIS12.pbm rename to ev3dev2/fonts/luIS12.pbm diff --git a/ev3dev/fonts/luIS12.pil b/ev3dev2/fonts/luIS12.pil similarity index 100% rename from ev3dev/fonts/luIS12.pil rename to ev3dev2/fonts/luIS12.pil diff --git a/ev3dev/fonts/luIS14.pbm b/ev3dev2/fonts/luIS14.pbm similarity index 100% rename from ev3dev/fonts/luIS14.pbm rename to ev3dev2/fonts/luIS14.pbm diff --git a/ev3dev/fonts/luIS14.pil b/ev3dev2/fonts/luIS14.pil similarity index 100% rename from ev3dev/fonts/luIS14.pil rename to ev3dev2/fonts/luIS14.pil diff --git a/ev3dev/fonts/luIS18.pbm b/ev3dev2/fonts/luIS18.pbm similarity index 100% rename from ev3dev/fonts/luIS18.pbm rename to ev3dev2/fonts/luIS18.pbm diff --git a/ev3dev/fonts/luIS18.pil b/ev3dev2/fonts/luIS18.pil similarity index 100% rename from ev3dev/fonts/luIS18.pil rename to ev3dev2/fonts/luIS18.pil diff --git a/ev3dev/fonts/luIS19.pbm b/ev3dev2/fonts/luIS19.pbm similarity index 100% rename from ev3dev/fonts/luIS19.pbm rename to ev3dev2/fonts/luIS19.pbm diff --git a/ev3dev/fonts/luIS19.pil b/ev3dev2/fonts/luIS19.pil similarity index 100% rename from ev3dev/fonts/luIS19.pil rename to ev3dev2/fonts/luIS19.pil diff --git a/ev3dev/fonts/luIS24.pbm b/ev3dev2/fonts/luIS24.pbm similarity index 100% rename from ev3dev/fonts/luIS24.pbm rename to ev3dev2/fonts/luIS24.pbm diff --git a/ev3dev/fonts/luIS24.pil b/ev3dev2/fonts/luIS24.pil similarity index 100% rename from ev3dev/fonts/luIS24.pil rename to ev3dev2/fonts/luIS24.pil diff --git a/ev3dev/fonts/luRS08.pbm b/ev3dev2/fonts/luRS08.pbm similarity index 100% rename from ev3dev/fonts/luRS08.pbm rename to ev3dev2/fonts/luRS08.pbm diff --git a/ev3dev/fonts/luRS08.pil b/ev3dev2/fonts/luRS08.pil similarity index 100% rename from ev3dev/fonts/luRS08.pil rename to ev3dev2/fonts/luRS08.pil diff --git a/ev3dev/fonts/luRS10.pbm b/ev3dev2/fonts/luRS10.pbm similarity index 100% rename from ev3dev/fonts/luRS10.pbm rename to ev3dev2/fonts/luRS10.pbm diff --git a/ev3dev/fonts/luRS10.pil b/ev3dev2/fonts/luRS10.pil similarity index 100% rename from ev3dev/fonts/luRS10.pil rename to ev3dev2/fonts/luRS10.pil diff --git a/ev3dev/fonts/luRS12.pbm b/ev3dev2/fonts/luRS12.pbm similarity index 100% rename from ev3dev/fonts/luRS12.pbm rename to ev3dev2/fonts/luRS12.pbm diff --git a/ev3dev/fonts/luRS12.pil b/ev3dev2/fonts/luRS12.pil similarity index 100% rename from ev3dev/fonts/luRS12.pil rename to ev3dev2/fonts/luRS12.pil diff --git a/ev3dev/fonts/luRS14.pbm b/ev3dev2/fonts/luRS14.pbm similarity index 100% rename from ev3dev/fonts/luRS14.pbm rename to ev3dev2/fonts/luRS14.pbm diff --git a/ev3dev/fonts/luRS14.pil b/ev3dev2/fonts/luRS14.pil similarity index 100% rename from ev3dev/fonts/luRS14.pil rename to ev3dev2/fonts/luRS14.pil diff --git a/ev3dev/fonts/luRS18.pbm b/ev3dev2/fonts/luRS18.pbm similarity index 100% rename from ev3dev/fonts/luRS18.pbm rename to ev3dev2/fonts/luRS18.pbm diff --git a/ev3dev/fonts/luRS18.pil b/ev3dev2/fonts/luRS18.pil similarity index 100% rename from ev3dev/fonts/luRS18.pil rename to ev3dev2/fonts/luRS18.pil diff --git a/ev3dev/fonts/luRS19.pbm b/ev3dev2/fonts/luRS19.pbm similarity index 100% rename from ev3dev/fonts/luRS19.pbm rename to ev3dev2/fonts/luRS19.pbm diff --git a/ev3dev/fonts/luRS19.pil b/ev3dev2/fonts/luRS19.pil similarity index 100% rename from ev3dev/fonts/luRS19.pil rename to ev3dev2/fonts/luRS19.pil diff --git a/ev3dev/fonts/luRS24.pbm b/ev3dev2/fonts/luRS24.pbm similarity index 100% rename from ev3dev/fonts/luRS24.pbm rename to ev3dev2/fonts/luRS24.pbm diff --git a/ev3dev/fonts/luRS24.pil b/ev3dev2/fonts/luRS24.pil similarity index 100% rename from ev3dev/fonts/luRS24.pil rename to ev3dev2/fonts/luRS24.pil diff --git a/ev3dev/fonts/lubB08.pbm b/ev3dev2/fonts/lubB08.pbm similarity index 100% rename from ev3dev/fonts/lubB08.pbm rename to ev3dev2/fonts/lubB08.pbm diff --git a/ev3dev/fonts/lubB08.pil b/ev3dev2/fonts/lubB08.pil similarity index 100% rename from ev3dev/fonts/lubB08.pil rename to ev3dev2/fonts/lubB08.pil diff --git a/ev3dev/fonts/lubB10.pbm b/ev3dev2/fonts/lubB10.pbm similarity index 100% rename from ev3dev/fonts/lubB10.pbm rename to ev3dev2/fonts/lubB10.pbm diff --git a/ev3dev/fonts/lubB10.pil b/ev3dev2/fonts/lubB10.pil similarity index 100% rename from ev3dev/fonts/lubB10.pil rename to ev3dev2/fonts/lubB10.pil diff --git a/ev3dev/fonts/lubB12.pbm b/ev3dev2/fonts/lubB12.pbm similarity index 100% rename from ev3dev/fonts/lubB12.pbm rename to ev3dev2/fonts/lubB12.pbm diff --git a/ev3dev/fonts/lubB12.pil b/ev3dev2/fonts/lubB12.pil similarity index 100% rename from ev3dev/fonts/lubB12.pil rename to ev3dev2/fonts/lubB12.pil diff --git a/ev3dev/fonts/lubB14.pbm b/ev3dev2/fonts/lubB14.pbm similarity index 100% rename from ev3dev/fonts/lubB14.pbm rename to ev3dev2/fonts/lubB14.pbm diff --git a/ev3dev/fonts/lubB14.pil b/ev3dev2/fonts/lubB14.pil similarity index 100% rename from ev3dev/fonts/lubB14.pil rename to ev3dev2/fonts/lubB14.pil diff --git a/ev3dev/fonts/lubB18.pbm b/ev3dev2/fonts/lubB18.pbm similarity index 100% rename from ev3dev/fonts/lubB18.pbm rename to ev3dev2/fonts/lubB18.pbm diff --git a/ev3dev/fonts/lubB18.pil b/ev3dev2/fonts/lubB18.pil similarity index 100% rename from ev3dev/fonts/lubB18.pil rename to ev3dev2/fonts/lubB18.pil diff --git a/ev3dev/fonts/lubB19.pbm b/ev3dev2/fonts/lubB19.pbm similarity index 100% rename from ev3dev/fonts/lubB19.pbm rename to ev3dev2/fonts/lubB19.pbm diff --git a/ev3dev/fonts/lubB19.pil b/ev3dev2/fonts/lubB19.pil similarity index 100% rename from ev3dev/fonts/lubB19.pil rename to ev3dev2/fonts/lubB19.pil diff --git a/ev3dev/fonts/lubB24.pbm b/ev3dev2/fonts/lubB24.pbm similarity index 100% rename from ev3dev/fonts/lubB24.pbm rename to ev3dev2/fonts/lubB24.pbm diff --git a/ev3dev/fonts/lubB24.pil b/ev3dev2/fonts/lubB24.pil similarity index 100% rename from ev3dev/fonts/lubB24.pil rename to ev3dev2/fonts/lubB24.pil diff --git a/ev3dev/fonts/lubBI08.pbm b/ev3dev2/fonts/lubBI08.pbm similarity index 100% rename from ev3dev/fonts/lubBI08.pbm rename to ev3dev2/fonts/lubBI08.pbm diff --git a/ev3dev/fonts/lubBI08.pil b/ev3dev2/fonts/lubBI08.pil similarity index 100% rename from ev3dev/fonts/lubBI08.pil rename to ev3dev2/fonts/lubBI08.pil diff --git a/ev3dev/fonts/lubBI10.pbm b/ev3dev2/fonts/lubBI10.pbm similarity index 100% rename from ev3dev/fonts/lubBI10.pbm rename to ev3dev2/fonts/lubBI10.pbm diff --git a/ev3dev/fonts/lubBI10.pil b/ev3dev2/fonts/lubBI10.pil similarity index 100% rename from ev3dev/fonts/lubBI10.pil rename to ev3dev2/fonts/lubBI10.pil diff --git a/ev3dev/fonts/lubBI12.pbm b/ev3dev2/fonts/lubBI12.pbm similarity index 100% rename from ev3dev/fonts/lubBI12.pbm rename to ev3dev2/fonts/lubBI12.pbm diff --git a/ev3dev/fonts/lubBI12.pil b/ev3dev2/fonts/lubBI12.pil similarity index 100% rename from ev3dev/fonts/lubBI12.pil rename to ev3dev2/fonts/lubBI12.pil diff --git a/ev3dev/fonts/lubBI14.pbm b/ev3dev2/fonts/lubBI14.pbm similarity index 100% rename from ev3dev/fonts/lubBI14.pbm rename to ev3dev2/fonts/lubBI14.pbm diff --git a/ev3dev/fonts/lubBI14.pil b/ev3dev2/fonts/lubBI14.pil similarity index 100% rename from ev3dev/fonts/lubBI14.pil rename to ev3dev2/fonts/lubBI14.pil diff --git a/ev3dev/fonts/lubBI18.pbm b/ev3dev2/fonts/lubBI18.pbm similarity index 100% rename from ev3dev/fonts/lubBI18.pbm rename to ev3dev2/fonts/lubBI18.pbm diff --git a/ev3dev/fonts/lubBI18.pil b/ev3dev2/fonts/lubBI18.pil similarity index 100% rename from ev3dev/fonts/lubBI18.pil rename to ev3dev2/fonts/lubBI18.pil diff --git a/ev3dev/fonts/lubBI19.pbm b/ev3dev2/fonts/lubBI19.pbm similarity index 100% rename from ev3dev/fonts/lubBI19.pbm rename to ev3dev2/fonts/lubBI19.pbm diff --git a/ev3dev/fonts/lubBI19.pil b/ev3dev2/fonts/lubBI19.pil similarity index 100% rename from ev3dev/fonts/lubBI19.pil rename to ev3dev2/fonts/lubBI19.pil diff --git a/ev3dev/fonts/lubBI24.pbm b/ev3dev2/fonts/lubBI24.pbm similarity index 100% rename from ev3dev/fonts/lubBI24.pbm rename to ev3dev2/fonts/lubBI24.pbm diff --git a/ev3dev/fonts/lubBI24.pil b/ev3dev2/fonts/lubBI24.pil similarity index 100% rename from ev3dev/fonts/lubBI24.pil rename to ev3dev2/fonts/lubBI24.pil diff --git a/ev3dev/fonts/lubI08.pbm b/ev3dev2/fonts/lubI08.pbm similarity index 100% rename from ev3dev/fonts/lubI08.pbm rename to ev3dev2/fonts/lubI08.pbm diff --git a/ev3dev/fonts/lubI08.pil b/ev3dev2/fonts/lubI08.pil similarity index 100% rename from ev3dev/fonts/lubI08.pil rename to ev3dev2/fonts/lubI08.pil diff --git a/ev3dev/fonts/lubI10.pbm b/ev3dev2/fonts/lubI10.pbm similarity index 100% rename from ev3dev/fonts/lubI10.pbm rename to ev3dev2/fonts/lubI10.pbm diff --git a/ev3dev/fonts/lubI10.pil b/ev3dev2/fonts/lubI10.pil similarity index 100% rename from ev3dev/fonts/lubI10.pil rename to ev3dev2/fonts/lubI10.pil diff --git a/ev3dev/fonts/lubI12.pbm b/ev3dev2/fonts/lubI12.pbm similarity index 100% rename from ev3dev/fonts/lubI12.pbm rename to ev3dev2/fonts/lubI12.pbm diff --git a/ev3dev/fonts/lubI12.pil b/ev3dev2/fonts/lubI12.pil similarity index 100% rename from ev3dev/fonts/lubI12.pil rename to ev3dev2/fonts/lubI12.pil diff --git a/ev3dev/fonts/lubI14.pbm b/ev3dev2/fonts/lubI14.pbm similarity index 100% rename from ev3dev/fonts/lubI14.pbm rename to ev3dev2/fonts/lubI14.pbm diff --git a/ev3dev/fonts/lubI14.pil b/ev3dev2/fonts/lubI14.pil similarity index 100% rename from ev3dev/fonts/lubI14.pil rename to ev3dev2/fonts/lubI14.pil diff --git a/ev3dev/fonts/lubI18.pbm b/ev3dev2/fonts/lubI18.pbm similarity index 100% rename from ev3dev/fonts/lubI18.pbm rename to ev3dev2/fonts/lubI18.pbm diff --git a/ev3dev/fonts/lubI18.pil b/ev3dev2/fonts/lubI18.pil similarity index 100% rename from ev3dev/fonts/lubI18.pil rename to ev3dev2/fonts/lubI18.pil diff --git a/ev3dev/fonts/lubI19.pbm b/ev3dev2/fonts/lubI19.pbm similarity index 100% rename from ev3dev/fonts/lubI19.pbm rename to ev3dev2/fonts/lubI19.pbm diff --git a/ev3dev/fonts/lubI19.pil b/ev3dev2/fonts/lubI19.pil similarity index 100% rename from ev3dev/fonts/lubI19.pil rename to ev3dev2/fonts/lubI19.pil diff --git a/ev3dev/fonts/lubI24.pbm b/ev3dev2/fonts/lubI24.pbm similarity index 100% rename from ev3dev/fonts/lubI24.pbm rename to ev3dev2/fonts/lubI24.pbm diff --git a/ev3dev/fonts/lubI24.pil b/ev3dev2/fonts/lubI24.pil similarity index 100% rename from ev3dev/fonts/lubI24.pil rename to ev3dev2/fonts/lubI24.pil diff --git a/ev3dev/fonts/lubR08.pbm b/ev3dev2/fonts/lubR08.pbm similarity index 100% rename from ev3dev/fonts/lubR08.pbm rename to ev3dev2/fonts/lubR08.pbm diff --git a/ev3dev/fonts/lubR08.pil b/ev3dev2/fonts/lubR08.pil similarity index 100% rename from ev3dev/fonts/lubR08.pil rename to ev3dev2/fonts/lubR08.pil diff --git a/ev3dev/fonts/lubR10.pbm b/ev3dev2/fonts/lubR10.pbm similarity index 100% rename from ev3dev/fonts/lubR10.pbm rename to ev3dev2/fonts/lubR10.pbm diff --git a/ev3dev/fonts/lubR10.pil b/ev3dev2/fonts/lubR10.pil similarity index 100% rename from ev3dev/fonts/lubR10.pil rename to ev3dev2/fonts/lubR10.pil diff --git a/ev3dev/fonts/lubR12.pbm b/ev3dev2/fonts/lubR12.pbm similarity index 100% rename from ev3dev/fonts/lubR12.pbm rename to ev3dev2/fonts/lubR12.pbm diff --git a/ev3dev/fonts/lubR12.pil b/ev3dev2/fonts/lubR12.pil similarity index 100% rename from ev3dev/fonts/lubR12.pil rename to ev3dev2/fonts/lubR12.pil diff --git a/ev3dev/fonts/lubR14.pbm b/ev3dev2/fonts/lubR14.pbm similarity index 100% rename from ev3dev/fonts/lubR14.pbm rename to ev3dev2/fonts/lubR14.pbm diff --git a/ev3dev/fonts/lubR14.pil b/ev3dev2/fonts/lubR14.pil similarity index 100% rename from ev3dev/fonts/lubR14.pil rename to ev3dev2/fonts/lubR14.pil diff --git a/ev3dev/fonts/lubR18.pbm b/ev3dev2/fonts/lubR18.pbm similarity index 100% rename from ev3dev/fonts/lubR18.pbm rename to ev3dev2/fonts/lubR18.pbm diff --git a/ev3dev/fonts/lubR18.pil b/ev3dev2/fonts/lubR18.pil similarity index 100% rename from ev3dev/fonts/lubR18.pil rename to ev3dev2/fonts/lubR18.pil diff --git a/ev3dev/fonts/lubR19.pbm b/ev3dev2/fonts/lubR19.pbm similarity index 100% rename from ev3dev/fonts/lubR19.pbm rename to ev3dev2/fonts/lubR19.pbm diff --git a/ev3dev/fonts/lubR19.pil b/ev3dev2/fonts/lubR19.pil similarity index 100% rename from ev3dev/fonts/lubR19.pil rename to ev3dev2/fonts/lubR19.pil diff --git a/ev3dev/fonts/lubR24.pbm b/ev3dev2/fonts/lubR24.pbm similarity index 100% rename from ev3dev/fonts/lubR24.pbm rename to ev3dev2/fonts/lubR24.pbm diff --git a/ev3dev/fonts/lubR24.pil b/ev3dev2/fonts/lubR24.pil similarity index 100% rename from ev3dev/fonts/lubR24.pil rename to ev3dev2/fonts/lubR24.pil diff --git a/ev3dev/fonts/lutBS08.pbm b/ev3dev2/fonts/lutBS08.pbm similarity index 100% rename from ev3dev/fonts/lutBS08.pbm rename to ev3dev2/fonts/lutBS08.pbm diff --git a/ev3dev/fonts/lutBS08.pil b/ev3dev2/fonts/lutBS08.pil similarity index 100% rename from ev3dev/fonts/lutBS08.pil rename to ev3dev2/fonts/lutBS08.pil diff --git a/ev3dev/fonts/lutBS10.pbm b/ev3dev2/fonts/lutBS10.pbm similarity index 100% rename from ev3dev/fonts/lutBS10.pbm rename to ev3dev2/fonts/lutBS10.pbm diff --git a/ev3dev/fonts/lutBS10.pil b/ev3dev2/fonts/lutBS10.pil similarity index 100% rename from ev3dev/fonts/lutBS10.pil rename to ev3dev2/fonts/lutBS10.pil diff --git a/ev3dev/fonts/lutBS12.pbm b/ev3dev2/fonts/lutBS12.pbm similarity index 100% rename from ev3dev/fonts/lutBS12.pbm rename to ev3dev2/fonts/lutBS12.pbm diff --git a/ev3dev/fonts/lutBS12.pil b/ev3dev2/fonts/lutBS12.pil similarity index 100% rename from ev3dev/fonts/lutBS12.pil rename to ev3dev2/fonts/lutBS12.pil diff --git a/ev3dev/fonts/lutBS14.pbm b/ev3dev2/fonts/lutBS14.pbm similarity index 100% rename from ev3dev/fonts/lutBS14.pbm rename to ev3dev2/fonts/lutBS14.pbm diff --git a/ev3dev/fonts/lutBS14.pil b/ev3dev2/fonts/lutBS14.pil similarity index 100% rename from ev3dev/fonts/lutBS14.pil rename to ev3dev2/fonts/lutBS14.pil diff --git a/ev3dev/fonts/lutBS18.pbm b/ev3dev2/fonts/lutBS18.pbm similarity index 100% rename from ev3dev/fonts/lutBS18.pbm rename to ev3dev2/fonts/lutBS18.pbm diff --git a/ev3dev/fonts/lutBS18.pil b/ev3dev2/fonts/lutBS18.pil similarity index 100% rename from ev3dev/fonts/lutBS18.pil rename to ev3dev2/fonts/lutBS18.pil diff --git a/ev3dev/fonts/lutBS19.pbm b/ev3dev2/fonts/lutBS19.pbm similarity index 100% rename from ev3dev/fonts/lutBS19.pbm rename to ev3dev2/fonts/lutBS19.pbm diff --git a/ev3dev/fonts/lutBS19.pil b/ev3dev2/fonts/lutBS19.pil similarity index 100% rename from ev3dev/fonts/lutBS19.pil rename to ev3dev2/fonts/lutBS19.pil diff --git a/ev3dev/fonts/lutBS24.pbm b/ev3dev2/fonts/lutBS24.pbm similarity index 100% rename from ev3dev/fonts/lutBS24.pbm rename to ev3dev2/fonts/lutBS24.pbm diff --git a/ev3dev/fonts/lutBS24.pil b/ev3dev2/fonts/lutBS24.pil similarity index 100% rename from ev3dev/fonts/lutBS24.pil rename to ev3dev2/fonts/lutBS24.pil diff --git a/ev3dev/fonts/lutRS08.pbm b/ev3dev2/fonts/lutRS08.pbm similarity index 100% rename from ev3dev/fonts/lutRS08.pbm rename to ev3dev2/fonts/lutRS08.pbm diff --git a/ev3dev/fonts/lutRS08.pil b/ev3dev2/fonts/lutRS08.pil similarity index 100% rename from ev3dev/fonts/lutRS08.pil rename to ev3dev2/fonts/lutRS08.pil diff --git a/ev3dev/fonts/lutRS10.pbm b/ev3dev2/fonts/lutRS10.pbm similarity index 100% rename from ev3dev/fonts/lutRS10.pbm rename to ev3dev2/fonts/lutRS10.pbm diff --git a/ev3dev/fonts/lutRS10.pil b/ev3dev2/fonts/lutRS10.pil similarity index 100% rename from ev3dev/fonts/lutRS10.pil rename to ev3dev2/fonts/lutRS10.pil diff --git a/ev3dev/fonts/lutRS12.pbm b/ev3dev2/fonts/lutRS12.pbm similarity index 100% rename from ev3dev/fonts/lutRS12.pbm rename to ev3dev2/fonts/lutRS12.pbm diff --git a/ev3dev/fonts/lutRS12.pil b/ev3dev2/fonts/lutRS12.pil similarity index 100% rename from ev3dev/fonts/lutRS12.pil rename to ev3dev2/fonts/lutRS12.pil diff --git a/ev3dev/fonts/lutRS14.pbm b/ev3dev2/fonts/lutRS14.pbm similarity index 100% rename from ev3dev/fonts/lutRS14.pbm rename to ev3dev2/fonts/lutRS14.pbm diff --git a/ev3dev/fonts/lutRS14.pil b/ev3dev2/fonts/lutRS14.pil similarity index 100% rename from ev3dev/fonts/lutRS14.pil rename to ev3dev2/fonts/lutRS14.pil diff --git a/ev3dev/fonts/lutRS18.pbm b/ev3dev2/fonts/lutRS18.pbm similarity index 100% rename from ev3dev/fonts/lutRS18.pbm rename to ev3dev2/fonts/lutRS18.pbm diff --git a/ev3dev/fonts/lutRS18.pil b/ev3dev2/fonts/lutRS18.pil similarity index 100% rename from ev3dev/fonts/lutRS18.pil rename to ev3dev2/fonts/lutRS18.pil diff --git a/ev3dev/fonts/lutRS19.pbm b/ev3dev2/fonts/lutRS19.pbm similarity index 100% rename from ev3dev/fonts/lutRS19.pbm rename to ev3dev2/fonts/lutRS19.pbm diff --git a/ev3dev/fonts/lutRS19.pil b/ev3dev2/fonts/lutRS19.pil similarity index 100% rename from ev3dev/fonts/lutRS19.pil rename to ev3dev2/fonts/lutRS19.pil diff --git a/ev3dev/fonts/lutRS24.pbm b/ev3dev2/fonts/lutRS24.pbm similarity index 100% rename from ev3dev/fonts/lutRS24.pbm rename to ev3dev2/fonts/lutRS24.pbm diff --git a/ev3dev/fonts/lutRS24.pil b/ev3dev2/fonts/lutRS24.pil similarity index 100% rename from ev3dev/fonts/lutRS24.pil rename to ev3dev2/fonts/lutRS24.pil diff --git a/ev3dev/fonts/ncenB08.pbm b/ev3dev2/fonts/ncenB08.pbm similarity index 100% rename from ev3dev/fonts/ncenB08.pbm rename to ev3dev2/fonts/ncenB08.pbm diff --git a/ev3dev/fonts/ncenB08.pil b/ev3dev2/fonts/ncenB08.pil similarity index 100% rename from ev3dev/fonts/ncenB08.pil rename to ev3dev2/fonts/ncenB08.pil diff --git a/ev3dev/fonts/ncenB10.pbm b/ev3dev2/fonts/ncenB10.pbm similarity index 100% rename from ev3dev/fonts/ncenB10.pbm rename to ev3dev2/fonts/ncenB10.pbm diff --git a/ev3dev/fonts/ncenB10.pil b/ev3dev2/fonts/ncenB10.pil similarity index 100% rename from ev3dev/fonts/ncenB10.pil rename to ev3dev2/fonts/ncenB10.pil diff --git a/ev3dev/fonts/ncenB12.pbm b/ev3dev2/fonts/ncenB12.pbm similarity index 100% rename from ev3dev/fonts/ncenB12.pbm rename to ev3dev2/fonts/ncenB12.pbm diff --git a/ev3dev/fonts/ncenB12.pil b/ev3dev2/fonts/ncenB12.pil similarity index 100% rename from ev3dev/fonts/ncenB12.pil rename to ev3dev2/fonts/ncenB12.pil diff --git a/ev3dev/fonts/ncenB14.pbm b/ev3dev2/fonts/ncenB14.pbm similarity index 100% rename from ev3dev/fonts/ncenB14.pbm rename to ev3dev2/fonts/ncenB14.pbm diff --git a/ev3dev/fonts/ncenB14.pil b/ev3dev2/fonts/ncenB14.pil similarity index 100% rename from ev3dev/fonts/ncenB14.pil rename to ev3dev2/fonts/ncenB14.pil diff --git a/ev3dev/fonts/ncenB18.pbm b/ev3dev2/fonts/ncenB18.pbm similarity index 100% rename from ev3dev/fonts/ncenB18.pbm rename to ev3dev2/fonts/ncenB18.pbm diff --git a/ev3dev/fonts/ncenB18.pil b/ev3dev2/fonts/ncenB18.pil similarity index 100% rename from ev3dev/fonts/ncenB18.pil rename to ev3dev2/fonts/ncenB18.pil diff --git a/ev3dev/fonts/ncenB24.pbm b/ev3dev2/fonts/ncenB24.pbm similarity index 100% rename from ev3dev/fonts/ncenB24.pbm rename to ev3dev2/fonts/ncenB24.pbm diff --git a/ev3dev/fonts/ncenB24.pil b/ev3dev2/fonts/ncenB24.pil similarity index 100% rename from ev3dev/fonts/ncenB24.pil rename to ev3dev2/fonts/ncenB24.pil diff --git a/ev3dev/fonts/ncenBI08.pbm b/ev3dev2/fonts/ncenBI08.pbm similarity index 100% rename from ev3dev/fonts/ncenBI08.pbm rename to ev3dev2/fonts/ncenBI08.pbm diff --git a/ev3dev/fonts/ncenBI08.pil b/ev3dev2/fonts/ncenBI08.pil similarity index 100% rename from ev3dev/fonts/ncenBI08.pil rename to ev3dev2/fonts/ncenBI08.pil diff --git a/ev3dev/fonts/ncenBI10.pbm b/ev3dev2/fonts/ncenBI10.pbm similarity index 100% rename from ev3dev/fonts/ncenBI10.pbm rename to ev3dev2/fonts/ncenBI10.pbm diff --git a/ev3dev/fonts/ncenBI10.pil b/ev3dev2/fonts/ncenBI10.pil similarity index 100% rename from ev3dev/fonts/ncenBI10.pil rename to ev3dev2/fonts/ncenBI10.pil diff --git a/ev3dev/fonts/ncenBI12.pbm b/ev3dev2/fonts/ncenBI12.pbm similarity index 100% rename from ev3dev/fonts/ncenBI12.pbm rename to ev3dev2/fonts/ncenBI12.pbm diff --git a/ev3dev/fonts/ncenBI12.pil b/ev3dev2/fonts/ncenBI12.pil similarity index 100% rename from ev3dev/fonts/ncenBI12.pil rename to ev3dev2/fonts/ncenBI12.pil diff --git a/ev3dev/fonts/ncenBI14.pbm b/ev3dev2/fonts/ncenBI14.pbm similarity index 100% rename from ev3dev/fonts/ncenBI14.pbm rename to ev3dev2/fonts/ncenBI14.pbm diff --git a/ev3dev/fonts/ncenBI14.pil b/ev3dev2/fonts/ncenBI14.pil similarity index 100% rename from ev3dev/fonts/ncenBI14.pil rename to ev3dev2/fonts/ncenBI14.pil diff --git a/ev3dev/fonts/ncenBI18.pbm b/ev3dev2/fonts/ncenBI18.pbm similarity index 100% rename from ev3dev/fonts/ncenBI18.pbm rename to ev3dev2/fonts/ncenBI18.pbm diff --git a/ev3dev/fonts/ncenBI18.pil b/ev3dev2/fonts/ncenBI18.pil similarity index 100% rename from ev3dev/fonts/ncenBI18.pil rename to ev3dev2/fonts/ncenBI18.pil diff --git a/ev3dev/fonts/ncenBI24.pbm b/ev3dev2/fonts/ncenBI24.pbm similarity index 100% rename from ev3dev/fonts/ncenBI24.pbm rename to ev3dev2/fonts/ncenBI24.pbm diff --git a/ev3dev/fonts/ncenBI24.pil b/ev3dev2/fonts/ncenBI24.pil similarity index 100% rename from ev3dev/fonts/ncenBI24.pil rename to ev3dev2/fonts/ncenBI24.pil diff --git a/ev3dev/fonts/ncenI08.pbm b/ev3dev2/fonts/ncenI08.pbm similarity index 100% rename from ev3dev/fonts/ncenI08.pbm rename to ev3dev2/fonts/ncenI08.pbm diff --git a/ev3dev/fonts/ncenI08.pil b/ev3dev2/fonts/ncenI08.pil similarity index 100% rename from ev3dev/fonts/ncenI08.pil rename to ev3dev2/fonts/ncenI08.pil diff --git a/ev3dev/fonts/ncenI10.pbm b/ev3dev2/fonts/ncenI10.pbm similarity index 100% rename from ev3dev/fonts/ncenI10.pbm rename to ev3dev2/fonts/ncenI10.pbm diff --git a/ev3dev/fonts/ncenI10.pil b/ev3dev2/fonts/ncenI10.pil similarity index 100% rename from ev3dev/fonts/ncenI10.pil rename to ev3dev2/fonts/ncenI10.pil diff --git a/ev3dev/fonts/ncenI12.pbm b/ev3dev2/fonts/ncenI12.pbm similarity index 100% rename from ev3dev/fonts/ncenI12.pbm rename to ev3dev2/fonts/ncenI12.pbm diff --git a/ev3dev/fonts/ncenI12.pil b/ev3dev2/fonts/ncenI12.pil similarity index 100% rename from ev3dev/fonts/ncenI12.pil rename to ev3dev2/fonts/ncenI12.pil diff --git a/ev3dev/fonts/ncenI14.pbm b/ev3dev2/fonts/ncenI14.pbm similarity index 100% rename from ev3dev/fonts/ncenI14.pbm rename to ev3dev2/fonts/ncenI14.pbm diff --git a/ev3dev/fonts/ncenI14.pil b/ev3dev2/fonts/ncenI14.pil similarity index 100% rename from ev3dev/fonts/ncenI14.pil rename to ev3dev2/fonts/ncenI14.pil diff --git a/ev3dev/fonts/ncenI18.pbm b/ev3dev2/fonts/ncenI18.pbm similarity index 100% rename from ev3dev/fonts/ncenI18.pbm rename to ev3dev2/fonts/ncenI18.pbm diff --git a/ev3dev/fonts/ncenI18.pil b/ev3dev2/fonts/ncenI18.pil similarity index 100% rename from ev3dev/fonts/ncenI18.pil rename to ev3dev2/fonts/ncenI18.pil diff --git a/ev3dev/fonts/ncenI24.pbm b/ev3dev2/fonts/ncenI24.pbm similarity index 100% rename from ev3dev/fonts/ncenI24.pbm rename to ev3dev2/fonts/ncenI24.pbm diff --git a/ev3dev/fonts/ncenI24.pil b/ev3dev2/fonts/ncenI24.pil similarity index 100% rename from ev3dev/fonts/ncenI24.pil rename to ev3dev2/fonts/ncenI24.pil diff --git a/ev3dev/fonts/ncenR08.pbm b/ev3dev2/fonts/ncenR08.pbm similarity index 100% rename from ev3dev/fonts/ncenR08.pbm rename to ev3dev2/fonts/ncenR08.pbm diff --git a/ev3dev/fonts/ncenR08.pil b/ev3dev2/fonts/ncenR08.pil similarity index 100% rename from ev3dev/fonts/ncenR08.pil rename to ev3dev2/fonts/ncenR08.pil diff --git a/ev3dev/fonts/ncenR10.pbm b/ev3dev2/fonts/ncenR10.pbm similarity index 100% rename from ev3dev/fonts/ncenR10.pbm rename to ev3dev2/fonts/ncenR10.pbm diff --git a/ev3dev/fonts/ncenR10.pil b/ev3dev2/fonts/ncenR10.pil similarity index 100% rename from ev3dev/fonts/ncenR10.pil rename to ev3dev2/fonts/ncenR10.pil diff --git a/ev3dev/fonts/ncenR12.pbm b/ev3dev2/fonts/ncenR12.pbm similarity index 100% rename from ev3dev/fonts/ncenR12.pbm rename to ev3dev2/fonts/ncenR12.pbm diff --git a/ev3dev/fonts/ncenR12.pil b/ev3dev2/fonts/ncenR12.pil similarity index 100% rename from ev3dev/fonts/ncenR12.pil rename to ev3dev2/fonts/ncenR12.pil diff --git a/ev3dev/fonts/ncenR14.pbm b/ev3dev2/fonts/ncenR14.pbm similarity index 100% rename from ev3dev/fonts/ncenR14.pbm rename to ev3dev2/fonts/ncenR14.pbm diff --git a/ev3dev/fonts/ncenR14.pil b/ev3dev2/fonts/ncenR14.pil similarity index 100% rename from ev3dev/fonts/ncenR14.pil rename to ev3dev2/fonts/ncenR14.pil diff --git a/ev3dev/fonts/ncenR18.pbm b/ev3dev2/fonts/ncenR18.pbm similarity index 100% rename from ev3dev/fonts/ncenR18.pbm rename to ev3dev2/fonts/ncenR18.pbm diff --git a/ev3dev/fonts/ncenR18.pil b/ev3dev2/fonts/ncenR18.pil similarity index 100% rename from ev3dev/fonts/ncenR18.pil rename to ev3dev2/fonts/ncenR18.pil diff --git a/ev3dev/fonts/ncenR24.pbm b/ev3dev2/fonts/ncenR24.pbm similarity index 100% rename from ev3dev/fonts/ncenR24.pbm rename to ev3dev2/fonts/ncenR24.pbm diff --git a/ev3dev/fonts/ncenR24.pil b/ev3dev2/fonts/ncenR24.pil similarity index 100% rename from ev3dev/fonts/ncenR24.pil rename to ev3dev2/fonts/ncenR24.pil diff --git a/ev3dev/fonts/symb08.pbm b/ev3dev2/fonts/symb08.pbm similarity index 100% rename from ev3dev/fonts/symb08.pbm rename to ev3dev2/fonts/symb08.pbm diff --git a/ev3dev/fonts/symb08.pil b/ev3dev2/fonts/symb08.pil similarity index 100% rename from ev3dev/fonts/symb08.pil rename to ev3dev2/fonts/symb08.pil diff --git a/ev3dev/fonts/symb10.pbm b/ev3dev2/fonts/symb10.pbm similarity index 100% rename from ev3dev/fonts/symb10.pbm rename to ev3dev2/fonts/symb10.pbm diff --git a/ev3dev/fonts/symb10.pil b/ev3dev2/fonts/symb10.pil similarity index 100% rename from ev3dev/fonts/symb10.pil rename to ev3dev2/fonts/symb10.pil diff --git a/ev3dev/fonts/symb12.pbm b/ev3dev2/fonts/symb12.pbm similarity index 100% rename from ev3dev/fonts/symb12.pbm rename to ev3dev2/fonts/symb12.pbm diff --git a/ev3dev/fonts/symb12.pil b/ev3dev2/fonts/symb12.pil similarity index 100% rename from ev3dev/fonts/symb12.pil rename to ev3dev2/fonts/symb12.pil diff --git a/ev3dev/fonts/symb14.pbm b/ev3dev2/fonts/symb14.pbm similarity index 100% rename from ev3dev/fonts/symb14.pbm rename to ev3dev2/fonts/symb14.pbm diff --git a/ev3dev/fonts/symb14.pil b/ev3dev2/fonts/symb14.pil similarity index 100% rename from ev3dev/fonts/symb14.pil rename to ev3dev2/fonts/symb14.pil diff --git a/ev3dev/fonts/symb18.pbm b/ev3dev2/fonts/symb18.pbm similarity index 100% rename from ev3dev/fonts/symb18.pbm rename to ev3dev2/fonts/symb18.pbm diff --git a/ev3dev/fonts/symb18.pil b/ev3dev2/fonts/symb18.pil similarity index 100% rename from ev3dev/fonts/symb18.pil rename to ev3dev2/fonts/symb18.pil diff --git a/ev3dev/fonts/symb24.pbm b/ev3dev2/fonts/symb24.pbm similarity index 100% rename from ev3dev/fonts/symb24.pbm rename to ev3dev2/fonts/symb24.pbm diff --git a/ev3dev/fonts/symb24.pil b/ev3dev2/fonts/symb24.pil similarity index 100% rename from ev3dev/fonts/symb24.pil rename to ev3dev2/fonts/symb24.pil diff --git a/ev3dev/fonts/tech14.pbm b/ev3dev2/fonts/tech14.pbm similarity index 100% rename from ev3dev/fonts/tech14.pbm rename to ev3dev2/fonts/tech14.pbm diff --git a/ev3dev/fonts/tech14.pil b/ev3dev2/fonts/tech14.pil similarity index 100% rename from ev3dev/fonts/tech14.pil rename to ev3dev2/fonts/tech14.pil diff --git a/ev3dev/fonts/techB14.pbm b/ev3dev2/fonts/techB14.pbm similarity index 100% rename from ev3dev/fonts/techB14.pbm rename to ev3dev2/fonts/techB14.pbm diff --git a/ev3dev/fonts/techB14.pil b/ev3dev2/fonts/techB14.pil similarity index 100% rename from ev3dev/fonts/techB14.pil rename to ev3dev2/fonts/techB14.pil diff --git a/ev3dev/fonts/term14.pbm b/ev3dev2/fonts/term14.pbm similarity index 100% rename from ev3dev/fonts/term14.pbm rename to ev3dev2/fonts/term14.pbm diff --git a/ev3dev/fonts/term14.pil b/ev3dev2/fonts/term14.pil similarity index 100% rename from ev3dev/fonts/term14.pil rename to ev3dev2/fonts/term14.pil diff --git a/ev3dev/fonts/termB14.pbm b/ev3dev2/fonts/termB14.pbm similarity index 100% rename from ev3dev/fonts/termB14.pbm rename to ev3dev2/fonts/termB14.pbm diff --git a/ev3dev/fonts/termB14.pil b/ev3dev2/fonts/termB14.pil similarity index 100% rename from ev3dev/fonts/termB14.pil rename to ev3dev2/fonts/termB14.pil diff --git a/ev3dev/fonts/timB08.pbm b/ev3dev2/fonts/timB08.pbm similarity index 100% rename from ev3dev/fonts/timB08.pbm rename to ev3dev2/fonts/timB08.pbm diff --git a/ev3dev/fonts/timB08.pil b/ev3dev2/fonts/timB08.pil similarity index 100% rename from ev3dev/fonts/timB08.pil rename to ev3dev2/fonts/timB08.pil diff --git a/ev3dev/fonts/timB10.pbm b/ev3dev2/fonts/timB10.pbm similarity index 100% rename from ev3dev/fonts/timB10.pbm rename to ev3dev2/fonts/timB10.pbm diff --git a/ev3dev/fonts/timB10.pil b/ev3dev2/fonts/timB10.pil similarity index 100% rename from ev3dev/fonts/timB10.pil rename to ev3dev2/fonts/timB10.pil diff --git a/ev3dev/fonts/timB12.pbm b/ev3dev2/fonts/timB12.pbm similarity index 100% rename from ev3dev/fonts/timB12.pbm rename to ev3dev2/fonts/timB12.pbm diff --git a/ev3dev/fonts/timB12.pil b/ev3dev2/fonts/timB12.pil similarity index 100% rename from ev3dev/fonts/timB12.pil rename to ev3dev2/fonts/timB12.pil diff --git a/ev3dev/fonts/timB14.pbm b/ev3dev2/fonts/timB14.pbm similarity index 100% rename from ev3dev/fonts/timB14.pbm rename to ev3dev2/fonts/timB14.pbm diff --git a/ev3dev/fonts/timB14.pil b/ev3dev2/fonts/timB14.pil similarity index 100% rename from ev3dev/fonts/timB14.pil rename to ev3dev2/fonts/timB14.pil diff --git a/ev3dev/fonts/timB18.pbm b/ev3dev2/fonts/timB18.pbm similarity index 100% rename from ev3dev/fonts/timB18.pbm rename to ev3dev2/fonts/timB18.pbm diff --git a/ev3dev/fonts/timB18.pil b/ev3dev2/fonts/timB18.pil similarity index 100% rename from ev3dev/fonts/timB18.pil rename to ev3dev2/fonts/timB18.pil diff --git a/ev3dev/fonts/timB24.pbm b/ev3dev2/fonts/timB24.pbm similarity index 100% rename from ev3dev/fonts/timB24.pbm rename to ev3dev2/fonts/timB24.pbm diff --git a/ev3dev/fonts/timB24.pil b/ev3dev2/fonts/timB24.pil similarity index 100% rename from ev3dev/fonts/timB24.pil rename to ev3dev2/fonts/timB24.pil diff --git a/ev3dev/fonts/timBI08.pbm b/ev3dev2/fonts/timBI08.pbm similarity index 100% rename from ev3dev/fonts/timBI08.pbm rename to ev3dev2/fonts/timBI08.pbm diff --git a/ev3dev/fonts/timBI08.pil b/ev3dev2/fonts/timBI08.pil similarity index 100% rename from ev3dev/fonts/timBI08.pil rename to ev3dev2/fonts/timBI08.pil diff --git a/ev3dev/fonts/timBI10.pbm b/ev3dev2/fonts/timBI10.pbm similarity index 100% rename from ev3dev/fonts/timBI10.pbm rename to ev3dev2/fonts/timBI10.pbm diff --git a/ev3dev/fonts/timBI10.pil b/ev3dev2/fonts/timBI10.pil similarity index 100% rename from ev3dev/fonts/timBI10.pil rename to ev3dev2/fonts/timBI10.pil diff --git a/ev3dev/fonts/timBI12.pbm b/ev3dev2/fonts/timBI12.pbm similarity index 100% rename from ev3dev/fonts/timBI12.pbm rename to ev3dev2/fonts/timBI12.pbm diff --git a/ev3dev/fonts/timBI12.pil b/ev3dev2/fonts/timBI12.pil similarity index 100% rename from ev3dev/fonts/timBI12.pil rename to ev3dev2/fonts/timBI12.pil diff --git a/ev3dev/fonts/timBI14.pbm b/ev3dev2/fonts/timBI14.pbm similarity index 100% rename from ev3dev/fonts/timBI14.pbm rename to ev3dev2/fonts/timBI14.pbm diff --git a/ev3dev/fonts/timBI14.pil b/ev3dev2/fonts/timBI14.pil similarity index 100% rename from ev3dev/fonts/timBI14.pil rename to ev3dev2/fonts/timBI14.pil diff --git a/ev3dev/fonts/timBI18.pbm b/ev3dev2/fonts/timBI18.pbm similarity index 100% rename from ev3dev/fonts/timBI18.pbm rename to ev3dev2/fonts/timBI18.pbm diff --git a/ev3dev/fonts/timBI18.pil b/ev3dev2/fonts/timBI18.pil similarity index 100% rename from ev3dev/fonts/timBI18.pil rename to ev3dev2/fonts/timBI18.pil diff --git a/ev3dev/fonts/timBI24.pbm b/ev3dev2/fonts/timBI24.pbm similarity index 100% rename from ev3dev/fonts/timBI24.pbm rename to ev3dev2/fonts/timBI24.pbm diff --git a/ev3dev/fonts/timBI24.pil b/ev3dev2/fonts/timBI24.pil similarity index 100% rename from ev3dev/fonts/timBI24.pil rename to ev3dev2/fonts/timBI24.pil diff --git a/ev3dev/fonts/timI08.pbm b/ev3dev2/fonts/timI08.pbm similarity index 100% rename from ev3dev/fonts/timI08.pbm rename to ev3dev2/fonts/timI08.pbm diff --git a/ev3dev/fonts/timI08.pil b/ev3dev2/fonts/timI08.pil similarity index 100% rename from ev3dev/fonts/timI08.pil rename to ev3dev2/fonts/timI08.pil diff --git a/ev3dev/fonts/timI10.pbm b/ev3dev2/fonts/timI10.pbm similarity index 100% rename from ev3dev/fonts/timI10.pbm rename to ev3dev2/fonts/timI10.pbm diff --git a/ev3dev/fonts/timI10.pil b/ev3dev2/fonts/timI10.pil similarity index 100% rename from ev3dev/fonts/timI10.pil rename to ev3dev2/fonts/timI10.pil diff --git a/ev3dev/fonts/timI12.pbm b/ev3dev2/fonts/timI12.pbm similarity index 100% rename from ev3dev/fonts/timI12.pbm rename to ev3dev2/fonts/timI12.pbm diff --git a/ev3dev/fonts/timI12.pil b/ev3dev2/fonts/timI12.pil similarity index 100% rename from ev3dev/fonts/timI12.pil rename to ev3dev2/fonts/timI12.pil diff --git a/ev3dev/fonts/timI14.pbm b/ev3dev2/fonts/timI14.pbm similarity index 100% rename from ev3dev/fonts/timI14.pbm rename to ev3dev2/fonts/timI14.pbm diff --git a/ev3dev/fonts/timI14.pil b/ev3dev2/fonts/timI14.pil similarity index 100% rename from ev3dev/fonts/timI14.pil rename to ev3dev2/fonts/timI14.pil diff --git a/ev3dev/fonts/timI18.pbm b/ev3dev2/fonts/timI18.pbm similarity index 100% rename from ev3dev/fonts/timI18.pbm rename to ev3dev2/fonts/timI18.pbm diff --git a/ev3dev/fonts/timI18.pil b/ev3dev2/fonts/timI18.pil similarity index 100% rename from ev3dev/fonts/timI18.pil rename to ev3dev2/fonts/timI18.pil diff --git a/ev3dev/fonts/timI24.pbm b/ev3dev2/fonts/timI24.pbm similarity index 100% rename from ev3dev/fonts/timI24.pbm rename to ev3dev2/fonts/timI24.pbm diff --git a/ev3dev/fonts/timI24.pil b/ev3dev2/fonts/timI24.pil similarity index 100% rename from ev3dev/fonts/timI24.pil rename to ev3dev2/fonts/timI24.pil diff --git a/ev3dev/fonts/timR08.pbm b/ev3dev2/fonts/timR08.pbm similarity index 100% rename from ev3dev/fonts/timR08.pbm rename to ev3dev2/fonts/timR08.pbm diff --git a/ev3dev/fonts/timR08.pil b/ev3dev2/fonts/timR08.pil similarity index 100% rename from ev3dev/fonts/timR08.pil rename to ev3dev2/fonts/timR08.pil diff --git a/ev3dev/fonts/timR10.pbm b/ev3dev2/fonts/timR10.pbm similarity index 100% rename from ev3dev/fonts/timR10.pbm rename to ev3dev2/fonts/timR10.pbm diff --git a/ev3dev/fonts/timR10.pil b/ev3dev2/fonts/timR10.pil similarity index 100% rename from ev3dev/fonts/timR10.pil rename to ev3dev2/fonts/timR10.pil diff --git a/ev3dev/fonts/timR12.pbm b/ev3dev2/fonts/timR12.pbm similarity index 100% rename from ev3dev/fonts/timR12.pbm rename to ev3dev2/fonts/timR12.pbm diff --git a/ev3dev/fonts/timR12.pil b/ev3dev2/fonts/timR12.pil similarity index 100% rename from ev3dev/fonts/timR12.pil rename to ev3dev2/fonts/timR12.pil diff --git a/ev3dev/fonts/timR14.pbm b/ev3dev2/fonts/timR14.pbm similarity index 100% rename from ev3dev/fonts/timR14.pbm rename to ev3dev2/fonts/timR14.pbm diff --git a/ev3dev/fonts/timR14.pil b/ev3dev2/fonts/timR14.pil similarity index 100% rename from ev3dev/fonts/timR14.pil rename to ev3dev2/fonts/timR14.pil diff --git a/ev3dev/fonts/timR18.pbm b/ev3dev2/fonts/timR18.pbm similarity index 100% rename from ev3dev/fonts/timR18.pbm rename to ev3dev2/fonts/timR18.pbm diff --git a/ev3dev/fonts/timR18.pil b/ev3dev2/fonts/timR18.pil similarity index 100% rename from ev3dev/fonts/timR18.pil rename to ev3dev2/fonts/timR18.pil diff --git a/ev3dev/fonts/timR24.pbm b/ev3dev2/fonts/timR24.pbm similarity index 100% rename from ev3dev/fonts/timR24.pbm rename to ev3dev2/fonts/timR24.pbm diff --git a/ev3dev/fonts/timR24.pil b/ev3dev2/fonts/timR24.pil similarity index 100% rename from ev3dev/fonts/timR24.pil rename to ev3dev2/fonts/timR24.pil diff --git a/ev3dev/led.py b/ev3dev2/led.py similarity index 96% rename from ev3dev/led.py rename to ev3dev2/led.py index 8d9b108..341a400 100644 --- a/ev3dev/led.py +++ b/ev3dev2/led.py @@ -32,28 +32,28 @@ import stat import time from collections import OrderedDict -from ev3dev import get_current_platform, Device +from ev3dev2 import get_current_platform, Device # Import the LED settings, this is platform specific platform = get_current_platform() if platform == 'ev3': - from ev3dev._platform.ev3 import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.ev3 import LEDS, LED_GROUPS, LED_COLORS elif platform == 'evb': - from ev3dev._platform.evb import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.evb import LEDS, LED_GROUPS, LED_COLORS elif platform == 'pistorms': - from ev3dev._platform.pistorms import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.pistorms import LEDS, LED_GROUPS, LED_COLORS elif platform == 'brickpi': - from ev3dev._platform.brickpi import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.brickpi import LEDS, LED_GROUPS, LED_COLORS elif platform == 'brickpi3': - from ev3dev._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS elif platform == 'fake': - from ev3dev._platform.fake import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.fake import LEDS, LED_GROUPS, LED_COLORS else: raise Exception("Unsupported platform '%s'" % platform) diff --git a/ev3dev/motor.py b/ev3dev2/motor.py similarity index 99% rename from ev3dev/motor.py rename to ev3dev2/motor.py index 4b683d0..9a145b9 100644 --- a/ev3dev/motor.py +++ b/ev3dev2/motor.py @@ -33,7 +33,7 @@ from logging import getLogger from math import atan2, degrees as math_degrees, hypot from os.path import abspath -from ev3dev import get_current_platform, Device, list_device_names +from ev3dev2 import get_current_platform, Device, list_device_names log = getLogger(__name__) @@ -46,22 +46,22 @@ platform = get_current_platform() if platform == 'ev3': - from ev3dev._platform.ev3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.ev3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D elif platform == 'evb': - from ev3dev._platform.evb import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.evb import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D elif platform == 'pistorms': - from ev3dev._platform.pistorms import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.pistorms import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D elif platform == 'brickpi': - from ev3dev._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D elif platform == 'brickpi3': - from ev3dev._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D elif platform == 'fake': - from ev3dev._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D else: raise Exception("Unsupported platform '%s'" % platform) diff --git a/ev3dev/port.py b/ev3dev2/port.py similarity index 100% rename from ev3dev/port.py rename to ev3dev2/port.py diff --git a/ev3dev/power.py b/ev3dev2/power.py similarity index 99% rename from ev3dev/power.py rename to ev3dev2/power.py index a04bcb8..afac94d 100644 --- a/ev3dev/power.py +++ b/ev3dev2/power.py @@ -28,7 +28,7 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') -from ev3dev import Device +from ev3dev2 import Device class PowerSupply(Device): diff --git a/ev3dev/sensor/__init__.py b/ev3dev2/sensor/__init__.py similarity index 95% rename from ev3dev/sensor/__init__.py rename to ev3dev2/sensor/__init__.py index dce5a3c..c2ff6ee 100644 --- a/ev3dev/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -31,29 +31,29 @@ import numbers from os.path import abspath from struct import unpack -from ev3dev import get_current_platform, Device, list_device_names +from ev3dev2 import get_current_platform, Device, list_device_names # INPUT ports have platform specific values that we must import platform = get_current_platform() if platform == 'ev3': - from ev3dev._platform.ev3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.ev3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 elif platform == 'evb': - from ev3dev._platform.evb import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.evb import INPUT_1, INPUT_2, INPUT_3, INPUT_4 elif platform == 'pistorms': - from ev3dev._platform.pistorms import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.pistorms import INPUT_1, INPUT_2, INPUT_3, INPUT_4 elif platform == 'brickpi': - from ev3dev._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 elif platform == 'brickpi3': - from ev3dev._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 elif platform == 'fake': - from ev3dev._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 else: raise Exception("Unsupported platform '%s'" % platform) @@ -258,7 +258,7 @@ def bin_data(self, fmt=None): Example:: - >>> from ev3dev import * + >>> from ev3dev2.sensor.lego import InfraredSensor >>> ir = InfraredSensor() >>> ir.value() 28 diff --git a/ev3dev/sensor/lego.py b/ev3dev2/sensor/lego.py similarity index 99% rename from ev3dev/sensor/lego.py rename to ev3dev2/sensor/lego.py index 488ec1e..591c3d5 100644 --- a/ev3dev/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -29,8 +29,8 @@ raise SystemError('Must be using Python 3.4 or higher') import time -from ev3dev.button import ButtonBase -from ev3dev.sensor import Sensor +from ev3dev2.button import ButtonBase +from ev3dev2.sensor import Sensor class TouchSensor(Sensor): diff --git a/ev3dev/sound.py b/ev3dev2/sound.py similarity index 100% rename from ev3dev/sound.py rename to ev3dev2/sound.py diff --git a/setup.py b/setup.py index 4f76a0e..d7ab4c4 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,11 @@ license='MIT', url='https://github.com/rhempel/ev3dev-lang-python', include_package_data=True, - packages=['ev3dev', - 'ev3dev.fonts', - 'ev3dev.sensor', - 'ev3dev.control', - 'ev3dev._platform'], + packages=['ev3dev2', + 'ev3dev2.fonts', + 'ev3dev2.sensor', + 'ev3dev2.control', + 'ev3dev2._platform'], package_data={'': ['*.pil', '*.pbm']}, install_requires=['Pillow'] ) diff --git a/tests/api_tests.py b/tests/api_tests.py index c67edd7..652f596 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -9,32 +9,32 @@ from populate_arena import populate_arena from clean_arena import clean_arena -import ev3dev as ev3 -from ev3dev.sensor.lego import InfraredSensor -from ev3dev.motor import MediumMotor +import ev3dev2 +from ev3dev2.sensor.lego import InfraredSensor +from ev3dev2.motor import MediumMotor -ev3.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') +ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') class TestAPI(unittest.TestCase): def test_device(self): clean_arena() populate_arena({'medium_motor' : [0, 'outA'], 'infrared_sensor' : [0, 'in1']}) - d = ev3.Device('tacho-motor', 'motor*') + d = ev3dev2.Device('tacho-motor', 'motor*') - d = ev3.Device('tacho-motor', 'motor0') + d = ev3dev2.Device('tacho-motor', 'motor0') - d = ev3.Device('tacho-motor', 'motor*', driver_name='lego-ev3-m-motor') + d = ev3dev2.Device('tacho-motor', 'motor*', driver_name='lego-ev3-m-motor') - d = ev3.Device('tacho-motor', 'motor*', address='outA') + d = ev3dev2.Device('tacho-motor', 'motor*', address='outA') - with self.assertRaises(ev3.DeviceNotFound): - d = ev3.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') + with self.assertRaises(ev3dev2.DeviceNotFound): + d = ev3dev2.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') - d = ev3.Device('lego-sensor', 'sensor*') + d = ev3dev2.Device('lego-sensor', 'sensor*') - with self.assertRaises(ev3.DeviceNotFound): - d = ev3.Device('this-does-not-exist') + with self.assertRaises(ev3dev2.DeviceNotFound): + d = ev3dev2.Device('this-does-not-exist') def test_medium_motor(self): def dummy(self): diff --git a/utils/move_motor.py b/utils/move_motor.py index 311ee9a..4c0aee8 100755 --- a/utils/move_motor.py +++ b/utils/move_motor.py @@ -5,7 +5,7 @@ where you can"t move the motor by hand. """ -from ev3dev.motor import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, Motor +from ev3dev2.motor import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, Motor import argparse import logging import sys diff --git a/utils/stop_all_motors.py b/utils/stop_all_motors.py index d1a1338..e95a012 100755 --- a/utils/stop_all_motors.py +++ b/utils/stop_all_motors.py @@ -3,7 +3,7 @@ """ Stop all motors """ -from ev3dev.motor import list_motors +from ev3dev2.motor import list_motors for motor in list_motors(): motor.stop(stop_action='brake') From 2de6ece10bdc7bca0e7ed2efd8412672172c7069 Mon Sep 17 00:00:00 2001 From: amandaoneal Date: Tue, 30 Jan 2018 13:28:09 -0800 Subject: [PATCH 052/172] ev3dev#1026: Sound.speak does not work with double quotes (#443) * ev3dev#1026: Sound.speak does not work with double quotes * Update to use shlex * Remove shell=true --- ev3dev2/sound.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 49a7fc6..0055249 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -31,7 +31,7 @@ import os import re import shlex -from subprocess import check_output, Popen +from subprocess import check_output, Popen, PIPE def _make_scales(notes): @@ -294,20 +294,22 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA self.set_volume(volume) with open(os.devnull, 'w') as n: - cmd_line = '/usr/bin/espeak --stdout {0} "{1}" | /usr/bin/aplay -q'.format( - espeak_opts, text - ) + cmd_line = ['/usr/bin/espeak', '--stdout'] + shlex.split(espeak_opts) + [shlex.quote(text)] + aplay_cmd_line = shlex.split('/usr/bin/aplay -q') if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: - play = Popen(cmd_line, stdout=n, shell=True) + espeak = Popen(cmd_line, stdout=PIPE) + play = Popen(aplay_cmd_line, stdin=espeak.stdout, stdout=n) play.wait() elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: - return Popen(cmd_line, stdout=n, shell=True) + espeak = Popen(cmd_line, stdout=PIPE) + return Popen(aplay_cmd_line, stdin=espeak.stdout, stdout=n) elif play_type == Sound.PLAY_LOOP: while True: - play = Popen(cmd_line, stdout=n, shell=True) + espeak = Popen(cmd_line, stdout=PIPE) + play = Popen(aplay_cmd_line, stdin=espeak.stdout, stdout=n) play.wait() def _get_channel(self): From 39dd0036878476e24d4919991a8ed20c53a66477 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 31 Jan 2018 08:07:48 -0500 Subject: [PATCH 053/172] EV3-G API: ultrasonic sensor #364 (#445) --- ev3dev2/sensor/lego.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 591c3d5..781c748 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -438,14 +438,33 @@ class UltrasonicSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(UltrasonicSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-us', 'lego-nxt-us'], **kwargs) + @property + def distance_centimeters_continuous(self): + self.mode = self.MODE_US_DIST_CM + return self.value(0) * self._scale('US_DIST_CM') + + @property + def distance_centimeters_ping(self): + self.mode = self.MODE_US_SI_CM + return self.value(0) * self._scale('US_DIST_CM') + @property def distance_centimeters(self): """ Measurement of the distance detected by the sensor, in centimeters. """ - self.mode = self.MODE_US_DIST_CM - return self.value(0) * self._scale('US_DIST_CM') + return self.distance_centimeters_continuous + + @property + def distance_inches_continuous(self): + self.mode = self.MODE_US_DIST_IN + return self.value(0) * self._scale('US_DIST_IN') + + @property + def distance_inches_ping(self): + self.mode = self.MODE_US_SI_IN + return self.value(0) * self._scale('US_DIST_IN') @property def distance_inches(self): @@ -453,8 +472,7 @@ def distance_inches(self): Measurement of the distance detected by the sensor, in inches. """ - self.mode = self.MODE_US_DIST_IN - return self.value(0) * self._scale('US_DIST_IN') + return self.distance_inches_continuous @property def other_sensor_present(self): @@ -463,7 +481,7 @@ def other_sensor_present(self): be heard nearby. """ self.mode = self.MODE_US_LISTEN - return self.value(0) + return bool(self.value(0)) class GyroSensor(Sensor): From 1801911bf73e2cf6aa36bd06595de7c3656e466b Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 31 Jan 2018 09:57:17 -0500 Subject: [PATCH 054/172] "address" argument on LEDs #422 (#446) * "address" argument on LEDs #422 * Update git_version.py for ev3dev2 directory * Improve Led and Leds __str__ --- ev3dev2/led.py | 15 +++++++++------ git_version.py | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ev3dev2/led.py b/ev3dev2/led.py index 341a400..83a1c8c 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -78,12 +78,9 @@ class Led(Device): 'desc', ] - def __init__(self, address=None, + def __init__(self, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, - desc='LED', **kwargs): - - if address is not None: - kwargs['address'] = address + desc=None, **kwargs): super(Led, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) self._max_brightness = None @@ -95,7 +92,10 @@ def __init__(self, address=None, self.desc = desc def __str__(self): - return self.desc + if self.desc: + return self.desc + else: + return Device.__str__(self) @property def max_brightness(self): @@ -282,6 +282,9 @@ def __init__(self): for led_name in value: self.led_groups[key].append(self.leds[led_name]) + def __str__(self): + return self.__class__.__name__ + def set_color(self, group, color, pct=1): """ Sets brigthness of leds in the given group to the values specified in diff --git a/git_version.py b/git_version.py index 54d9b85..63703c0 100644 --- a/git_version.py +++ b/git_version.py @@ -71,8 +71,8 @@ def git_version(abbrev=4): if version != release_version: write_release_version(version) - # Update the ev3dev/version.py - with open('{}/ev3dev/version.py'.format(os.path.dirname(__file__)), 'w') as f: + # Update the ev3dev2/version.py + with open('{}/ev3dev2/version.py'.format(os.path.dirname(__file__)), 'w') as f: f.write("__version__ = '{}'".format(version)) # Finally, return the current version. From b8026b552e0df7f23c7bd52f3085671e74d9e32d Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 31 Jan 2018 21:20:21 -0500 Subject: [PATCH 055/172] one line import #420 (#447) * one line import #420 Example: import ev3dev2.auto as ev3 ts = ev3.TouchSensor(ev3.INPUT_1) --- ev3dev2/auto.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ev3dev2/auto.py diff --git a/ev3dev2/auto.py b/ev3dev2/auto.py new file mode 100644 index 0000000..b13a4df --- /dev/null +++ b/ev3dev2/auto.py @@ -0,0 +1,47 @@ +from ev3dev2 import * + +platform = get_current_platform() + +if platform == 'ev3': + from ev3dev2._platform.ev3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.ev3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.ev3 import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'evb': + from ev3dev2._platform.evb import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.evb import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.evb import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'pistorms': + from ev3dev2._platform.pistorms import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.pistorms import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.pistorms import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'brickpi': + from ev3dev2._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.brickpi import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'brickpi3': + from ev3dev2._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS + +elif platform == 'fake': + from ev3dev2._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.fake import LEDS, LED_GROUPS, LED_COLORS + +else: + raise Exception("Unsupported platform '%s'" % platform) + +from ev3dev2.button import * +from ev3dev2.display import * +from ev3dev2.fonts import * +from ev3dev2.led import * +from ev3dev2.motor import * +from ev3dev2.port import * +from ev3dev2.power import * +from ev3dev2.sensor import * +from ev3dev2.sensor.lego import * +from ev3dev2.sound import * From c968ab1b22156e259c2b49cca0613abe965b7e26 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Mon, 30 Apr 2018 10:24:46 +0300 Subject: [PATCH 056/172] Replace pkg_resources with __file__/glob combination (#458) --- ev3dev2/fonts/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/ev3dev2/fonts/__init__.py b/ev3dev2/fonts/__init__.py index abfe504..2ec97cd 100644 --- a/ev3dev2/fonts/__init__.py +++ b/ev3dev2/fonts/__init__.py @@ -1,16 +1,14 @@ -import pkg_resources import os.path +from glob import glob from PIL import ImageFont def available(): """ Returns list of available font names. """ - names = [] - for f in pkg_resources.resource_listdir('ev3dev.fonts', ''): - name, ext = os.path.splitext(os.path.basename(f)) - if ext == '.pil': - names.append(name) + font_dir = os.path.dirname(__file__) + names = [os.path.basename(os.path.splitext(f)[0]) + for f in glob(os.path.join(font_dir, '*.pil'))] return sorted(names) def load(name): @@ -20,8 +18,9 @@ def load(name): class. """ try: - pil_file = pkg_resources.resource_filename('ev3dev.fonts', '{}.pil'.format(name)) - pbm_file = pkg_resources.resource_filename('ev3dev.fonts', '{}.pbm'.format(name)) + font_dir = os.path.dirname(__file__) + pil_file = os.path.join(font_dir, '{}.pil'.format(name)) + pbm_file = os.path.join(font_dir, '{}.pbm'.format(name)) return ImageFont.load(pil_file) except FileNotFoundError: raise Exception('Failed to load font "{}". '.format(name) + From 70b7b449824c996196ed5b092213b3306e7a69f2 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Sun, 20 May 2018 21:41:45 +0300 Subject: [PATCH 057/172] Update display for 4.14 kernel (#462) * Update display for 4.14 kernel Fixes #455 --- ev3dev2/display.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 8412e19..3af5630 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -31,8 +31,9 @@ import os import mmap import ctypes -import ev3dev.fonts as fonts from PIL import Image, ImageDraw +from . import fonts +from . import get_current_platform from struct import pack import fcntl @@ -195,8 +196,19 @@ class Display(FbMem): def __init__(self, desc='Display'): FbMem.__init__(self) + self.platform = get_current_platform() + + if self.var_info.bits_per_pixel == 1: + im_type = "1" + elif self.var_info.bits_per_pixel == 16: + im_type = "RGB" + elif self.platform == "ev3" and self.var_info.bits_per_pixel == 32: + im_type = "L" + else: + raise Exception("Not supported") + self._img = Image.new( - self.var_info.bits_per_pixel == 1 and "1" or "RGB", + im_type, (self.fix_info.line_length * 8 // self.var_info.bits_per_pixel, self.yres), "white") @@ -266,6 +278,16 @@ def _img_to_rgb565_bytes(self): pixels = [self._color565(r, g, b) for (r, g, b) in self._img.getdata()] return pack('H' * len(pixels), *pixels) + def _color_xrgb(self, v): + """Convert red, green, blue components to a 32-bit XRGB value. Components + should be values 0 to 255. + """ + return ((v << 16) | (v << 8) | v) + + def _img_to_xrgb_bytes(self): + pixels = [self._color_xrgb(v) for v in self._img.getdata()] + return pack('I' * len(pixels), *pixels) + def update(self): """ Applies pending changes to the screen. @@ -276,6 +298,8 @@ def update(self): self.mmap[:len(b)] = b elif self.var_info.bits_per_pixel == 16: self.mmap[:] = self._img_to_rgb565_bytes() + elif self.platform == "ev3" and self.var_info.bits_per_pixel == 32: + self.mmap[:] = self._img_to_xrgb_bytes() else: raise Exception("Not supported") From ac390119941ddd2187a78c7d938f744904c200d5 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Wed, 23 May 2018 11:34:44 -0500 Subject: [PATCH 058/172] Update brickpi names for ev3dev-2.0.0 kernel (#460) fixes #459 --- ev3dev2/_platform/brickpi.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ev3dev2/_platform/brickpi.py b/ev3dev2/_platform/brickpi.py index c5f79e2..583442b 100644 --- a/ev3dev2/_platform/brickpi.py +++ b/ev3dev2/_platform/brickpi.py @@ -28,22 +28,22 @@ from collections import OrderedDict -OUTPUT_A = 'ttyAMA0:MA' -OUTPUT_B = 'ttyAMA0:MB' -OUTPUT_C = 'ttyAMA0:MC' -OUTPUT_D = 'ttyAMA0:MD' +OUTPUT_A = 'serial0-0:MA' +OUTPUT_B = 'serial0-0:MB' +OUTPUT_C = 'serial0-0:MC' +OUTPUT_D = 'serial0-0:MD' -INPUT_1 = 'ttyAMA0:S1' -INPUT_2 = 'ttyAMA0:S2' -INPUT_3 = 'ttyAMA0:S3' -INPUT_4 = 'ttyAMA0:S4' +INPUT_1 = 'serial0-0:S1' +INPUT_2 = 'serial0-0:S2' +INPUT_3 = 'serial0-0:S3' +INPUT_4 = 'serial0-0:S4' BUTTONS_FILENAME = None EVDEV_DEVICE_NAME = None LEDS = OrderedDict() -LEDS['blue_led1'] = 'brickpi:led1:blue:ev3dev' -LEDS['blue_led2'] = 'brickpi:led2:blue:ev3dev' +LEDS['blue_led1'] = 'led1:blue:brick-status' +LEDS['blue_led2'] = 'led2:blue:brick-status' LED_GROUPS = OrderedDict() LED_GROUPS['LED1'] = ('blue_led1',) From 9c5e4a9c55ff4a1d758df356ef519011ff42be7f Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 11 Jul 2018 20:37:53 -0700 Subject: [PATCH 059/172] Add test which writes to a property --- tests/api_tests.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/api_tests.py b/tests/api_tests.py index 652f596..ee61d55 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -86,5 +86,15 @@ def test_infrared_sensor(self): self.assertEqual(s.address, 'in1') self.assertEqual(s.value(0), 16) + def test_medium_motor_write(self): + clean_arena() + populate_arena({'medium_motor' : [0, 'outA']}) + + m = MediumMotor() + + self.assertEqual(m.speed_sp, 0) + m.speed_sp = 500 + self.assertEqual(m.speed_sp, 500) + if __name__ == "__main__": unittest.main() From 862c4feb23d6592d6791748914c02d7e16ab9bbf Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Thu, 12 Jul 2018 19:39:36 -0700 Subject: [PATCH 060/172] Manually mark device files as group-writable on Travis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 33f7310..24c37cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ install: - pip install Pillow - pip install evdev script: +- chmod -R g+rw ./tests/fake-sys/devices/**/* - ./tests/api_tests.py deploy: provider: pypi From 6af0b14145bc5ebc6ec516bc2127efc5ba191971 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Mon, 16 Jul 2018 18:54:46 -0700 Subject: [PATCH 061/172] Initial set of updates to documentation for Stretch (#411) * Initial set of updates to documentation for Stretch * Remove "indices and tables" section of home page It seemed pretty useless to me given that we have the "tree" immediately above it. * Update navbar link * Improvements to motor docs * Add a new SpeedInteger for percentage and improve related docs * More motor docs improvements * README updates * Continue iterating on motor docs and readme * Update sensor docs page * Add new section to Stretch upgrade guide on new features * Attempt to fix docs build * Add WIP section * Clean-up and new drive example * Add docs build validation to Travis * Update README.rst --- .gitignore | 2 + .travis.yml | 8 +- README.rst | 173 ++++++++++--------- docs/conf.py | 16 +- docs/index.rst | 14 +- docs/motors.rst | 88 +++++++++- docs/other.rst | 28 ++-- docs/requirements.txt | 1 + docs/rpyc.rst | 2 +- docs/sensors.rst | 61 ++++--- docs/spec.rst | 12 +- docs/upgrading-to-stretch.md | 55 ++++++ ev3dev2/display.py | 8 +- ev3dev2/motor.py | 312 +++++++++++++++++++++++------------ ev3dev2/sound.py | 4 +- 15 files changed, 511 insertions(+), 273 deletions(-) create mode 100644 docs/requirements.txt create mode 100644 docs/upgrading-to-stretch.md diff --git a/.gitignore b/.gitignore index 3d0d13b..4920911 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ dist RELEASE-VERSION ev3dev2/version.py build +_build/ +docs/_build/ .idea diff --git a/.travis.yml b/.travis.yml index 24c37cb..3130bb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,17 @@ language: python python: - 3.4 +cache: pip sudo: false +git: + depth: 100 install: -- pip install Pillow -- pip install evdev +- pip install -q Pillow evdev Sphinx sphinx_bootstrap_theme recommonmark evdev +- pip install -q -r ./docs/requirements.txt script: - chmod -R g+rw ./tests/fake-sys/devices/**/* - ./tests/api_tests.py +- sphinx-build -nW -b html ./docs/ ./docs/_build/html deploy: provider: pypi user: Denis.Demidov diff --git a/README.rst b/README.rst index 30b4ace..dcd2cd6 100644 --- a/README.rst +++ b/README.rst @@ -25,122 +25,126 @@ your EV3 or other ev3dev device as explained in the `ev3dev Getting Started guid Make sure that you have a kernel version that includes ``-10-ev3dev`` or higher (a larger number). You can check the kernel version by selecting "About" in Brickman and scrolling down to the "kernel version". If you don't have a compatible version, -`upgrade the kernel before continuing`_. Also note that if the ev3dev image you downloaded -was created before September 2016, you probably don't have the most recent version of this -library installed: see `Upgrading this Library`_ to upgrade it. +`upgrade the kernel before continuing`_. -Once you have booted ev3dev and `connected to your EV3 (or Raspberry Pi / BeagleBone) -via SSH`_, you should be ready to start using ev3dev with Python: this library -is included out-of-the-box. If you want to go through some basic usage examples, -check out the `Usage Examples`_ section to try out motors, sensors and LEDs. -Then look at `Writing Python Programs for Ev3dev`_ to see how you can save -your Python code to a file. +Usage +----- -Make sure that you look at the `User Resources`_ section as well for links -to documentation and larger examples. +To start out, you'll need a way to work with Python. We recommend the +`ev3dev Visual Studio Code extension`_. If you're interested in using that, +check out our `Python + VSCode introduction tutorial`_ and then come back +once you have that set up. -Usage Examples --------------- +Otherwise, you can can work with files `via an SSH connection`_ with an editor +such as `nano`_, use the Python interactive REPL (type ``python3``), or roll +your own solution. If you don't know how to do that, you are probably better off +choosing the recommended option above. -To run these minimal examples, run the Python3 interpreter from -the terminal using the ``python3`` command: +The template for a Python script +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: bash +Every Python program should have a few basic parts. Use this template +to get started: - $ python3 - Python 3.4.2 (default, Oct 8 2014, 14:47:30) - [GCC 4.9.1] on linux - Type "help", "copyright", "credits" or "license" for more information. - >>> +.. code-block:: python -The ``>>>`` characters are the default prompt for Python. In the examples -below, we have removed these characters so it's easier to cut and -paste the code into your session. + #!/usr/bin/env python3 + from ev3dev2.motor import LargeMotor, OUTPUT_A, SpeedPercent + from ev3dev2.sensor.lego import TouchSensor + from ev3dev2.led import Leds -Required: Import the library -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # TODO: Add code here -If you are using an EV3 brick (which is the case for most users), add the -following to the top of your file: +The first line should be included in every Python program you write +for ev3dev. It allows you to run this program from Brickman, the graphical +menu that you see on the device screen. The other lines are import statements +which give you access to the library functionality. You will need to add +additional classes to the import list if you want to use other types of devices +or additional utilities. -.. code-block:: python +You should use the ``.py`` extension for your file, e.g. ``my-file.py``. - import ev3dev.ev3 as ev3 +Important: Make your script executable (non-Visual Studio Code only) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you are using a BrickPi, use this line: +To be able to run your Python file, **your program must be executable**. If +you are using the `ev3dev Visual Studio Code extension`_, you can skip this step, +as it will be automatically performed when you download your code to the brick. -.. code-block:: python +**To mark a program as executable from the command line (often an SSH session), +run** ``chmod +x my-file.py``. - import ev3dev.brickpi as ev3 +You can now run ``my-file.py`` via the Brickman File Browser or you can run it +from the command line by preceding the file name with ``./``: ``./my-file.py`` Controlling the LEDs with a touch sensor ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This code will turn the left LED red whenever the touch sensor is pressed, and -back to green when it's released. Plug a touch sensor into any sensor port and -then paste in this code - you'll need to hit ``Enter`` after pasting to complete -the loop and start the program. Hit ``Ctrl-C`` to exit the loop. +back to green when it's released. Plug a touch sensor into any sensor port before +trying this out. .. code-block:: python - ts = ev3.TouchSensor() + ts = TouchSensor() + leds = Leds() + while True: - ev3.Leds.set_color(ev3.Leds.LEFT, (ev3.Leds.GREEN, ev3.Leds.RED)[ts.value()]) + if ts.is_pressed: + leds.set_color(leds.led_groups.LEFT, leds.led_colors.GREEN) + else: + leds.set_color(leds.led_groups.LEFT, leds.led_colors.RED) Running a motor ~~~~~~~~~~~~~~~ -Now plug a motor into the ``A`` port and paste this code into the Python prompt. -This little program will run the motor at 500 ticks per second, which on the EV3 -"large" motors equates to around 1.4 rotations per second, for three seconds -(3000 milliseconds). +This will run a LEGO Large Motor at 75% of maximum speed for 5 rotations. .. code-block:: python - m = ev3.LargeMotor('outA') - m.run_timed(time_sp=3000, speed_sp=500) + m = LargeMotor(OUTPUT_A) + m.on_for_rotations(SpeedPercent(75), 5) -The units for ``speed_sp`` that you see above are in "tacho ticks" per second. -On the large EV3 motor, these equate to one tick per degree, so this is 500 -degress per second. +You can also run a motor for a number of degrees, an amount of time, or simply +start it and let it run until you tell it to stop. Additionally, other units are +also available. See the following pages for more information: +- http://python-ev3dev.readthedocs.io/en/stretch/motors.html#ev3dev.motor.Motor.on_for_degrees +- http://python-ev3dev.readthedocs.io/en/stretch/motors.html#units +Driving with two motors +~~~~~~~~~~~~~~~~~~~~~~~ -Using text-to-speech -~~~~~~~~~~~~~~~~~~~~ - -If you want to make your robot speak, you can use the `Sound.speak` method: +The simplest drive control style is with the `MoveTank` class: .. code-block:: python - ev3.Sound.speak('Welcome to the E V 3 dev project!').wait() + drive = MoveTank(OUTPUT_A, OUTPUT_B) -**To quit the Python REPL, just type** ``exit()`` **or press** ``Ctrl-D`` **.** + # drive in a turn for 5 rotations of the outer motor + # the first two parameters are percentages; they can also be unit classes. + drive.on_for_rotations(50, 75, 10) + + # drive in a different turn for 3 rotations of the outer motor + drive.on_for_rotations(60, 30, 3) -Make sure to check out the `User Resources`_ section for more detailed -information on these features and many others. +There are also `MoveSteering` and `MoveJoystick` classes which provide different +styles of control. Take a look at the corresponding documentation for more detail. -Writing Python Programs for Ev3dev ----------------------------------- +Using text-to-speech +~~~~~~~~~~~~~~~~~~~~ -Every Python program should have a few basic parts. Use this template -to get started: +If you want to make your robot speak, you can use the ``Sound.speak`` method: .. code-block:: python - #!/usr/bin/env python3 - from ev3dev.ev3 import * + from ev3dev2.sound import Sound - # TODO: Add code here + sound = Sound() + sound.speak('Welcome to the E V 3 dev project!').wait() -The first two lines should be included in every Python program you write -for ev3dev. The first allows you to run this program from Brickman, while the -second imports this library. - -When saving Python files, it is best to use the ``.py`` extension, e.g. ``my-file.py``. -To be able to run your Python code, **your program must be executable**. To mark a -program as executable run ``chmod +x my-file.py``. You can then run ``my-file.py`` -via the Brickman File Browser or you can run it from the command line via ``$ ./my-file.py`` +Make sure to check out the `User Resources`_ section for more detailed +information on these features and many others. User Resources -------------- @@ -150,6 +154,11 @@ Library Documentation You can always go there to get information on how you can use this library's functionality. +Demo Code + There are several demo programs that you can run to get acquainted with + this language binding. The programs are available at + https://github.com/ev3dev/ev3dev-lang-python-demo + ev3python.com One of our community members, @ndward, has put together a great website with detailed guides on using this library which are targeted at beginners. @@ -171,11 +180,6 @@ Support what you are trying to do and what you have tried. The issue template is in place to guide you through this process. -Demo Code - There are several demo programs that you can run to get acquainted with - this language binding. The programs are available at - https://github.com/ev3dev/ev3dev-lang-python-demo - Upgrading this Library ---------------------- @@ -196,16 +200,6 @@ Python Package Index libraries that others have written, including the `latest version of this package`_. -The ev3dev Binding Specification - Like all of the language bindings for ev3dev_ supported hardware, the - Python binding follows the minimal API that must be provided per - `this document`_. - -The ev3dev-lang Project on GitHub - The `source repository for the generic API`_ and the scripts to automatically - generate the binding. Only developers of the ev3dev-lang-python_ binding - would normally need to access this information. - Python 2.x and Python 3.x Compatibility --------------------------------------- @@ -222,17 +216,13 @@ Python 3 and this is the only version that will be supported from here forward. .. _ev3dev-getting-started: http://www.ev3dev.org/docs/getting-started/ .. _upgrade the kernel before continuing: http://www.ev3dev.org/docs/tutorials/upgrading-ev3dev/ .. _detailed instructions for USB connections: ev3dev-usb-internet_ -.. _connected to your EV3 (or Raspberry Pi / BeagleBone) via SSH: http://www.ev3dev.org/docs/tutorials/connecting-to-ev3dev-with-ssh/ +.. _via an SSH connection: http://www.ev3dev.org/docs/tutorials/connecting-to-ev3dev-with-ssh/ .. _ev3dev-usb-internet: http://www.ev3dev.org/docs/tutorials/connecting-to-the-internet-via-usb/ .. _our Read the Docs page: http://python-ev3dev.readthedocs.org/en/stable/ -.. _source repository for the generic API: ev3dev-lang_ .. _ev3python.com: http://ev3python.com/ .. _FAQ: http://python-ev3dev.readthedocs.io/en/stable/faq.html -.. _ev3dev-lang: https://github.com/ev3dev/ev3dev-lang .. _ev3dev-lang-python: https://github.com/rhempel/ev3dev-lang-python .. _our Issues tracker: https://github.com/rhempel/ev3dev-lang-python/issues -.. _this document: wrapper-specification_ -.. _wrapper-specification: https://github.com/ev3dev/ev3dev-lang/blob/develop/wrapper-specification.md .. _EXPLOR3R: demo-robot_ .. _demo-robot: http://robotsquare.com/2015/10/06/explor3r-building-instructions/ .. _demo programs: demo-code_ @@ -246,3 +236,6 @@ Python 3 and this is the only version that will be supported from here forward. .. _pypi: https://pypi.python.org/pypi .. _latest version of this package: pypi-python-ev3dev_ .. _pypi-python-ev3dev: https://pypi.python.org/pypi/python-ev3dev +.. _ev3dev Visual Studio Code extension: https://github.com/ev3dev/vscode-ev3dev-browser +.. _Python + VSCode introduction tutorial: https://github.com/ev3dev/vscode-hello-python +.. _nano: http://www.ev3dev.org/docs/tutorials/nano-cheat-sheet/ diff --git a/docs/conf.py b/docs/conf.py index 014f8ff..8f3d526 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ import sphinx_bootstrap_theme from recommonmark.parser import CommonMarkParser +from recommonmark.transform import AutoStructify # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -135,7 +136,7 @@ html_theme_options = { 'bootswatch_theme': 'yeti', 'navbar_links' : [ - ("GitHub", "https://github.com/rhempel/ev3dev-lang-python", True) + ("GitHub", "https://github.com/ev3dev/ev3dev-lang-python", True) ] } @@ -311,3 +312,16 @@ # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False + +autodoc_member_order = 'bysource' + +nitpick_ignore = [ + ('py:class', 'ev3dev2.display.FbMem'), + ('py:class', 'ev3dev2.button.ButtonBase') +] + +def setup(app): + app.add_config_value('recommonmark_config', { + 'enable_eval_rst': True, + }, True) + app.add_transform(AutoStructify) \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index eabecfd..5cc8837 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,3 @@ -.. python-ev3dev documentation master file, created by - sphinx-quickstart on Sat Oct 31 20:38:27 2015. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - .. include:: ../README.rst .. rubric:: Contents @@ -10,14 +5,7 @@ .. toctree:: :maxdepth: 3 + upgrading-to-stretch spec rpyc faq - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/motors.rst b/docs/motors.rst index 927cee1..2ec99ad 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -1,36 +1,110 @@ Motor classes ============= -.. currentmodule:: ev3dev.motor +.. currentmodule:: ev3dev2.motor -Tacho motor ------------ +.. contents:: :local: + +.. _motor-unit-classes: + +Units +----- + +Most methods which run motors with accept a ``speed_pct`` argument. While this can be provided as an integer which will be interpreted as a percentage of max speed, you can also specify an instance of any of the following classes, each of which represents a different unit system: + +.. autoclass:: SpeedInteger +.. autoclass:: SpeedPercent +.. autoclass:: SpeedRPS +.. autoclass:: SpeedRPM +.. autoclass:: SpeedDPS +.. autoclass:: SpeedDPM + +Example: + +.. code:: python + + from ev3dev2.motor import SpeedRPM + + # later... + + # rotates the motor at 200 RPM (rotations-per-minute) for five seconds. + my_motor.on_for_seconds(SpeedRPM(200), 5) + + + +Common motors +------------- + +Tacho Motor (``Motor``) +~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: Motor :members: Large EV3 Motor ---------------- +~~~~~~~~~~~~~~~ .. autoclass:: LargeMotor :members: :show-inheritance: Medium EV3 Motor ----------------- +~~~~~~~~~~~~~~~~ .. autoclass:: MediumMotor :members: :show-inheritance: +Additional motors +----------------- + DC Motor --------- +~~~~~~~~ .. autoclass:: DcMotor :members: Servo Motor ------------ +~~~~~~~~~~~ .. autoclass:: ServoMotor :members: + +Actuonix L12 50 Linear Servo Motor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ActuonixL1250Motor + :members: + +Actuonix L12 100 Linear Servo Motor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ActuonixL12100Motor + :members: + +Multiple-motor groups +--------------------- + +Motor Set +~~~~~~~~~ + +.. autoclass:: MotorSet + :members: + +Move Tank +~~~~~~~~~ + +.. autoclass:: MoveTank + :members: + +Move Steering +~~~~~~~~~~~~~ + +.. autoclass:: MoveSteering + :members: + +Move Joystick +~~~~~~~~~~~~~ + +.. autoclass:: MoveJoystick + :members: diff --git a/docs/other.rst b/docs/other.rst index 3c68071..a65a81c 100644 --- a/docs/other.rst +++ b/docs/other.rst @@ -4,7 +4,7 @@ Other classes Button ------ -.. autoclass:: ev3dev.button.Button +.. autoclass:: ev3dev2.button.Button :members: :inherited-members: @@ -24,10 +24,10 @@ Button Leds ---- -.. autoclass:: ev3dev.led.Led +.. autoclass:: ev3dev2.led.Led :members: -.. autoclass:: ev3dev.led.Leds +.. autoclass:: ev3dev2.led.Leds :members: .. rubric:: EV3 platform @@ -59,39 +59,39 @@ Leds Power Supply ------------ -.. autoclass:: ev3dev.power.PowerSupply +.. autoclass:: ev3dev2.power.PowerSupply :members: Sound ----- -.. autoclass:: ev3dev.sound.Sound +.. autoclass:: ev3dev2.sound.Sound :members: -Screen ------- +Display +------- -.. autoclass:: ev3dev.display.Display +.. autoclass:: ev3dev2.display.Display :members: :show-inheritance: Bitmap fonts ^^^^^^^^^^^^ -The :py:class:`Display` class allows to write text on the LCD using python +The :py:class:`ev3dev2.display.Display` class allows to write text on the LCD using python imaging library (PIL) interface (see description of the ``text()`` method `here `_). -The ``ev3dev.fonts`` module contains bitmap fonts in PIL format that should +The ``ev3dev2.fonts`` module contains bitmap fonts in PIL format that should look good on a tiny EV3 screen: .. code-block:: py - import ev3dev.fonts as fonts + import ev3dev2.fonts as fonts display.draw.text((10,10), 'Hello World!', font=fonts.load('luBS14')) -.. autofunction:: ev3dev.fonts.available +.. autofunction:: ev3dev2.fonts.available -.. autofunction:: ev3dev.fonts.load +.. autofunction:: ev3dev2.fonts.load The following image lists all available fonts. The grid lines correspond to EV3 screen size: @@ -101,5 +101,5 @@ to EV3 screen size: Lego Port --------- -.. autoclass:: ev3dev.port.LegoPort +.. autoclass:: ev3dev2.port.LegoPort :members: diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..341fd90 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +docutils==0.14 \ No newline at end of file diff --git a/docs/rpyc.rst b/docs/rpyc.rst index 3984f78..58ff0c2 100644 --- a/docs/rpyc.rst +++ b/docs/rpyc.rst @@ -58,7 +58,7 @@ use RPyC with ev3dev: import rpyc conn = rpyc.classic.connect('ev3dev') # host name or IP address of the EV3 - ev3 = conn.modules['ev3dev.ev3'] # import ev3dev.ev3 remotely + ev3 = conn.modules['ev3dev2.ev3'] # import ev3dev2.ev3 remotely m = ev3.LargeMotor('outA') m.run_timed(time_sp=1000, speed_sp=600) diff --git a/docs/sensors.rst b/docs/sensors.rst index de1d421..368ee3e 100644 --- a/docs/sensors.rst +++ b/docs/sensors.rst @@ -1,86 +1,95 @@ Sensor classes ============== -Sensor ------- +.. contents:: :local: -This is the base class all the other sensor classes are derived from. +Dedicated sensor classes +------------------------ -.. autoclass:: ev3dev.sensor.Sensor - :members: - -Special sensor classes ----------------------- - -The classes derive from :py:class:`Sensor` and provide helper functions -specific to the corresponding sensor type. Each of the functions makes -sure the sensor is in the required mode and then returns the specified value. +These classes derive from :py:class:`ev3dev2.sensor.Sensor` and provide helper functions +specific to the corresponding sensor type. Each provides sensible property +accessors for the main functionality of the sensor. .. +.. currentmodule:: ev3dev2.sensor.lego + Touch Sensor -######################## +############ -.. autoclass:: ev3dev.sensor.lego.TouchSensor +.. autoclass:: TouchSensor :members: :show-inheritance: Color Sensor -######################## +############ -.. autoclass:: ev3dev.sensor.lego.ColorSensor +.. autoclass:: ColorSensor :members: :show-inheritance: Ultrasonic Sensor -######################## +################# -.. autoclass:: ev3dev.sensor.lego.UltrasonicSensor +.. autoclass:: UltrasonicSensor :members: :show-inheritance: Gyro Sensor -######################## +########### -.. autoclass:: ev3dev.sensor.lego.GyroSensor +.. autoclass:: GyroSensor :members: :show-inheritance: Infrared Sensor -######################## +############### -.. autoclass:: ev3dev.sensor.lego.InfraredSensor +.. autoclass:: InfraredSensor :members: :show-inheritance: Sound Sensor -######################## +############ -.. autoclass:: ev3dev.sensor.lego.SoundSensor +.. autoclass:: SoundSensor :members: :show-inheritance: Light Sensor -######################## +############ -.. autoclass:: ev3dev.sensor.lego.LightSensor +.. autoclass:: LightSensor :members: :show-inheritance: +Base "Sensor" +------------- + +This is the base class all the other sensor classes are derived from. You +generally want to use one of the other classes instead, but if your sensor +doesn't have a dedicated class, this is will let you interface with it as a +generic device. + +.. currentmodule:: ev3dev2.sensor + +.. autoclass:: Sensor + :members: + .. diff --git a/docs/spec.rst b/docs/spec.rst index 5519ac2..773acea 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -1,17 +1,17 @@ API reference ============= -Each class in ev3dev module inherits from the base :py:class:`Device` class. +Each class in ev3dev module inherits from the base :py:class:`ev3dev2.Device` class. -.. autoclass:: ev3dev.Device +.. autoclass:: ev3dev2.Device -.. autofunction:: ev3dev.list_device_names +.. autofunction:: ev3dev2.list_device_names -.. autofunction:: ev3dev.list_devices +.. autofunction:: ev3dev2.list_devices -.. autofunction:: ev3dev.motor.list_motors +.. autofunction:: ev3dev2.motor.list_motors -.. autofunction:: ev3dev.sensor.list_sensors +.. autofunction:: ev3dev2.sensor.list_sensors .. rubric:: Contents: diff --git a/docs/upgrading-to-stretch.md b/docs/upgrading-to-stretch.md new file mode 100644 index 0000000..ef2366e --- /dev/null +++ b/docs/upgrading-to-stretch.md @@ -0,0 +1,55 @@ +# Upgrading from ev3dev-jessie (library v1) to ev3dev-stretch (library v2) + +With ev3dev-stretch, we have introduced some breaking changes that you must be aware of to get older scripts running with new features. + +**Scripts which worked on ev3dev-jessie are still supported and will continue to work as-is on Stretch.** However, if you want to use any of the new features we have introduced, you will need to switch to using version 2 of the python-ev3dev library. You can switch to version 2 by updating your import statements. + +## Updating import statements + +Previously, we recommended using one of the following as your `import` declaration: + +```python +import ev3dev.ev3 as ev3 +import ev3dev.brickpi as ev3 +import ev3dev.auto as ev3 +``` + +We have re-arranged the library to provide more control over what gets imported. For all platforms, you will now import from individual modules for things like sensors and motors, like this: + +```python +from ev3dev2.motor import Motor, OUTPUT_A +from ev3dev2.sensor.lego import TouchSensor, UltrasonicSensor +``` + +The platform (EV3, BrickPi, etc.) will now be automatically determined. + +You can omit import statements for modules you don't need, and add any additional ones that you do require. With this style of import, members are globally available by their name, so you would now refer to the Motor class as simply `Motor` rather than `ev3.Motor`. + +## Remove references to `connected` attribute + +In version 1 of the library, instantiating a device such as a motor or sensor would always succeed without an error. To see if the device connected successfully you would have to check the `connected` attribute. With the new version of the module, the constructor of device classes will throw an `ev3dev2.DeviceNotConnected` exception. You will need to remove any uses of the `connected` attribute. + +## `Screen` class has been renamed to `Display` + +To match the name used by LEGO's "EV3-G" graphical programming tools, we have renamed the `Screen` module to `Display`. + +## Reorganization of `RemoteControl`, `BeaconSeeker` and `InfraredSensor` + +The `RemoteControl` and `BeaconSeeker` classes have been removed; you will now use `InfraredSensor` for all purposes. + +Additionally, we have renamed many of the properties on the `InfraredSensor` class to make the meaning more obvious. Check out [the `InfraredSensor` documentation](docs/sensors#infrared-sensor) for more info. + +## Re-designed `Sound` class + +The names and interfaces of some of the `Sound` class methods have changed. Check out [the `Sound` class docs](docs/other#sound) for details. + +# Once you've adapted to breaking changes, check out the cool new features! + +```eval_rst +- New classes are available for coordinating motors: :py:class:`ev3dev2.motor.MotorSet`, :py:class:`ev3dev2.motor.MoveTank`, :py:class:`ev3dev2.motor.MoveSteering`, and :py:class:`ev3dev2.motor.MoveJoystick`. +- Classes representing a variety of motor speed units are available and accepted by many of the motor interfaces: see :ref:`motor-unit-classes`. +- Friendlier interfaces for operating motors and sensors: check out :py:meth:`ev3dev2.motor.Motor.on_for_rotations` and the other ``on_for_*`` methods on motors. +- Easier interactivity via buttons: each button now has ``wait_for_pressed``, ``wait_for_released`` and ``wait_for_bump`` +- Improved :py:class:`ev3dev2.sound.Sound` and :py:class:`ev3dev2.display.Display` interfaces +- New color conversion methods in :py:class:`ev3dev2.sensor.lego.ColorSensor` +``` \ No newline at end of file diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 3af5630..aebed23 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -35,8 +35,14 @@ from . import fonts from . import get_current_platform from struct import pack -import fcntl +try: + # This is a linux-specific module. + # It is required by the Display class, but failure to import it may be + # safely ignored if one just needs to run API tests on Windows. + import fcntl +except ImportError: + print("WARNING: Failed to import fcntl. Display class will be unusable!") class FbMem(object): diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 9a145b9..dbbbb2d 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -68,12 +68,31 @@ class SpeedInteger(int): + """ + A base class for other unit types. Don't use this directly; instead, see + :class:`SpeedPercent`, :class:`SpeedRPS`, :class:`SpeedRPM`, + :class:`SpeedDPS`, and :class:`SpeedDPM`. + """ pass +class SpeedPercent(SpeedInteger): + """ + Speed as a percentage of the motor's maximum rated speed. + """ + + def __str__(self): + return ("%d%%" % self) + + def get_speed_pct(self, motor): + """ + Return the motor speed percentage represented by this SpeedPercent + """ + return self + class SpeedRPS(SpeedInteger): """ - Speed in rotations-per-second + Speed in rotations-per-second. """ def __str__(self): @@ -89,7 +108,7 @@ def get_speed_pct(self, motor): class SpeedRPM(SpeedInteger): """ - Speed in rotations-per-minute + Speed in rotations-per-minute. """ def __str__(self): @@ -105,7 +124,7 @@ def get_speed_pct(self, motor): class SpeedDPS(SpeedInteger): """ - Speed in degrees-per-second + Speed in degrees-per-second. """ def __str__(self): @@ -121,7 +140,7 @@ def get_speed_pct(self, motor): class SpeedDPM(SpeedInteger): """ - Speed in degrees-per-minute + Speed in degrees-per-minute. """ def __str__(self): @@ -848,10 +867,10 @@ def _set_brake(self, brake): def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): """ - Rotate the motor at 'speed_pct' for 'rotations' + Rotate the motor at ``speed_pct`` for ``rotations`` - 'speed_pct' can be an integer or a SpeedInteger object which will be - converted to an actual speed percentage in _speed_pct() + ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + object, enabling use of other units. """ speed_pct = self._speed_pct(speed_pct) @@ -871,10 +890,10 @@ def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): """ - Rotate the motor at 'speed_pct' for 'degrees' + Rotate the motor at ``speed_pct`` for ``degrees`` - 'speed_pct' can be an integer or a SpeedInteger object which will be - converted to an actual speed percentage in _speed_pct() + ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + object, enabling use of other units. """ speed_pct = self._speed_pct(speed_pct) @@ -894,10 +913,10 @@ def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): def on_to_position(self, speed_pct, position, brake=True, block=True): """ - Rotate the motor at 'speed_pct' to 'position' + Rotate the motor at ``speed_pct`` to ``position`` - 'speed_pct' can be an integer or a SpeedInteger object which will be - converted to an actual speed percentage in _speed_pct() + ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + object, enabling use of other units. """ speed_pct = self._speed_pct(speed_pct) @@ -917,10 +936,10 @@ def on_to_position(self, speed_pct, position, brake=True, block=True): def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): """ - Rotate the motor at 'speed_pct' for 'seconds' + Rotate the motor at ``speed_pct`` for ``seconds`` - 'speed_pct' can be an integer or a SpeedInteger object which will be - converted to an actual speed percentage in _speed_pct() + ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + object, enabling use of other units. """ speed_pct = self._speed_pct(speed_pct) @@ -940,13 +959,13 @@ def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): def on(self, speed_pct, brake=True, block=False): """ - Rotate the motor at 'speed_pct' for forever + Rotate the motor at ``speed_pct`` for forever - 'speed_pct' can be an integer or a SpeedInteger object which will be - converted to an actual speed percentage in _speed_pct() + ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + object, enabling use of other units. Note that `block` is False by default, this is different from the - other `on_for_XYZ` methods + other `on_for_XYZ` methods. """ speed_pct = self._speed_pct(speed_pct) @@ -998,7 +1017,9 @@ def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): class LargeMotor(Motor): """ - EV3/NXT large servo motor + EV3/NXT large servo motor. + + Same as :class:`Motor`, except it will only successfully initialize if it finds a "large" motor. """ SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME @@ -1013,7 +1034,9 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam class MediumMotor(Motor): """ - EV3 medium servo motor + EV3 medium servo motor. + + Same as :class:`Motor`, except it will only successfully initialize if it finds a "medium" motor. """ SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME @@ -1028,7 +1051,9 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam class ActuonixL1250Motor(Motor): """ - Actuonix L12 50 linear servo motor + Actuonix L12 50 linear servo motor. + + Same as :class:`Motor`, except it will only successfully initialize if it finds an Actuonix L12 50 linear servo motor """ SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME @@ -1043,7 +1068,9 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam class ActuonixL12100Motor(Motor): """ - Actuonix L12 100 linear servo motor + Actuonix L12 100 linear servo motor. + + Same as :class:`Motor`, except it will only successfully initialize if it finds an Actuonix L12 100 linear servo motor """ SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME @@ -1661,6 +1688,17 @@ def wait_while(self, s, timeout=None, motors=None): class MoveTank(MotorSet): + """ + Controls a pair of motors simultaneously, via individual speed setpoints for each motor. + + Example: + + .. code:: python + + drive = MoveTank(OUTPUT_A, OUTPUT_B) + # drive in a turn for 10 rotations of the outer motor + drive.on_for_rotations(50, 75, 10) + """ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=LargeMotor): motor_specs = { @@ -1689,12 +1727,20 @@ def _validate_speed_pct(self, left_speed_pct, right_speed_pct): def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=True, block=True): """ - Rotate the motor at 'left_speed & right_speed' for 'rotations' + Rotate the motors at 'left_speed & right_speed' for 'rotations'. + + If the left speed is not equal to the right speed (i.e., the robot will + turn), the motor on the outside of the turn will rotate for the full + ``rotations`` while the motor on the inside will have its requested + distance calculated according to the expected turn. """ self._validate_speed_pct(left_speed_pct, right_speed_pct) left_speed = int((left_speed_pct * self.max_speed) / 100) right_speed = int((right_speed_pct * self.max_speed) / 100) + # proof of the following distance calculation: consider the circle formed by each wheel's path + # v_l = d_l/t, v_r = d_r/t + # therefore, t = d_l/v_l = d_r/v_r if left_speed > right_speed: left_rotations = rotations right_rotations = abs(float(right_speed / left_speed)) * rotations @@ -1719,7 +1765,12 @@ def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=Tru def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, block=True): """ - Rotate the motor at 'left_speed & right_speed' for 'degrees' + Rotate the motors at 'left_speed & right_speed' for 'degrees'. + + If the left speed is not equal to the right speed (i.e., the robot will + turn), the motor on the outside of the turn will rotate for the full + ``degrees`` while the motor on the inside will have its requested + distance calculated according to the expected turn. """ self._validate_speed_pct(left_speed_pct, right_speed_pct) left_speed = int((left_speed_pct * self.max_speed) / 100) @@ -1749,7 +1800,7 @@ def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, b def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, block=True): """ - Rotate the motor at 'left_speed & right_speed' for 'seconds' + Rotate the motors at 'left_speed & right_speed' for 'seconds' """ self._validate_speed_pct(left_speed_pct, right_speed_pct) @@ -1770,7 +1821,7 @@ def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, b def on(self, left_speed_pct, right_speed_pct): """ - Rotate the motor at 'left_speed & right_speed' for forever + Start rotating the motors according to ``left_speed_pct`` and ``right_speed_pct`` forever. """ self._validate_speed_pct(left_speed_pct, right_speed_pct) self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) @@ -1781,6 +1832,10 @@ def on(self, left_speed_pct, right_speed_pct): self.right_motor.run_forever() def off(self, brake=True): + """ + Stop both motors immediately. Configure both to brake if ``brake`` is + set. + """ self.left_motor._set_brake(brake) self.right_motor._set_brake(brake) self.left_motor.stop() @@ -1788,6 +1843,53 @@ def off(self, brake=True): class MoveSteering(MoveTank): + """ + Controls a pair of motors simultaneously, via a single "steering" value. + + steering [-100, 100]: + * -100 means turn left as fast as possible, + * 0 means drive in a straight line, and + * 100 means turn right as fast as possible. + + Example: + + .. code:: python + + drive = MoveSteering(OUTPUT_A, OUTPUT_B) + # drive in a turn for 10 rotations of the outer motor + drive.on_for_rotations(-20, 75, 10) + """ + def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): + """ + Rotate the motors according to the provided ``steering``. + + The distance each motor will travel follows the rules of :meth:`MoveTank.on_for_rotations`. + """ + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake, block) + + def on_for_degrees(self, steering, speed_pct, degrees, brake=True, block=True): + """ + Rotate the motors according to the provided ``steering``. + + The distance each motor will travel follows the rules of :meth:`MoveTank.on_for_degrees`. + """ + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake, block) + + def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): + """ + Rotate the motors according to the provided ``steering`` for ``seconds``. + """ + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake, block) + + def on(self, steering, speed_pct): + """ + Start rotating the motors according to the provided ``steering`` forever. + """ + (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) + MoveTank.on(self, left_speed_pct, right_speed_pct) def get_speed_steering(self, steering, speed_pct): """ @@ -1828,52 +1930,84 @@ def get_speed_steering(self, steering, speed_pct): return (left_speed_pct, right_speed_pct) - def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake, block) - def on_for_degrees(self, steering, speed_pct, degrees, brake=True, block=True): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake, block) +class MoveJoystick(MoveTank): + """ + Used to control a pair of motors via a single joystick vector. + """ - def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake, block) + def on(self, x, y, max_speed, radius=100.0): + """ + Convert x,y joystick coordinates to left/right motor speed percentages + and move the motors + """ - def on(self, steering, speed_pct): - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on(self, left_speed_pct, right_speed_pct) + # If joystick is in the middle stop the tank + if not x and not y: + MoveTank.off() + return + vector_length = hypot(x, y) + angle = math_degrees(atan2(y, x)) + + if angle < 0: + angle += 360 + + # Should not happen but can happen (just by a hair) due to floating point math + if vector_length > radius: + vector_length = radius + + (init_left_speed_percentage, init_right_speed_percentage) = MoveJoystick.angle_to_speed_percentage(angle) + + # scale the speed percentages based on vector_length vs. radius + left_speed_percentage = (init_left_speed_percentage * vector_length) / radius + right_speed_percentage = (init_right_speed_percentage * vector_length) / radius + + log.debug(""" + x, y : %s, %s + radius : %s + angle : %s + vector length : %s + init left_speed_percentage : %s + init right_speed_percentage : %s + final left_speed_percentage : %s + final right_speed_percentage : %s + """ % (x, y, radius, angle, vector_length, + init_left_speed_percentage, init_right_speed_percentage, + left_speed_percentage, right_speed_percentage)) + + MoveTank.on(self, left_speed_percentage, right_speed_percentage) -class MoveJoystick(MoveTank): - """ - Used to control a pair of motors via a joystick - """ - def angle_to_speed_percentage(self, angle): - """ - (1, 1) - . . . . . . . - . | . - . | . - (0, 1) . | . (1, 0) - . | . - . | . - . | . - . | . - . | . - . | x-axis . - (-1, 1) .---------------------------------------. (1, -1) - . | . - . | . - . | . - . | y-axis . - . | . - (0, -1) . | . (-1, 0) - . | . - . | . - . . . . . . . - (-1, -1) + @staticmethod + def angle_to_speed_percentage(angle): + """ + The following graphic illustrates the **motor power outputs** for the + left and right motors based on where the joystick is pointing, of the + form ``(left power, right power)``:: + + (1, 1) + . . . . . . . + . | . + . | . + (0, 1) . | . (1, 0) + . | . + . | . + . | . + . | . + . | . + . | x-axis . + (-1, 1) .---------------------------------------. (1, -1) + . | . + . | . + . | . + . | y-axis . + . | . + (0, -1) . | . (-1, 0) + . | . + . | . + . . . . . . . + (-1, -1) The joystick is a circle within a circle where the (x, y) coordinates @@ -1997,45 +2131,3 @@ def angle_to_speed_percentage(self, angle): raise Exception('You created a circle with more than 360 degrees (%s)...that is quite the trick' % angle) return (left_speed_percentage * 100, right_speed_percentage * 100) - - def on(self, x, y, max_speed, radius=100.0): - """ - Convert x,y joystick coordinates to left/right motor speed percentages - and move the motors - """ - - # If joystick is in the middle stop the tank - if not x and not y: - MoveTank.off() - return - - vector_length = hypot(x, y) - angle = math_degrees(atan2(y, x)) - - if angle < 0: - angle += 360 - - # Should not happen but can happen (just by a hair) due to floating point math - if vector_length > radius: - vector_length = radius - - (init_left_speed_percentage, init_right_speed_percentage) = self.angle_to_speed_percentage(angle) - - # scale the speed percentages based on vector_length vs. radius - left_speed_percentage = (init_left_speed_percentage * vector_length) / radius - right_speed_percentage = (init_right_speed_percentage * vector_length) / radius - - log.debug(""" - x, y : %s, %s - radius : %s - angle : %s - vector length : %s - init left_speed_percentage : %s - init right_speed_percentage : %s - final left_speed_percentage : %s - final right_speed_percentage : %s - """ % (x, y, radius, angle, vector_length, - init_left_speed_percentage, init_right_speed_percentage, - left_speed_percentage, right_speed_percentage)) - - MoveTank.on(self, left_speed_percentage, right_speed_percentage) diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 0055249..e2280b5 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -374,8 +374,8 @@ def play_song(self, song, tempo=120, delay=0.05): It supports symbolic notes (e.g. ``A4``, ``D#3``, ``Gb5``) and durations (e.g. ``q``, ``h``). - For an exhaustive list of accepted note symbols and values, have a look at the :py:attr:`_NOTE_FREQUENCIES` - and :py:attr:`_NOTE_VALUES` private dictionaries in the source code. + For an exhaustive list of accepted note symbols and values, have a look at the ``_NOTE_FREQUENCIES`` + and ``_NOTE_VALUES`` private dictionaries in the source code. The value can be suffixed by modifiers: From 35b2c94dc13793d2b113c40a05e40861a1b153f3 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 17 Jul 2018 13:24:28 -0500 Subject: [PATCH 062/172] debian: update control file for ev3dev-lang-python 2.x (#482) This changes the name of the debian package so that the 2.x version can be installed at the same time as the 1.x version. Also update git: to https: --- debian/control | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/debian/control b/debian/control index b2a7c16..e2b098d 100644 --- a/debian/control +++ b/debian/control @@ -1,13 +1,13 @@ -Source: python-ev3dev +Source: python-ev3dev2 Maintainer: Ralph Hempel Section: python Priority: optional -Standards-Version: 3.9.5 +Standards-Version: 3.9.8 Build-Depends: python3-setuptools (>= 0.6b3), python3-all (>= 3.4), debhelper (>= 9), dh-python, python3-pillow -VCS-Git: git://github.com/rhempel/ev3dev-lang-python.git -VCS-Browser: https://github.com/rhempel/ev3dev-lang-python +VCS-Git: https://github.com/ev3dev/ev3dev-lang-python.git +VCS-Browser: https://github.com/ev3dev/ev3dev-lang-python -Package: python3-ev3dev +Package: python3-ev3dev2 Architecture: all Depends: ${misc:Depends}, ${python3:Depends} Description: Python language bindings for ev3dev From 9b55eb39559490cfbdada7e820b74ef9e36e3422 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 18 Jul 2018 21:35:30 -0700 Subject: [PATCH 063/172] Fix PyPi deploy config on Travis --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3130bb6..1087f8a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,4 +19,5 @@ deploy: secure: W13pXc2pp2A9gBUDz2QW/K3OfDT/cn/iapkW6NMdkwRQ+CL7MWXsY2qayfxs6QPfR5W7838pmMXogLOie70n8Hov5/XIrou+dEz/xh6If2VFG41KUz/tP9cy1yUhDYeADn5I+mr+8Yrh59OkHkaeZH1EFndbsxY4pyXV0DT+FNVg4eLc8sE5ZCfa/itVdsqp8M4xrMe8NO/ldnFEIHFymyEMTZHR5qoJD+Uk+AGMR4cSeSuOrZixnSLpQViZDFHpwej6F1LrreLltbT9ChjSEmdPAr1Jp1ReQfuD+vBzUMkhVfZyEo+fY1x8FZVPrTAEtbJGDhvTAsrV1KTgPqanMyyrCIG+OWfCSYYPyg7MYbAJVfB+P0BRp1cm2D5oFrpAZE129oVtATOykQarFzzRFhH4Tyc04WriyY/greEAe58MqYoJAZXUIe/JLf9+GLdjBKD07+q7QMZvyEEdsDCPYkqKQeuwrWZ/JlpWO5rmy12L23aYzvJqhcKo9LZQOY8LkmFmxuxt7k5eb/3iZ1trhj/lwoLLSu8l29B8cK3dax38URie0x9bMMhQRxaO59JQuhGuu0sNHiuFQHs6wLs/V8ff1IZIlRSlzztqIP3KW514TEdsFIuF0Gpn5wvagAXFbSnyxAUUTy81eQjY/ExTiUHKfU3zjluaAYGt9zjF0Bc= on: tags: true - repo: rhempel/ev3dev-lang-python + condition: $TRAVIS_TAG != ev3dev-stretch/* + repo: ev3dev/ev3dev-lang-python From 8b3f4f21117c2a581a7ab20b07a8c00fbecd8bd4 Mon Sep 17 00:00:00 2001 From: Denis Demidov Date: Thu, 19 Jul 2018 10:26:41 +0300 Subject: [PATCH 064/172] Update pypi credentials for travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 1087f8a..4235441 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ deploy: provider: pypi user: Denis.Demidov password: - secure: W13pXc2pp2A9gBUDz2QW/K3OfDT/cn/iapkW6NMdkwRQ+CL7MWXsY2qayfxs6QPfR5W7838pmMXogLOie70n8Hov5/XIrou+dEz/xh6If2VFG41KUz/tP9cy1yUhDYeADn5I+mr+8Yrh59OkHkaeZH1EFndbsxY4pyXV0DT+FNVg4eLc8sE5ZCfa/itVdsqp8M4xrMe8NO/ldnFEIHFymyEMTZHR5qoJD+Uk+AGMR4cSeSuOrZixnSLpQViZDFHpwej6F1LrreLltbT9ChjSEmdPAr1Jp1ReQfuD+vBzUMkhVfZyEo+fY1x8FZVPrTAEtbJGDhvTAsrV1KTgPqanMyyrCIG+OWfCSYYPyg7MYbAJVfB+P0BRp1cm2D5oFrpAZE129oVtATOykQarFzzRFhH4Tyc04WriyY/greEAe58MqYoJAZXUIe/JLf9+GLdjBKD07+q7QMZvyEEdsDCPYkqKQeuwrWZ/JlpWO5rmy12L23aYzvJqhcKo9LZQOY8LkmFmxuxt7k5eb/3iZ1trhj/lwoLLSu8l29B8cK3dax38URie0x9bMMhQRxaO59JQuhGuu0sNHiuFQHs6wLs/V8ff1IZIlRSlzztqIP3KW514TEdsFIuF0Gpn5wvagAXFbSnyxAUUTy81eQjY/ExTiUHKfU3zjluaAYGt9zjF0Bc= + secure: cTDN4WMJEjxZ0zwFwkLPpOIOSG/1JlHbQsHzd/tV9LfjBMR0nJR5HrmyiIO1TE5Wo6AFSFd7S3JmKdEnFO1ByvN/EFtsKGRUR9L6Yst4KfFi4nHwdZ7vgPTV0nNdvgC1YM/3bx8W8PcjFSm/k8awx7aicwXj09yDA8ENTf5XedaX22Z+9OKhb1mKola1cqEoc0GwaYzd8UX0Ruwh9/6RRbvTt7zn8BCZc9vIVqNd6mZgBWY9zAU40ZZsjYORbiZmDNCygEI+RViZ51M58WYkPPhoivzcG9em8DMRS7SC3CjWGapiOaXUHa3Fhnn+IQ+Q8Xv9YU5+qmj65MWQy3SSnMnvuxmrLf4aLoOlSJUMhDarjni4tdBOTX5PdOkdmhskyQt1DqDrw0+WhPItYfGe5zQfQwqW+YOpGbOipAeU+844arPo5jvZG/IOBX2qVUwdSxo8Y/98ocjqoZOq8b5xkWtJef0Kh1RCkp1bR2XELQVe76qeWqQxWz3OPqq+wK3xeNvj5kMQmytl3dCEB//D6UcES7Qr8YxD+LWoaIf32JIj/4LaCXXuxMVH+PJ68Oc72/ox0qLmXK0qhbea2QvaqXyGDrO+a2X6VbMdH32D2xHzH4Mg75xLnXnSaFvGovhYl1zEVcYUDioxrXZEuDmymGf9nH2mivJ24Fon6u+C3QQ= on: tags: true condition: $TRAVIS_TAG != ev3dev-stretch/* From 875b31ea924bc7227de3ba9866b3da8bdb06020b Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 19 Jul 2018 14:03:31 -0500 Subject: [PATCH 065/172] Update setup.py (#487) This updates setup.py with a new package name (`python-ev3dev2`), adds 2.x to the description, change the author/email to a shared team name and fixes the GitHub URL. --- setup.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index d7ab4c4..a8d650d 100644 --- a/setup.py +++ b/setup.py @@ -3,13 +3,13 @@ setup( - name='python-ev3dev', + name='python-ev3dev2', version=git_version(), - description='Python language bindings for ev3dev', - author='Ralph Hempel', - author_email='rhempel@hempeldesigngroup.com', + description='v2.x Python language bindings for ev3dev', + author='ev3dev Python team', + author_email='python-team@ev3dev.org', license='MIT', - url='https://github.com/rhempel/ev3dev-lang-python', + url='https://github.com/ev3dev/ev3dev-lang-python', include_package_data=True, packages=['ev3dev2', 'ev3dev2.fonts', @@ -19,4 +19,3 @@ package_data={'': ['*.pil', '*.pbm']}, install_requires=['Pillow'] ) - From 3a0d5aba7e7e35837edba4f050951f3ad6e074de Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 19 Jul 2018 14:04:16 -0500 Subject: [PATCH 066/172] debian: update Maintainer field with team email (#488) --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index e2b098d..d4ea249 100644 --- a/debian/control +++ b/debian/control @@ -1,5 +1,5 @@ Source: python-ev3dev2 -Maintainer: Ralph Hempel +Maintainer: ev3dev Python team Section: python Priority: optional Standards-Version: 3.9.8 From 10776f6f60157773825f6aa62175d42da447deef Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 27 Jul 2018 22:26:19 -0700 Subject: [PATCH 067/172] Update README badges --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index dcd2cd6..fdc16d5 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,10 @@ Python language bindings for ev3dev =================================== -.. image:: https://travis-ci.org/ev3dev/ev3dev-lang-python.svg?branch=master +.. image:: https://travis-ci.org/ev3dev/ev3dev-lang-python.svg?branch=ev3dev-stretch :target: https://travis-ci.org/ev3dev/ev3dev-lang-python -.. image:: https://readthedocs.org/projects/python-ev3dev/badge/?version=stable - :target: http://python-ev3dev.readthedocs.org/en/stable/?badge=stable +.. image:: https://readthedocs.org/projects/python-ev3dev/badge/?version=ev3dev-stretch + :target: http://python-ev3dev.readthedocs.org/en/ev3dev-stretch/?badge=ev3dev-stretch :alt: Documentation Status .. image:: https://badges.gitter.im/ev3dev/chat.svg :target: https://gitter.im/ev3dev/chat From b25637f0720db8153e0fd9f612cb5d102796f6d8 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 27 Jul 2018 23:32:32 -0700 Subject: [PATCH 068/172] Improve clarity of motor docs (#489) --- README.rst | 23 +++++++++++++---------- docs/motors.rst | 2 +- ev3dev2/motor.py | 38 +++++++++++++++++++++++++++++--------- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/README.rst b/README.rst index fdc16d5..918da02 100644 --- a/README.rst +++ b/README.rst @@ -95,8 +95,8 @@ trying this out. else: leds.set_color(leds.led_groups.LEFT, leds.led_colors.RED) -Running a motor -~~~~~~~~~~~~~~~ +Running a single motor +~~~~~~~~~~~~~~~~~~~~~~ This will run a LEGO Large Motor at 75% of maximum speed for 5 rotations. @@ -109,8 +109,8 @@ You can also run a motor for a number of degrees, an amount of time, or simply start it and let it run until you tell it to stop. Additionally, other units are also available. See the following pages for more information: -- http://python-ev3dev.readthedocs.io/en/stretch/motors.html#ev3dev.motor.Motor.on_for_degrees -- http://python-ev3dev.readthedocs.io/en/stretch/motors.html#units +- http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#ev3dev.motor.Motor.on_for_degrees +- http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#units Driving with two motors ~~~~~~~~~~~~~~~~~~~~~~~ @@ -119,17 +119,20 @@ The simplest drive control style is with the `MoveTank` class: .. code-block:: python - drive = MoveTank(OUTPUT_A, OUTPUT_B) + tank_drive = MoveTank(OUTPUT_A, OUTPUT_B) # drive in a turn for 5 rotations of the outer motor - # the first two parameters are percentages; they can also be unit classes. - drive.on_for_rotations(50, 75, 10) + # the first two parameters can be unit classes or percentages. + tank_drive.on_for_rotations(SpeedPercent(50), SpeedPercent(75), 10) - # drive in a different turn for 3 rotations of the outer motor - drive.on_for_rotations(60, 30, 3) + # drive in a different turn for 3 seconds + tank_drive.on_for_seconds(SpeedPercent(60), SpeedPercent(30), 3) There are also `MoveSteering` and `MoveJoystick` classes which provide different -styles of control. Take a look at the corresponding documentation for more detail. +styles of control. See the following pages for more information: + +- http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#multiple-motor-groups +- http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#units Using text-to-speech ~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/motors.rst b/docs/motors.rst index 2ec99ad..d7fc6f0 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -10,7 +10,7 @@ Motor classes Units ----- -Most methods which run motors with accept a ``speed_pct`` argument. While this can be provided as an integer which will be interpreted as a percentage of max speed, you can also specify an instance of any of the following classes, each of which represents a different unit system: +Most methods which run motors with accept a ``speed`` or ``speed_pct`` argument. While this can be provided as an integer which will be interpreted as a percentage of max speed, you can also specify an instance of any of the following classes, each of which represents a different unit system: .. autoclass:: SpeedInteger .. autoclass:: SpeedPercent diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index dbbbb2d..adbc6f7 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1695,9 +1695,9 @@ class MoveTank(MotorSet): .. code:: python - drive = MoveTank(OUTPUT_A, OUTPUT_B) + tank_drive = MoveTank(OUTPUT_A, OUTPUT_B) # drive in a turn for 10 rotations of the outer motor - drive.on_for_rotations(50, 75, 10) + tank_drive.on_for_rotations(50, 75, 10) """ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=LargeMotor): @@ -1847,17 +1847,19 @@ class MoveSteering(MoveTank): Controls a pair of motors simultaneously, via a single "steering" value. steering [-100, 100]: - * -100 means turn left as fast as possible, + * -100 means turn left on the spot (right motor at 100% forward, left motor at 100% backward), * 0 means drive in a straight line, and - * 100 means turn right as fast as possible. + * 100 means turn right on the spot (left motor at 100% forward, right motor at 100% backward). + "steering" can be any number between -100 and 100. + Example: .. code:: python - drive = MoveSteering(OUTPUT_A, OUTPUT_B) + steering_drive = MoveSteering(OUTPUT_A, OUTPUT_B) # drive in a turn for 10 rotations of the outer motor - drive.on_for_rotations(-20, 75, 10) + steering_drive.on_for_rotations(-20, 75, 10) """ def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): """ @@ -1899,9 +1901,9 @@ def get_speed_steering(self, steering, speed_pct): afterwards to make the motors move. steering [-100, 100]: - * -100 means turn left as fast as possible, + * -100 means turn left on the spot (right motor at 100% forward, left motor at 100% backward), * 0 means drive in a straight line, and - * 100 means turn right as fast as possible. + * 100 means turn right on the spot (left motor at 100% forward, right motor at 100% backward). speed_pct: The speed that should be applied to the outmost motor (the one @@ -1939,7 +1941,25 @@ class MoveJoystick(MoveTank): def on(self, x, y, max_speed, radius=100.0): """ Convert x,y joystick coordinates to left/right motor speed percentages - and move the motors + and move the motors. + + This will use a classic "arcade drive" algorithm: a full-forward joystick + goes straight forward and likewise for full-backward. Pushing the joystick + all the way to one side will make it turn on the spot in that direction. + Positions in the middle will control how fast the vehicle moves and how + sharply it turns. + + "x", "y": + The X and Y coordinates of the joystick's position, with + (0,0) representing the center position. X is horizontal and Y is vertical. + + max_speed (default 100%): + A percentage or other SpeedInteger, controlling the maximum motor speed. + + radius (default 100): + The radius of the joystick, controlling the range of the input (x, y) values. + e.g. if "x" and "y" can be between -1 and 1, radius should be set to "1". + """ # If joystick is in the middle stop the tank From ecfbc29dd87ab7fb569976004e90f9260d939a62 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 27 Jul 2018 23:43:01 -0700 Subject: [PATCH 069/172] Port fixes from 1.2.0 tag to stretch release branch; update EV3 port names; update debian metadata (#483) - Port fixes from 1.2.0 tag to stretch release branch - Update EV3 port names - Update debian metadata and add release script --- debian/changelog | 122 +--------------------------------- debian/gbp.conf | 3 + debian/release.sh | 21 ++++++ ev3dev2/_platform/brickpi3.py | 21 ++++++ ev3dev2/_platform/ev3.py | 18 ++--- ev3dev2/_platform/pistorms.py | 37 ++++++----- 6 files changed, 77 insertions(+), 145 deletions(-) create mode 100644 debian/gbp.conf create mode 100755 debian/release.sh diff --git a/debian/changelog b/debian/changelog index 6f80429..c9081f7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,121 +1,5 @@ -python-ev3dev (1.0.0) stable; urgency=medium +python-ev3dev2 (2.0.0) stable; urgency=medium - [Denis Demidov] - * Add constants for color values to color sensor class - * Update documentation on motors - * Add getters to check state of motors - * Implement BeaconSeeker class - * Fix scaling of floating-point values from sensors + Initial release for ev3dev-stretch. - [Eric Pascual] - * Add Sound.play_song method - - [Stefan Sauer] - * Make sound commands cancelable - - [Maximilian Nöthe] - * Add tuples for sensor modes - - [Daniel Walton] - * Add wait_until_not_moving for motors - - -- Denis Demidov Tue, 05 Sep 2017 16:50:20 +0300 - -python-ev3dev (0.8.1) stable; urgency=medium - - [Kaelin Laundry] - * Documentation updates - - [Thomas Watson] - * Use speed instead of duty cycle in EXPLOR3R/auto-drive.py - - [Denis Demidov] - * Provide Sound.set_volume(pct) - * Implement Sound.get_volume() - * Documentation updates - - [Daniel Walton] - * Documentation updates - * Update utils to python3 - * Clean up MINDCUB3R demo - - [Pepijn de Vos] - * Check for non-existing device attributes - - [Stefan Sauer] - * Fix inverted button logic for the latest kernel - - [@craigsammisutherland] - * Added a function to list all the connected sensors - - -- Denis Demidov Mon, 06 Feb 2017 09:27:21 +0300 - -python-ev3dev (0.8.0) stable; urgency=medium - - [Denis Demidov] - * Add option to not set mode before reading sensor value - * Return a tuple for multiple values from a sensor property - * Add ColorSensor.raw() method to get all color channels at once - * Merge NXTMotor functionality into LargeMotor class and remove NXTMotor - * Distribute fonts from xfonts-75dpi in PIL format and expose them in - code - * Replace special sensor methods with properties for the sake of consistency - (breaking change!) - * Provide waiting functions for motors. - * Make implementation of Led timer-based trigger more robust. - - -- Denis Demidov Thu, 03 Nov 2016 19:18:49 +0300 - -python-ev3dev (0.7.0) stable; urgency=medium - - [Denis Demidov] - * Support "-13-ev3dev" and newer kernels. - * Rename FirgelliL1250Motor and FirgelliL12100Motor to ActuonixL1250Motor - and ActuonixL12100Motor. - * Allow passing espeak options to Sound.speak - * Make sure that device classes connect to devices of the right - type (driver name) - * Fix fatal error due to calling an undefined function in - some property setters, such as LED triggers - - [Daniel Walton] - * Add "__version__" property. - * Add ev3dev/helper.py with classes for Tanks, Motors, Web UI, etc. - * Add ev3dev/GyroBalancer.py for robots that use a gyroscope. - - [Donald Webster] - * Fix port names on BrickPi. - - [Frank Busse] - * Fix bad division logic in display classes. - - [Kaelin Laundry] - * Fix fatal error when a requested sysfs device class hasn't been - populated yet. - - -- Denis Demidov Fri, 30 Sep 2016 21:29:28 +0300 - -python-ev3dev (0.7.0~rc1) stable; urgency=medium - - * Drop python 2.x support. - * Performance improvements for reading/writing sysfs attributes. - * Updates for breaking ev3dev kernel changes. - - -- David Lechner Mon, 25 Jul 2016 21:13:43 -0500 - -python-ev3dev (0.6.0) stable; urgency=medium - - [Ralph Hempel] - * Change port_name to address. - - [Denis Demidov] - * Restore python3 compatibility - * Implement device enumeration functions - - -- David Lechner Tue, 29 Dec 2015 23:05:39 -0600 - -python-ev3dev (0.5.0) stable; urgency=low - - * Initial Release. - - -- David Lechner Tue, 10 Nov 2015 21:30:53 -0600 + -- \ No newline at end of file diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 0000000..e723f5a --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,3 @@ +[DEFAULT] +debian-branch=ev3dev-stretch +debian-tag=ev3dev-stretch/%(version)s diff --git a/debian/release.sh b/debian/release.sh new file mode 100755 index 0000000..79d44e1 --- /dev/null +++ b/debian/release.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# Maintainer script for publishing releases. + +set -e + +source=$(dpkg-parsechangelog -S Source) +version=$(dpkg-parsechangelog -S Version) +distribution=$(dpkg-parsechangelog -S Distribution) +codename=$(debian-distro-info --codename --${distribution}) + +OS=debian DIST=${codename} ARCH=amd64 pbuilder-ev3dev build +OS=raspbian DIST=${codename} ARCH=armhf pbuilder-ev3dev build + +debsign ~/pbuilder-ev3dev/debian/${codename}-amd64/${source}_${version}_amd64.changes +debsign ~/pbuilder-ev3dev/raspbian/${codename}-armhf/${source}_${version}_armhf.changes + +dput ev3dev-debian ~/pbuilder-ev3dev/debian/${codename}-amd64/${source}_${version}_amd64.changes +dput ev3dev-raspbian ~/pbuilder-ev3dev/raspbian/${codename}-armhf/${source}_${version}_armhf.changes + +gbp buildpackage --git-tag-only diff --git a/ev3dev2/_platform/brickpi3.py b/ev3dev2/_platform/brickpi3.py index 9054fc6..4447e4a 100644 --- a/ev3dev2/_platform/brickpi3.py +++ b/ev3dev2/_platform/brickpi3.py @@ -1,3 +1,24 @@ +from collections import OrderedDict + +OUTPUT_A = 'spi0.1:MA' +OUTPUT_B = 'spi0.1:MB' +OUTPUT_C = 'spi0.1:MC' +OUTPUT_D = 'spi0.1:MD' + +INPUT_1 = 'spi0.1:S1' +INPUT_2 = 'spi0.1:S2' +INPUT_3 = 'spi0.1:S3' +INPUT_4 = 'spi0.1:S4' BUTTONS_FILENAME = None EVDEV_DEVICE_NAME = None + +LEDS = OrderedDict() +LEDS['blue_led'] = 'led0:blue:brick-status' + +LED_GROUPS = OrderedDict() +LED_GROUPS['LED'] = ('blue_led',) + +LED_COLORS = OrderedDict() +LED_COLORS['BLACK'] = (0,) +LED_COLORS['BLUE'] = (1,) diff --git a/ev3dev2/_platform/ev3.py b/ev3dev2/_platform/ev3.py index 573d3df..863d4f4 100644 --- a/ev3dev2/_platform/ev3.py +++ b/ev3dev2/_platform/ev3.py @@ -28,15 +28,15 @@ from collections import OrderedDict -OUTPUT_A = 'outA' -OUTPUT_B = 'outB' -OUTPUT_C = 'outC' -OUTPUT_D = 'outD' - -INPUT_1 = 'in1' -INPUT_2 = 'in2' -INPUT_3 = 'in3' -INPUT_4 = 'in4' +OUTPUT_A = 'ev3-ports:outA' +OUTPUT_B = 'ev3-ports:outB' +OUTPUT_C = 'ev3-ports:outC' +OUTPUT_D = 'ev3-ports:outD' + +INPUT_1 = 'ev3-ports:in1' +INPUT_2 = 'ev3-ports:in2' +INPUT_3 = 'ev3-ports:in3' +INPUT_4 = 'ev3-ports:in4' BUTTONS_FILENAME = '/dev/input/by-path/platform-gpio_keys-event' EVDEV_DEVICE_NAME = 'EV3 brick buttons' diff --git a/ev3dev2/_platform/pistorms.py b/ev3dev2/_platform/pistorms.py index 3e09ff3..40d556e 100644 --- a/ev3dev2/_platform/pistorms.py +++ b/ev3dev2/_platform/pistorms.py @@ -4,28 +4,28 @@ """ from collections import OrderedDict -OUTPUT_A = 'pistorms:BBM1' -OUTPUT_B = 'pistorms:BBM2' -OUTPUT_C = 'pistorms:BAM2' -OUTPUT_D = 'pistorms:BAM1' +OUTPUT_A = 'pistorms:BAM1' +OUTPUT_B = 'pistorms:BAM2' +OUTPUT_C = 'pistorms:BBM1' +OUTPUT_D = 'pistorms:BBM2' -INPUT_1 = 'pistorms:BBS1' -INPUT_2 = 'pistorms:BBS2' -INPUT_3 = 'pistorms:BAS2' -INPUT_4 = 'pistorms:BAS1' +INPUT_1 = 'pistorms:BAS1' +INPUT_2 = 'pistorms:BAS2' +INPUT_3 = 'pistorms:BBS1' +INPUT_4 = 'pistorms:BBS2' -BUTTONS_FILENAME = None -EVDEV_DEVICE_NAME = None +BUTTONS_FILENAME = '/dev/input/by-path/platform-3f804000.i2c-event' +EVDEV_DEVICE_NAME = 'PiStorms' LEDS = OrderedDict() -LEDS['red_left'] = 'pistorms:BA:red:brick-status' -LEDS['green_left'] = 'pistorms:BA:green:brick-statu' -LEDS['blue_left'] = 'pistorms:BA:blue:brick-status' -LEDS['red_right'] = 'pistorms:BB:red:brick-status' -LEDS['green_right'] = 'pistorms:BB:green:brick-statu' -LEDS['blue_right'] = 'pistorms:BB:blue:brick-status' +LEDS['red_left'] = 'pistorms:BB:red:brick-status' +LEDS['red_right'] = 'pistorms:BA:red:brick-status' +LEDS['green_left'] = 'pistorms:BB:green:brick-status' +LEDS['green_right'] = 'pistorms:BA:green:brick-status' +LEDS['blue_left'] = 'pistorms:BB:blue:brick-status' +LEDS['blue_right'] = 'pistorms:BA:blue:brick-status' LED_GROUPS = OrderedDict() LED_GROUPS['LEFT'] = ('red_left', 'green_left', 'blue_left') @@ -35,4 +35,7 @@ LED_COLORS['BLACK'] = (0, 0, 0) LED_COLORS['RED'] = (1, 0, 0) LED_COLORS['GREEN'] = (0, 1, 0) -LED_COLORS['BLUE'] = (0, 1, 1) +LED_COLORS['BLUE'] = (0, 0, 1) +LED_COLORS['YELLOW'] = (1, 1, 0) +LED_COLORS['CYAN'] = (0, 1, 1) +LED_COLORS['MAGENTA'] = (1, 0, 1) \ No newline at end of file From caa01ad726529afc83d26ee728c1fe528c2794f3 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 28 Jul 2018 21:03:24 -0700 Subject: [PATCH 070/172] Accept unit classes in motor groups (#480) --- docs/motors.rst | 1 + ev3dev2/__init__.py | 3 + ev3dev2/button.py | 18 +++- ev3dev2/display.py | 7 +- ev3dev2/motor.py | 218 ++++++++++++++++++++++++-------------------- tests/api_tests.py | 84 ++++++++++++++++- tests/fake-sys | 2 +- 7 files changed, 220 insertions(+), 113 deletions(-) diff --git a/docs/motors.rst b/docs/motors.rst index d7fc6f0..514070b 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -14,6 +14,7 @@ Most methods which run motors with accept a ``speed`` or ``speed_pct`` argument. .. autoclass:: SpeedInteger .. autoclass:: SpeedPercent +.. autoclass:: SpeedNativeUnits .. autoclass:: SpeedRPS .. autoclass:: SpeedRPM .. autoclass:: SpeedDPS diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 74d26c6..4da4332 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -124,6 +124,9 @@ def matches(attribute, pattern): yield f +def library_load_warning_message(library_name, dependent_class): + return 'Import warning: Failed to import "{}". {} will be unusable!'.format(library_name, dependent_class) + class DeviceNotFound(Exception): pass diff --git a/ev3dev2/button.py b/ev3dev2/button.py index 4d69f83..74b660c 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -30,16 +30,26 @@ import array import time -import evdev -from . import get_current_platform +import logging +from . import get_current_platform, library_load_warning_message + +log = logging.getLogger(__name__) try: # This is a linux-specific module. - # It is required by the Button() class, but failure to import it may be + # It is required by the Button class, but failure to import it may be # safely ignored if one just needs to run API tests on Windows. import fcntl except ImportError: - print("WARNING: Failed to import fcntl. Button class will be unuseable!") + log.warning(library_load_warning_message("fcntl", "Button")) + +try: + # This is a linux-specific module. + # It is required by the Button class, but failure to import it may be + # safely ignored if one just needs to run API tests on Windows. + import evdev +except ImportError: + log.warning(library_load_warning_message("evdev", "Button")) # Import the button filenames, this is platform specific diff --git a/ev3dev2/display.py b/ev3dev2/display.py index aebed23..cf863e3 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -31,18 +31,21 @@ import os import mmap import ctypes +import logging from PIL import Image, ImageDraw from . import fonts -from . import get_current_platform +from . import get_current_platform, library_load_warning_message from struct import pack +log = logging.getLogger(__name__) + try: # This is a linux-specific module. # It is required by the Display class, but failure to import it may be # safely ignored if one just needs to run API tests on Windows. import fcntl except ImportError: - print("WARNING: Failed to import fcntl. Display class will be unusable!") + log.warning(library_load_warning_message("fcntl", "Display")) class FbMem(object): diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index adbc6f7..e2aa7f5 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -81,7 +81,7 @@ class SpeedPercent(SpeedInteger): """ def __str__(self): - return ("%d%%" % self) + return int.__str__(self) + "%" def get_speed_pct(self, motor): """ @@ -90,19 +90,33 @@ def get_speed_pct(self, motor): return self +class SpeedNativeUnits(SpeedInteger): + """ + Speed in tacho counts per second. + """ + + def __str__(self): + return int.__str__(self) + "% (counts/sec)" + + def get_speed_pct(self, motor): + """ + Return the motor speed percentage represented by this SpeedNativeUnits + """ + return self/motor.max_speed * 100 + class SpeedRPS(SpeedInteger): """ Speed in rotations-per-second. """ def __str__(self): - return ("%d rps" % self) + return int.__str__(self) + " rps" def get_speed_pct(self, motor): """ Return the motor speed percentage to achieve desired rotations-per-second """ - assert self <= motor.max_rps, "%s max RPS is %s, %s was requested" % (motor, motor.max_rps, self) + assert self <= motor.max_rps, "{} max RPS is {}, {} was requested".format(motor, motor.max_rps, self) return (self/motor.max_rps) * 100 @@ -112,13 +126,13 @@ class SpeedRPM(SpeedInteger): """ def __str__(self): - return ("%d rpm" % self) + return int.__str__(self) + " rpm" def get_speed_pct(self, motor): """ Return the motor speed percentage to achieve desired rotations-per-minute """ - assert self <= motor.max_rpm, "%s max RPM is %s, %s was requested" % (motor, motor.max_rpm, self) + assert self <= motor.max_rpm, "{} max RPM is {}, {} was requested".format(motor, motor.max_rpm, self) return (self/motor.max_rpm) * 100 @@ -128,13 +142,13 @@ class SpeedDPS(SpeedInteger): """ def __str__(self): - return ("%d dps" % self) + return int.__str__(self) + " dps" def get_speed_pct(self, motor): """ Return the motor speed percentage to achieve desired degrees-per-second """ - assert self <= motor.max_dps, "%s max DPS is %s, %s was requested" % (motor, motor.max_dps, self) + assert self <= motor.max_dps, "{} max DPS is {}, {} was requested".format(motor, motor.max_dps, self) return (self/motor.max_dps) * 100 @@ -144,13 +158,13 @@ class SpeedDPM(SpeedInteger): """ def __str__(self): - return ("%d dpm" % self) + return int.__str__(self) + " dpm" def get_speed_pct(self, motor): """ Return the motor speed percentage to achieve desired degrees-per-minute """ - assert self <= motor.max_dps, "%s max DPM is %s, %s was requested" % (motor, motor.max_dpm, self) + assert self <= motor.max_dpm, "{} max DPM is {}, {} was requested".format(motor, motor.max_dpm, self) return (self/motor.max_dpm) * 100 @@ -827,7 +841,7 @@ def wait_while(self, s, timeout=None): """ return self.wait(lambda state: s not in state, timeout) - def _speed_pct(self, speed_pct): + def _speed_pct(self, speed_pct, label=None): # If speed_pct is SpeedInteger object we must convert # SpeedRPS, etc to an actual speed percentage @@ -835,14 +849,14 @@ def _speed_pct(self, speed_pct): speed_pct = speed_pct.get_speed_pct(self) assert -100 <= speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + "{}{} is an invalid speed_pct, must be between -100 and 100 (inclusive)".format(None if label is None else (label + ": ") , speed_pct) return speed_pct def _set_position_rotations(self, speed_pct, rotations): # +/- speed is used to control direction, rotations must be positive - assert rotations >= 0, "rotations is %s, must be >= 0" % rotations + assert rotations >= 0, "rotations is {}, must be >= 0".format(rotations) if speed_pct > 0: self.position_sp = self.position + int(rotations * self.count_per_rot) @@ -875,7 +889,7 @@ def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): speed_pct = self._speed_pct(speed_pct) if not speed_pct or not rotations: - log.warning("%s speed_pct is %s but rotations is %s, motor will not move" % (self, speed_pct, rotations)) + log.warning("({}) Either speed_pct ({}) or rotations ({}) is invalid, motor will not move" .format(self, speed_pct, rotations)) self._set_brake(brake) return @@ -898,7 +912,7 @@ def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): speed_pct = self._speed_pct(speed_pct) if not speed_pct or not degrees: - log.warning("%s speed_pct is %s but degrees is %s, motor will not move" % (self, speed_pct, degrees)) + log.warning("({}) Either speed_pct ({}) or degrees ({}) is invalid, motor will not move" .format(self, speed_pct, degrees)) self._set_brake(brake) return @@ -921,7 +935,7 @@ def on_to_position(self, speed_pct, position, brake=True, block=True): speed_pct = self._speed_pct(speed_pct) if not speed_pct: - log.warning("%s speed_pct is %s, motor will not move" % (self, speed_pct)) + log.warning("({}) speed_pct is invalid ({}), motor will not move".format(self, speed_pct)) self._set_brake(brake) return @@ -944,7 +958,7 @@ def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): speed_pct = self._speed_pct(speed_pct) if not speed_pct or not seconds: - log.warning("%s speed_pct is %s but seconds is %s, motor will not move" % (self, speed_pct, seconds)) + log.warning("({}) Either speed_pct ({}) or seconds ({}) is invalid, motor will not move" .format(self, speed_pct, seconds)) self._set_brake(brake) return @@ -970,7 +984,7 @@ def on(self, speed_pct, brake=True, block=False): speed_pct = self._speed_pct(speed_pct) if not speed_pct: - log.warning("%s speed_pct is %s, motor will not move" % (self, speed_pct)) + log.warning("({}) speed_pct is invalid ({}), motor will not move".format(self, speed_pct)) self._set_brake(brake) return @@ -1717,43 +1731,46 @@ def _block(self): self.left_motor.wait_until_not_moving() self.right_motor.wait_until_not_moving() - def _validate_speed_pct(self, left_speed_pct, right_speed_pct): - assert left_speed_pct >= -100 and left_speed_pct <= 100,\ - "%s is an invalid left_speed_pct, must be between -100 and 100 (inclusive)" % left_speed_pct - assert right_speed_pct >= -100 and right_speed_pct <= 100,\ - "%s is an invalid right_speed_pct, must be between -100 and 100 (inclusive)" % right_speed_pct + def _unpack_speeds_to_native_units(self, left_speed, right_speed): + left_speed_pct = self.left_motor._speed_pct(left_speed, "left_speed") + right_speed_pct = self.right_motor._speed_pct(right_speed, "right_speed") + assert left_speed_pct or right_speed_pct,\ - "Either left_speed_pct or right_speed_pct must be non-zero" + "Either left_speed or right_speed must be non-zero" + + return ( + int((left_speed_pct * self.left_motor.max_speed) / 100), + int((right_speed_pct * self.right_motor.max_speed) / 100) + ) - def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=True, block=True): + def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block=True): """ - Rotate the motors at 'left_speed & right_speed' for 'rotations'. + Rotate the motors at 'left_speed & right_speed' for 'rotations'. Speeds + can be integer percentages or any SpeedInteger implementation. If the left speed is not equal to the right speed (i.e., the robot will turn), the motor on the outside of the turn will rotate for the full ``rotations`` while the motor on the inside will have its requested distance calculated according to the expected turn. """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - left_speed = int((left_speed_pct * self.max_speed) / 100) - right_speed = int((right_speed_pct * self.max_speed) / 100) + (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # proof of the following distance calculation: consider the circle formed by each wheel's path # v_l = d_l/t, v_r = d_r/t # therefore, t = d_l/v_l = d_r/v_r - if left_speed > right_speed: + if left_speed_native_units > right_speed_native_units: left_rotations = rotations - right_rotations = abs(float(right_speed / left_speed)) * rotations + right_rotations = abs(float(right_speed_native_units / left_speed_native_units)) * rotations else: - left_rotations = abs(float(left_speed / right_speed)) * rotations + left_rotations = abs(float(left_speed_native_units / right_speed_native_units)) * rotations right_rotations = rotations # Set all parameters - self.left_motor.speed_sp = left_speed - self.left_motor._set_position_rotations(left_speed, left_rotations) + self.left_motor.speed_sp = left_speed_native_units + self.left_motor._set_position_rotations(left_speed_native_units, left_rotations) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed - self.right_motor._set_position_rotations(right_speed, right_rotations) + self.right_motor.speed_sp = right_speed_native_units + self.right_motor._set_position_rotations(right_speed_native_units, right_rotations) self.right_motor._set_brake(brake) # Start the motors @@ -1763,32 +1780,31 @@ def on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake=Tru if block: self._block() - def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, block=True): + def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=True): """ - Rotate the motors at 'left_speed & right_speed' for 'degrees'. + Rotate the motors at 'left_speed & right_speed' for 'degrees'. Speeds + can be integer percentages or any SpeedInteger implementation. If the left speed is not equal to the right speed (i.e., the robot will turn), the motor on the outside of the turn will rotate for the full ``degrees`` while the motor on the inside will have its requested distance calculated according to the expected turn. """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - left_speed = int((left_speed_pct * self.max_speed) / 100) - right_speed = int((right_speed_pct * self.max_speed) / 100) + (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) - if left_speed > right_speed: + if left_speed_native_units > right_speed_native_units: left_degrees = degrees - right_degrees = float(right_speed / left_speed) * degrees + right_degrees = float(right_speed / left_speed_native_units) * degrees else: - left_degrees = float(left_speed / right_speed) * degrees + left_degrees = float(left_speed_native_units / right_speed_native_units) * degrees right_degrees = degrees # Set all parameters - self.left_motor.speed_sp = left_speed - self.left_motor._set_position_degrees(left_speed, left_degrees) + self.left_motor.speed_sp = left_speed_native_units + self.left_motor._set_position_degrees(left_speed_native_units, left_degrees) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed - self.right_motor._set_position_degrees(right_speed, right_degrees) + self.right_motor.speed_sp = right_speed_native_units + self.right_motor._set_position_degrees(right_speed_native_units, right_degrees) self.right_motor._set_brake(brake) # Start the motors @@ -1798,17 +1814,18 @@ def on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake=True, b if block: self._block() - def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, block=True): + def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=True): """ - Rotate the motors at 'left_speed & right_speed' for 'seconds' + Rotate the motors at 'left_speed & right_speed' for 'seconds'. Speeds + can be integer percentages or any SpeedInteger implementation. """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) + (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # Set all parameters - self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) + self.left_motor.speed_sp = left_speed_native_units self.left_motor.time_sp = int(seconds * 1000) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) + self.right_motor.speed_sp = right_speed_native_units self.right_motor.time_sp = int(seconds * 1000) self.right_motor._set_brake(brake) @@ -1819,13 +1836,15 @@ def on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake=True, b if block: self._block() - def on(self, left_speed_pct, right_speed_pct): + def on(self, left_speed, right_speed): """ - Start rotating the motors according to ``left_speed_pct`` and ``right_speed_pct`` forever. + Start rotating the motors according to ``left_speed`` and ``right_speed`` forever. + Speeds can be integer percentages or any SpeedInteger implementation. """ - self._validate_speed_pct(left_speed_pct, right_speed_pct) - self.left_motor.speed_sp = int((left_speed_pct * self.max_speed) / 100) - self.right_motor.speed_sp = int((right_speed_pct * self.max_speed) / 100) + (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) + + self.left_motor.speed_sp = left_speed_native_units + self.right_motor.speed_sp = right_speed_native_units # Start the motors self.left_motor.run_forever() @@ -1859,45 +1878,45 @@ class MoveSteering(MoveTank): steering_drive = MoveSteering(OUTPUT_A, OUTPUT_B) # drive in a turn for 10 rotations of the outer motor - steering_drive.on_for_rotations(-20, 75, 10) + steering_drive.on_for_rotations(-20, SpeedPercent(75), 10) """ - def on_for_rotations(self, steering, speed_pct, rotations, brake=True, block=True): + def on_for_rotations(self, steering, speed, rotations, brake=True, block=True): """ Rotate the motors according to the provided ``steering``. The distance each motor will travel follows the rules of :meth:`MoveTank.on_for_rotations`. """ - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_rotations(self, left_speed_pct, right_speed_pct, rotations, brake, block) + (left_speed, right_speed) = self.get_speed_steering(steering, speed) + MoveTank.on_for_rotations(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), rotations, brake, block) - def on_for_degrees(self, steering, speed_pct, degrees, brake=True, block=True): + def on_for_degrees(self, steering, speed, degrees, brake=True, block=True): """ Rotate the motors according to the provided ``steering``. The distance each motor will travel follows the rules of :meth:`MoveTank.on_for_degrees`. """ - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_degrees(self, left_speed_pct, right_speed_pct, degrees, brake, block) + (left_speed, right_speed) = self.get_speed_steering(steering, speed) + MoveTank.on_for_degrees(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), degrees, brake, block) - def on_for_seconds(self, steering, speed_pct, seconds, brake=True, block=True): + def on_for_seconds(self, steering, speed, seconds, brake=True, block=True): """ Rotate the motors according to the provided ``steering`` for ``seconds``. """ - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on_for_seconds(self, left_speed_pct, right_speed_pct, seconds, brake, block) + (left_speed, right_speed) = self.get_speed_steering(steering, speed) + MoveTank.on_for_seconds(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), seconds, brake, block) - def on(self, steering, speed_pct): + def on(self, steering, speed): """ Start rotating the motors according to the provided ``steering`` forever. """ - (left_speed_pct, right_speed_pct) = self.get_speed_steering(steering, speed_pct) - MoveTank.on(self, left_speed_pct, right_speed_pct) + (left_speed, right_speed) = self.get_speed_steering(steering, speed) + MoveTank.on(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed)) - def get_speed_steering(self, steering, speed_pct): + def get_speed_steering(self, steering, speed): """ Calculate the speed_sp for each motor in a pair to achieve the specified steering. Note that calling this function alone will not make the - motors move, it only sets the speed. A run_* function must be called + motors move, it only calculates the speed. A run_* function must be called afterwards to make the motors move. steering [-100, 100]: @@ -1905,32 +1924,29 @@ def get_speed_steering(self, steering, speed_pct): * 0 means drive in a straight line, and * 100 means turn right on the spot (left motor at 100% forward, right motor at 100% backward). - speed_pct: + speed: The speed that should be applied to the outmost motor (the one rotating faster). The speed of the other motor will be computed automatically. """ assert steering >= -100 and steering <= 100,\ - "%s is an invalid steering, must be between -100 and 100 (inclusive)" % steering - assert speed_pct >= -100 and speed_pct <= 100,\ - "%s is an invalid speed_pct, must be between -100 and 100 (inclusive)" % speed_pct + "%{} is an invalid steering, must be between -100 and 100 (inclusive)".format(steering) + # We don't have a good way to make this generic for the pair... so we + # assume that the left motor's speed stats are the same as the right + # motor's. + speed_pct = self.left_motor._speed_pct(speed) left_speed = int((speed_pct * self.max_speed) / 100) right_speed = left_speed - speed = (50 - abs(float(steering))) / 50 + speed_factor = (50 - abs(float(steering))) / 50 if steering >= 0: - right_speed *= speed + right_speed *= speed_factor else: - left_speed *= speed - - left_speed_pct = int((left_speed * 100) / self.left_motor.max_speed) - right_speed_pct = int((right_speed * 100) / self.right_motor.max_speed) - #log.debug("%s: steering %d, %s speed %d, %s speed %d" % - # (self, steering, self.left_motor, left_speed_pct, self.right_motor, right_speed_pct)) - - return (left_speed_pct, right_speed_pct) + left_speed *= speed_factor + + return (left_speed, right_speed) class MoveJoystick(MoveTank): @@ -1938,7 +1954,7 @@ class MoveJoystick(MoveTank): Used to control a pair of motors via a single joystick vector. """ - def on(self, x, y, max_speed, radius=100.0): + def on(self, x, y, max_speed=100.0, radius=100.0): """ Convert x,y joystick coordinates to left/right motor speed percentages and move the motors. @@ -1983,20 +1999,20 @@ def on(self, x, y, max_speed, radius=100.0): left_speed_percentage = (init_left_speed_percentage * vector_length) / radius right_speed_percentage = (init_right_speed_percentage * vector_length) / radius - log.debug(""" - x, y : %s, %s - radius : %s - angle : %s - vector length : %s - init left_speed_percentage : %s - init right_speed_percentage : %s - final left_speed_percentage : %s - final right_speed_percentage : %s - """ % (x, y, radius, angle, vector_length, - init_left_speed_percentage, init_right_speed_percentage, - left_speed_percentage, right_speed_percentage)) + # log.debug(""" + # x, y : %s, %s + # radius : %s + # angle : %s + # vector length : %s + # init left_speed_percentage : %s + # init right_speed_percentage : %s + # final left_speed_percentage : %s + # final right_speed_percentage : %s + # """ % (x, y, radius, angle, vector_length, + # init_left_speed_percentage, init_right_speed_percentage, + # left_speed_percentage, right_speed_percentage)) - MoveTank.on(self, left_speed_percentage, right_speed_percentage) + MoveTank.on(self, SpeedPercent(left_speed_percentage * self.left_motor._speed_pct(max_speed) / 100), SpeedPercent(right_speed_percentage * self.right_motor._speed_pct(max_speed) / 100)) @staticmethod @@ -2148,6 +2164,6 @@ def angle_to_speed_percentage(angle): right_speed_percentage = -1 * percentage_from_315_to_360 else: - raise Exception('You created a circle with more than 360 degrees (%s)...that is quite the trick' % angle) + raise Exception('You created a circle with more than 360 degrees ({})...that is quite the trick'.format(angle)) return (left_speed_percentage * 100, right_speed_percentage * 100) diff --git a/tests/api_tests.py b/tests/api_tests.py index ee61d55..0f91999 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -11,14 +11,19 @@ import ev3dev2 from ev3dev2.sensor.lego import InfraredSensor -from ev3dev2.motor import MediumMotor +from ev3dev2.motor import Motor, MediumMotor, MoveTank, MoveSteering, MoveJoystick, SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits, OUTPUT_A, OUTPUT_B ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') +def dummy_wait(self, cond, timeout=None): + pass + +Motor.wait = dummy_wait + class TestAPI(unittest.TestCase): def test_device(self): clean_arena() - populate_arena({'medium_motor' : [0, 'outA'], 'infrared_sensor' : [0, 'in1']}) + populate_arena([('medium_motor', 0, 'outA'), ('infrared_sensor', 0, 'in1')]) d = ev3dev2.Device('tacho-motor', 'motor*') @@ -41,7 +46,7 @@ def dummy(self): pass clean_arena() - populate_arena({'medium_motor' : [0, 'outA']}) + populate_arena([('medium_motor', 0, 'outA')]) # Do not write motor.command on exit (so that fake tree stays intact) MediumMotor.__del__ = dummy @@ -75,7 +80,7 @@ def dummy(self): def test_infrared_sensor(self): clean_arena() - populate_arena({'infrared_sensor' : [0, 'in1']}) + populate_arena([('infrared_sensor', 0, 'in1')]) s = InfraredSensor() @@ -88,7 +93,7 @@ def test_infrared_sensor(self): def test_medium_motor_write(self): clean_arena() - populate_arena({'medium_motor' : [0, 'outA']}) + populate_arena([('medium_motor', 0, 'outA')]) m = MediumMotor() @@ -96,5 +101,74 @@ def test_medium_motor_write(self): m.speed_sp = 500 self.assertEqual(m.speed_sp, 500) + def test_move_tank(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) + + drive = MoveTank(OUTPUT_A, OUTPUT_B) + drive.on_for_rotations(50, 25, 10) + + self.assertEqual(drive.left_motor.position, 0) + self.assertEqual(drive.left_motor.position_sp, 10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 1050 / 2) + + self.assertEqual(drive.right_motor.position, 0) + self.assertAlmostEqual(drive.right_motor.position_sp, 5 * 360, delta=5) + self.assertAlmostEqual(drive.right_motor.speed_sp, 1050 / 4, delta=1) + + def test_tank_units(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) + + drive = MoveTank(OUTPUT_A, OUTPUT_B) + drive.on_for_rotations(SpeedDPS(400), SpeedDPM(10000), 10) + + self.assertEqual(drive.left_motor.position, 0) + self.assertEqual(drive.left_motor.position_sp, 10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 400) + + self.assertEqual(drive.right_motor.position, 0) + self.assertAlmostEqual(drive.right_motor.position_sp, 10 * 360 * ((10000 / 60) / 400), delta=7) + self.assertAlmostEqual(drive.right_motor.speed_sp, 10000 / 60, delta=1) + + def test_steering_units(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) + + drive = MoveSteering(OUTPUT_A, OUTPUT_B) + drive.on_for_rotations(25, SpeedDPS(400), 10) + + self.assertEqual(drive.left_motor.position, 0) + self.assertEqual(drive.left_motor.position_sp, 10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 400) + + self.assertEqual(drive.right_motor.position, 0) + self.assertEqual(drive.right_motor.position_sp, 5 * 360) + self.assertEqual(drive.right_motor.speed_sp, 200) + + def test_joystick_units(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) + + drive = MoveJoystick(OUTPUT_A, OUTPUT_B) + drive.on(100, 100, max_speed=SpeedPercent(50)) + + self.assertEqual(drive.left_motor.speed_sp, 1050 / 2) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0) + + def test_units(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) + + m = Motor() + + self.assertEqual(SpeedPercent(35).get_speed_pct(m), 35) + self.assertEqual(SpeedDPS(300).get_speed_pct(m), 300 / 1050 * 100) + self.assertEqual(SpeedNativeUnits(300).get_speed_pct(m), 300 / 1050 * 100) + self.assertEqual(SpeedDPM(30000).get_speed_pct(m), (30000 / 60) / 1050 * 100) + self.assertEqual(SpeedRPS(2).get_speed_pct(m), 360 * 2 / 1050 * 100) + self.assertEqual(SpeedRPM(100).get_speed_pct(m), (360 * 100 / 60) / 1050 * 100) + + if __name__ == "__main__": unittest.main() diff --git a/tests/fake-sys b/tests/fake-sys index a006e99..13904dc 160000 --- a/tests/fake-sys +++ b/tests/fake-sys @@ -1 +1 @@ -Subproject commit a006e999da6434bf094242847dd85593aaa0b3a0 +Subproject commit 13904dc7d0555857d4a0a85a59252e9a0812cfd6 From 255cf5dd12eac5c37f661cb4bbabb4141b0c3adc Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 29 Jul 2018 15:53:24 -0700 Subject: [PATCH 071/172] Sync README, docs and code behavior (#490) * Sync README, docs and code behavior Minor improvement to LED parameter options. * Make beep and tone behave the same as other sound methods * Add nitpick ignore for "list" --- README.rst | 44 +++++++++----- docs/conf.py | 9 ++- docs/other.rst | 72 ++++++++++++++++------ ev3dev2/led.py | 15 ++++- ev3dev2/sound.py | 154 ++++++++++++++++++++++++++--------------------- 5 files changed, 191 insertions(+), 103 deletions(-) diff --git a/README.rst b/README.rst index 918da02..ba82ae2 100644 --- a/README.rst +++ b/README.rst @@ -22,10 +22,9 @@ Getting Started This library runs on ev3dev_. Before continuing, make sure that you have set up your EV3 or other ev3dev device as explained in the `ev3dev Getting Started guide`_. -Make sure that you have a kernel version that includes ``-10-ev3dev`` or higher (a -larger number). You can check the kernel version by selecting "About" in Brickman -and scrolling down to the "kernel version". If you don't have a compatible version, -`upgrade the kernel before continuing`_. +Make sure you have an ev3dev-stretch version greater than ``2.2.0``. You can check +the kernel version by selecting "About" in Brickman and scrolling down to the +"kernel version". If you don't have a compatible version, `upgrade the kernel before continuing`_. Usage ----- @@ -41,7 +40,7 @@ your own solution. If you don't know how to do that, you are probably better off choosing the recommended option above. The template for a Python script -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Every Python program should have a few basic parts. Use this template to get started: @@ -49,7 +48,8 @@ to get started: .. code-block:: python #!/usr/bin/env python3 - from ev3dev2.motor import LargeMotor, OUTPUT_A, SpeedPercent + from ev3dev2.motor import LargeMotor, OUTPUT_A, OUTPUT_B, SpeedPercent, MoveTank + from ev3dev2.sensor import INPUT_1 from ev3dev2.sensor.lego import TouchSensor from ev3dev2.led import Leds @@ -64,6 +64,10 @@ or additional utilities. You should use the ``.py`` extension for your file, e.g. ``my-file.py``. +If you encounter an error such as ``/usr/bin/env: 'python3\r': No such file or directory``, +you must switch your editor's "line endings" setting for the file from "CRLF" to just "LF". +This is usually in the status bar at the bottom. For help, see `our FAQ page`_. + Important: Make your script executable (non-Visual Studio Code only) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -80,7 +84,7 @@ from the command line by preceding the file name with ``./``: ``./my-file.py`` Controlling the LEDs with a touch sensor ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This code will turn the left LED red whenever the touch sensor is pressed, and +This code will turn the LEDs red whenever the touch sensor is pressed, and back to green when it's released. Plug a touch sensor into any sensor port before trying this out. @@ -89,11 +93,21 @@ trying this out. ts = TouchSensor() leds = Leds() + print("Press the touch sensor to change the LED color!") + while True: if ts.is_pressed: - leds.set_color(leds.led_groups.LEFT, leds.led_colors.GREEN) + leds.set_color("LEFT", "GREEN") + leds.set_color("RIGHT", "GREEN") else: - leds.set_color(leds.led_groups.LEFT, leds.led_colors.RED) + leds.set_color("LEFT", "RED") + leds.set_color("RIGHT", "RED") + +If you'd like to use a sensor on a specific port, specify the port like this: + +.. code-block:: python + + ts = TouchSensor(INPUT_1) Running a single motor ~~~~~~~~~~~~~~~~~~~~~~ @@ -144,7 +158,7 @@ If you want to make your robot speak, you can use the ``Sound.speak`` method: from ev3dev2.sound import Sound sound = Sound() - sound.speak('Welcome to the E V 3 dev project!').wait() + sound.speak('Welcome to the E V 3 dev project!') Make sure to check out the `User Resources`_ section for more detailed information on these features and many others. @@ -192,7 +206,7 @@ to type the password (the default is ``maker``) when prompted. .. code-block:: bash sudo apt-get update - sudo apt-get install --only-upgrade python3-ev3dev + sudo apt-get install --only-upgrade python3-ev3dev2 Developer Resources @@ -209,9 +223,6 @@ Python 2.x and Python 3.x Compatibility Some versions of the ev3dev_ distribution come with both `Python 2.x`_ and `Python 3.x`_ installed but this library is compatible only with Python 3. -As of the 2016-10-17 ev3dev image, the version of this library which is included runs on -Python 3 and this is the only version that will be supported from here forward. - .. _ev3dev: http://ev3dev.org .. _ev3dev.org: ev3dev_ .. _Getting Started: ev3dev-getting-started_ @@ -221,9 +232,10 @@ Python 3 and this is the only version that will be supported from here forward. .. _detailed instructions for USB connections: ev3dev-usb-internet_ .. _via an SSH connection: http://www.ev3dev.org/docs/tutorials/connecting-to-ev3dev-with-ssh/ .. _ev3dev-usb-internet: http://www.ev3dev.org/docs/tutorials/connecting-to-the-internet-via-usb/ -.. _our Read the Docs page: http://python-ev3dev.readthedocs.org/en/stable/ +.. _our Read the Docs page: http://python-ev3dev.readthedocs.org/en/ev3dev-stretch/ .. _ev3python.com: http://ev3python.com/ -.. _FAQ: http://python-ev3dev.readthedocs.io/en/stable/faq.html +.. _FAQ: http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html +.. _our FAQ page: FAQ_ .. _ev3dev-lang-python: https://github.com/rhempel/ev3dev-lang-python .. _our Issues tracker: https://github.com/rhempel/ev3dev-lang-python/issues .. _EXPLOR3R: demo-robot_ diff --git a/docs/conf.py b/docs/conf.py index 8f3d526..1291fc3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -317,7 +317,14 @@ nitpick_ignore = [ ('py:class', 'ev3dev2.display.FbMem'), - ('py:class', 'ev3dev2.button.ButtonBase') + ('py:class', 'ev3dev2.button.ButtonBase'), + ('py:class', 'int'), + ('py:class', 'float'), + ('py:class', 'string'), + ('py:class', 'iterable'), + ('py:class', 'tuple'), + ('py:class', 'list'), + ('py:exc', 'ValueError') ] def setup(app): diff --git a/docs/other.rst b/docs/other.rst index a65a81c..c74a0a7 100644 --- a/docs/other.rst +++ b/docs/other.rst @@ -30,31 +30,69 @@ Leds .. autoclass:: ev3dev2.led.Leds :members: - .. rubric:: EV3 platform +LED group and color names +~~~~~~~~~~~~~~~~~~~~~~~~~ - Led groups: +.. rubric:: EV3 platform - .. py:data:: LEFT - .. py:data:: RIGHT +Led groups: - Colors: +- ``LEFT`` +- ``RIGHT`` - .. py:data:: RED - .. py:data:: GREEN - .. py:data:: AMBER - .. py:data:: ORANGE - .. py:data:: YELLOW +Colors: - .. rubric:: BrickPI platform +- ``BLACK`` +- ``RED`` +- ``GREEN`` +- ``AMBER`` +- ``ORANGE`` +- ``YELLOW`` - Led groups: +.. rubric:: BrickPI platform - .. py:data:: LED1 - .. py:data:: LED2 +Led groups: - Colors: +- ``LED1`` +- ``LED2`` - .. py:data:: BLUE +Colors: + +- ``BLACK`` +- ``BLUE`` + +.. rubric:: BrickPI3 platform + +Led groups: + +- ``LED`` + +Colors: + +- ``BLACK`` +- ``BLUE`` + +.. rubric:: PiStorms platform + +Led groups: + +- ``LEFT`` +- ``RIGHT`` + +Colors: + +- ``BLACK`` +- ``RED`` +- ``GREEN`` +- ``BLUE`` +- ``YELLOW`` +- ``CYAN`` +- ``MAGENTA`` + +.. rubric:: EVB platform + +None. + Power Supply ------------ @@ -76,7 +114,7 @@ Display :show-inheritance: Bitmap fonts -^^^^^^^^^^^^ +~~~~~~~~~~~~ The :py:class:`ev3dev2.display.Display` class allows to write text on the LCD using python imaging library (PIL) interface (see description of the ``text()`` method diff --git a/ev3dev2/led.py b/ev3dev2/led.py index 83a1c8c..69b516e 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -292,17 +292,27 @@ def set_color(self, group, color, pct=1): reduced proportionally. Example:: + my_leds = Leds() my_leds.set_color('LEFT', 'AMBER') + + With a custom color:: + + my_leds = Leds() + my_leds.set_color('LEFT', (0.5, 0.3)) """ # If this is a platform without LEDs there is nothing to do if not self.leds: return + color_tuple = color + if isinstance(color, str): + assert color in self.led_colors, "%s is an invalid LED color, valid choices are %s" % (color, ','.join(self.led_colors.keys())) + color_tuple = self.led_colors[color] + assert group in self.led_groups, "%s is an invalid LED group, valid choices are %s" % (group, ','.join(self.led_groups.keys())) - assert color in self.led_colors, "%s is an invalid LED color, valid choices are %s" % (color, ','.join(self.led_colors.keys())) - for led, value in zip(self.led_groups[group], self.led_colors[color]): + for led, value in zip(self.led_groups[group], color_tuple): led.brightness_pct = value * pct def set(self, group, **kwargs): @@ -310,6 +320,7 @@ def set(self, group, **kwargs): Set attributes for each led in group. Example:: + my_leds = Leds() my_leds.set_color('LEFT', brightness_pct=0.5, trigger='timer') """ diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index e2280b5..6f4e125 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -55,11 +55,11 @@ class Sound(object): Examples:: - # Play 'bark.wav', return immediately: + # Play 'bark.wav': Sound.play('bark.wav') - # Introduce yourself, wait for completion: - Sound.speak('Hello, I am Robot').wait() + # Introduce yourself: + Sound.speak('Hello, I am Robot') # Play a small song Sound.play_song(( @@ -78,9 +78,9 @@ class Sound(object): channel = None # play_types - PLAY_WAIT_FOR_COMPLETE = 0 - PLAY_NO_WAIT_FOR_COMPLETE = 1 - PLAY_LOOP = 2 + PLAY_WAIT_FOR_COMPLETE = 0 #: Play the sound and block until it is complete + PLAY_NO_WAIT_FOR_COMPLETE = 1 #: Start playing the sound but return immediately + PLAY_LOOP = 2 #: Never return; start the sound immediately after it completes, until the program is killed PLAY_TYPES = ( PLAY_WAIT_FOR_COMPLETE, @@ -92,30 +92,40 @@ def _validate_play_type(self, play_type): assert play_type in self.PLAY_TYPES, \ "Invalid play_type %s, must be one of %s" % (play_type, ','.join(str(t) for t in self.PLAY_TYPES)) - def beep(self, args=''): + def beep(self, args='', play_type=PLAY_WAIT_FOR_COMPLETE): """ Call beep command with the provided arguments (if any). See `beep man page`_ and google `linux beep music`_ for inspiration. + :param string args: Any additional arguments to be passed to ``beep`` (see the `beep man page`_ for details) + + :param play_type: The behavior of ``beep`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` + + :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + .. _`beep man page`: https://linux.die.net/man/1/beep .. _`linux beep music`: https://www.google.com/search?q=linux+beep+music """ with open(os.devnull, 'w') as n: - return Popen(shlex.split('/usr/bin/beep %s' % args), stdout=n) + subprocess = Popen(shlex.split('/usr/bin/beep %s' % args), stdout=n) + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: + subprocess.wait() + return None + else: + return subprocess - def tone(self, *args): + + def tone(self, *args, play_type=PLAY_WAIT_FOR_COMPLETE): """ .. rubric:: tone(tone_sequence) - Play tone sequence. The tone_sequence parameter is a list of tuples, - where each tuple contains up to three numbers. The first number is - frequency in Hz, the second is duration in milliseconds, and the third - is delay in milliseconds between this and the next tone in the - sequence. + Play tone sequence. Here is a cheerful example:: - Sound.tone([ + my_sound = Sound() + my_sound.tone([ (392, 350, 100), (392, 350, 100), (392, 350, 100), (311.1, 250, 100), (466.2, 25, 100), (392, 350, 100), (311.1, 250, 100), (466.2, 25, 100), (392, 700, 100), (587.32, 350, 100), (587.32, 350, 100), @@ -134,14 +144,29 @@ def tone(self, *args): (466.16, 25, 100), (440, 25, 100), (466.16, 50, 400), (311.13, 25, 200), (392, 350, 100), (311.13, 250, 100), (466.16, 25, 100), (392.00, 300, 150), (311.13, 250, 100), (466.16, 25, 100), (392, 700) - ]).wait() + ]) Have also a look at :py:meth:`play_song` for a more musician-friendly way of doing, which uses the conventional notation for notes and durations. + :param list[tuple(float,float,float)] tone_sequence: The sequence of tones to play. The first number of each tuple is frequency in Hz, the second is duration in milliseconds, and the third is delay in milliseconds between this and the next tone in the sequence. + + :param play_type: The behavior of ``tone`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` + + :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + .. rubric:: tone(frequency, duration) - Play single tone of given frequency (Hz) and duration (milliseconds). + Play single tone of given frequency and duration. + + :param float frequency: The frequency of the tone in Hz + :param float duration: The duration of the tone in milliseconds + + :param play_type: The behavior of ``tone`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` + + :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ def play_tone_sequence(tone_sequence): def beep_args(frequency=None, duration=None, delay=None): @@ -155,31 +180,30 @@ def beep_args(frequency=None, duration=None, delay=None): return args - return self.beep(' -n '.join([beep_args(*t) for t in tone_sequence])) + return self.beep(' -n '.join([beep_args(*t) for t in tone_sequence]), play_type=play_type) if len(args) == 1: return play_tone_sequence(args[0]) elif len(args) == 2: return play_tone_sequence([(args[0], args[1])]) else: - raise Exception("Unsupported number of parameters in Sound.tone()") + raise Exception("Unsupported number of parameters in Sound.tone(): expected 1 or 2, got " + str(len(args))) def play_tone(self, frequency, duration, delay=0.0, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Play a single tone, specified by its frequency, duration, volume and final delay. - Args: - frequency (int): the tone frequency, in Hertz - duration (float): tone duration, in seconds - delay (float): delay after tone, in seconds (can be useful when chaining calls to ``play_tone``) - volume (int): sound volume in percent (between 0 and 100) - play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + :param int frequency: the tone frequency, in Hertz + :param float duration: Tone duration, in seconds + :param float delay: Delay after tone, in seconds (can be useful when chaining calls to ``play_tone``) + :param int volume: The play volume, in percent of maximum volume + + :param play_type: The behavior of ``play_tone`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - Returns: - the sound playing subprocess PID when no wait play type is selected, None otherwise + :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise - Raises: - ValueError: if invalid value for parameter(s) + :raises ValueError: if invalid parameter """ self._validate_play_type(play_type) @@ -210,18 +234,16 @@ def play_tone(self, frequency, duration, delay=0.0, volume=100, def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Plays a note, given by its name as defined in ``_NOTE_FREQUENCIES``. - Args: - note (str) the note symbol with its octave number - duration (float): tone duration, in seconds - volume (int) the play volume, in percent of maximum volume - play_type (int) the type of play (wait, no wait, loop), as defined - by the ``PLAY_xxx`` constants + :param string note: The note symbol with its octave number + :param float duration: Tone duration, in seconds + :param int volume: The play volume, in percent of maximum volume - Returns: - the PID of the underlying beep command if no wait play type, None otherwise + :param play_type: The behavior of ``play_note`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - Raises: - ValueError: is invalid parameter (note, duration,...) + :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise + + :raises ValueError: is invalid parameter (note, duration,...) """ self._validate_play_type(play_type) try: @@ -238,12 +260,12 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): """ Play a sound file (wav format). - Args: - wav_file (str): the sound file path - play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + :param string wav_file: The sound file path + + :param play_type: The behavior of ``play`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - Returns: - subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise + :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ self._validate_play_type(play_type) @@ -265,13 +287,14 @@ def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Play a sound file (wav format) at a given volume. - Args: - wav_file (str): the sound file path - volume (int) the play volume, in percent of maximum volume - play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + + :param string wav_file: The sound file path + :param int volume: The play volume, in percent of maximum volume - Returns: - subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise + :param play_type: The behavior of ``play_file`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` + + :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ self.set_volume(volume) self.play(wav_file, play_type) @@ -281,14 +304,14 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA Uses the ``espeak`` external command. - Args: - text (str): the text to speak - espeak_opts (str): espeak command options - volume (int) the play volume, in percent of maximum volume - play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop) + :param string text: The text to speak + :param string espeak_opts: ``espeak`` command options (advanced usage) + :param int volume: The play volume, in percent of maximum volume + + :param play_type: The behavior of ``speak`` once playback has been initiated + :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - Returns: - subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise + :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ self._validate_play_type(play_type) self.set_volume(volume) @@ -314,8 +337,8 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA def _get_channel(self): """ - Returns: - str: the detected sound channel + :returns: The detected sound channel + :rtype: string """ if self.channel is None: # Get default channel as the first one that pops up in @@ -422,16 +445,13 @@ def play_song(self, song, tempo=120, delay=0.05): Only 4/4 signature songs are supported with respect to note durations. - Args: - song (iterable[tuple(str, str)]): the song - tempo (int): the song tempo, given in quarters per minute - delay (float): delay between notes (in seconds) + :param iterable[tuple(string, string)] song: the song + :param int tempo: the song tempo, given in quarters per minute + :param float delay: delay between notes (in seconds) - Returns: - subprocess.Popen: the spawn subprocess + :return: the spawn subprocess from ``subprocess.Popen`` - Raises: - ValueError: if invalid note in song or invalid play parameters + :raises ValueError: if invalid note in song or invalid play parameters """ if tempo <= 0: raise ValueError('invalid tempo (%s)' % tempo) From 74375ec71aac87e8dd581d9f980cf2bba1fbe0e4 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 29 Jul 2018 16:49:57 -0700 Subject: [PATCH 072/172] Update package metadata for PyPi (#491) --- README.rst | 2 +- setup.cfg | 2 +- setup.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ba82ae2..b0dbc37 100644 --- a/README.rst +++ b/README.rst @@ -250,7 +250,7 @@ but this library is compatible only with Python 3. .. _package repository: pypi_ .. _pypi: https://pypi.python.org/pypi .. _latest version of this package: pypi-python-ev3dev_ -.. _pypi-python-ev3dev: https://pypi.python.org/pypi/python-ev3dev +.. _pypi-python-ev3dev: https://pypi.python.org/pypi/python-ev3dev2 .. _ev3dev Visual Studio Code extension: https://github.com/ev3dev/vscode-ev3dev-browser .. _Python + VSCode introduction tutorial: https://github.com/ev3dev/vscode-hello-python .. _nano: http://www.ev3dev.org/docs/tutorials/nano-cheat-sheet/ diff --git a/setup.cfg b/setup.cfg index f797a99..be98747 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [sdist_dsc] -package: python-ev3dev +package: python-ev3dev2 diff --git a/setup.py b/setup.py index a8d650d..9c697d6 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,10 @@ from setuptools import setup from git_version import git_version +from os import path +this_directory = path.abspath(path.dirname(__file__)) +with open(path.join(this_directory, 'README.rst'), encoding='utf-8') as f: + long_description = f.read() setup( name='python-ev3dev2', @@ -11,6 +15,8 @@ license='MIT', url='https://github.com/ev3dev/ev3dev-lang-python', include_package_data=True, + long_description=long_description, + long_description_content_type='text/x-rst', packages=['ev3dev2', 'ev3dev2.fonts', 'ev3dev2.sensor', From 7605c740aafd0f9dd96f88edbec34b2fb045f3bf Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 29 Jul 2018 16:57:12 -0700 Subject: [PATCH 073/172] Update docs links in display class --- ev3dev2/display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev2/display.py b/ev3dev2/display.py index cf863e3..f8c2643 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -383,7 +383,7 @@ def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', fon https://www.w3schools.com/colors/colors_names.asp 'font' : can be any font displayed here - http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/stable/other.html#bitmap-fonts + http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/other.html#bitmap-fonts """ if clear_screen: @@ -409,7 +409,7 @@ def text_grid(self, text, clear_screen=True, x=0, y=0, text_color='black', font= https://www.w3schools.com/colors/colors_names.asp 'font' : can be any font displayed here - http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/stable/other.html#bitmap-fonts + http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/other.html#bitmap-fonts """ assert 0 <= x < Display.GRID_COLUMNS,\ From 0cd5c6bc8239c12b41d6fa670aacaa8430dd20b0 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 29 Jul 2018 17:46:07 -0700 Subject: [PATCH 074/172] Remove obsolete file --- MANIFEST.in | 1 - spec_version.py | 6 ------ 2 files changed, 7 deletions(-) delete mode 100644 spec_version.py diff --git a/MANIFEST.in b/MANIFEST.in index 072c60e..8368fdc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ include git_version.py -include spec_version.py include RELEASE-VERSION diff --git a/spec_version.py b/spec_version.py deleted file mode 100644 index b74d5f6..0000000 --- a/spec_version.py +++ /dev/null @@ -1,6 +0,0 @@ -spec_version = "1.2.0" -kernel_versions = { - "11-ev3dev" - "11-rc1-ev3dev" - } - From b1221ac756eb859e61bba3cd4556441088f65c38 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 29 Jul 2018 17:54:26 -0700 Subject: [PATCH 075/172] Update changelog for 2.0.0-beta1 release --- debian/changelog | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index c9081f7..d26bebb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,5 @@ -python-ev3dev2 (2.0.0) stable; urgency=medium +python-ev3dev2 (2.0.0~beta1) stable; urgency=medium - Initial release for ev3dev-stretch. + Initial beta release for ev3dev-stretch. - -- \ No newline at end of file + -- Kaelin Laundry Tue, 31 Jul 2018 20:37:00 -0700 From e2dd9a1180eed72e20a22f5f53744b06754b9048 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 1 Aug 2018 21:33:33 -0700 Subject: [PATCH 076/172] Update issue template for library V2 (#493) --- ISSUE_TEMPLATE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index 9c3ebdf..8e1d7f4 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -4,7 +4,7 @@ --> - **ev3dev version:** PASTE THE OUTPUT OF `uname -r` HERE -- **ev3dev-lang-python version:** +- **ev3dev-lang-python version:** INSERT ALL VERSIONS GIVEN BY `dpkg-query -l python3-ev3dev*` HERE - + From 59cd74aa6d0d18ab16cc37ed18d3d53593102762 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 31 Aug 2018 13:44:46 -0700 Subject: [PATCH 077/172] Improve speed integer and rename "speed_pct" to "speed" (#500) * SpeedInteger -> SpeedValue * strip out "speed_pct" and use native units everywhere internally Minimizes rounding. * Round rather than truncate; test for within minimal rounding error * no longer needs to be an "integer percentage" * Install evdev 1.0.0 on Travis as a workaround for build failure --- .travis.yml | 4 +- docs/motors.rst | 4 +- ev3dev2/motor.py | 242 ++++++++++++++++++++++++--------------------- tests/api_tests.py | 23 +++-- 4 files changed, 145 insertions(+), 128 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4235441..d447dcd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,8 +6,8 @@ sudo: false git: depth: 100 install: -- pip install -q Pillow evdev Sphinx sphinx_bootstrap_theme recommonmark evdev -- pip install -q -r ./docs/requirements.txt +- pip install Pillow Sphinx sphinx_bootstrap_theme recommonmark evdev==1.0.0 +- pip install -r ./docs/requirements.txt script: - chmod -R g+rw ./tests/fake-sys/devices/**/* - ./tests/api_tests.py diff --git a/docs/motors.rst b/docs/motors.rst index 514070b..6973d73 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -10,9 +10,9 @@ Motor classes Units ----- -Most methods which run motors with accept a ``speed`` or ``speed_pct`` argument. While this can be provided as an integer which will be interpreted as a percentage of max speed, you can also specify an instance of any of the following classes, each of which represents a different unit system: +Most methods which run motors will accept a ``speed`` argument. While this can be provided as an integer which will be interpreted as a percentage of max speed, you can also specify an instance of any of the following classes, each of which represents a different unit system: -.. autoclass:: SpeedInteger +.. autoclass:: SpeedValue .. autoclass:: SpeedPercent .. autoclass:: SpeedNativeUnits .. autoclass:: SpeedRPS diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index e2aa7f5..b39d00c 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -67,7 +67,7 @@ raise Exception("Unsupported platform '%s'" % platform) -class SpeedInteger(int): +class SpeedValue(): """ A base class for other unit types. Don't use this directly; instead, see :class:`SpeedPercent`, :class:`SpeedRPS`, :class:`SpeedRPM`, @@ -75,97 +75,118 @@ class SpeedInteger(int): """ pass -class SpeedPercent(SpeedInteger): +class SpeedPercent(SpeedValue): """ Speed as a percentage of the motor's maximum rated speed. """ + def __init__(self, percent): + assert -100 <= percent <= 100,\ + "{} is an invalid percentage, must be between -100 and 100 (inclusive)".format(percent) + + self.percent = percent + def __str__(self): - return int.__str__(self) + "%" + return str(self.percent) + "%" - def get_speed_pct(self, motor): + def to_native_units(self, motor): """ - Return the motor speed percentage represented by this SpeedPercent + Return this SpeedPercent in native motor units """ - return self + return self.percent / 100 * motor.max_speed -class SpeedNativeUnits(SpeedInteger): +class SpeedNativeUnits(SpeedValue): """ Speed in tacho counts per second. """ + def __init__(self, native_counts): + self.native_counts = native_counts + def __str__(self): - return int.__str__(self) + "% (counts/sec)" + return str(self.native_counts) + " counts/sec" - def get_speed_pct(self, motor): + def to_native_units(self, motor): """ - Return the motor speed percentage represented by this SpeedNativeUnits + Return this SpeedNativeUnits as a number """ - return self/motor.max_speed * 100 + return self.native_counts -class SpeedRPS(SpeedInteger): +class SpeedRPS(SpeedValue): """ Speed in rotations-per-second. """ + def __init__(self, rotations_per_second): + self.rotations_per_second = rotations_per_second + def __str__(self): - return int.__str__(self) + " rps" + return str(self.rotations_per_second) + " rot/sec" - def get_speed_pct(self, motor): + def to_native_units(self, motor): """ - Return the motor speed percentage to achieve desired rotations-per-second + Return the native speed measurement required to achieve desired rotations-per-second """ - assert self <= motor.max_rps, "{} max RPS is {}, {} was requested".format(motor, motor.max_rps, self) - return (self/motor.max_rps) * 100 + assert abs(self.rotations_per_second) <= motor.max_rps, "invalid rotations-per-second: {} max RPS is {}, {} was requested".format(motor, motor.max_rps, self.rotations_per_second) + return self.rotations_per_second/motor.max_rps * motor.max_speed -class SpeedRPM(SpeedInteger): +class SpeedRPM(SpeedValue): """ Speed in rotations-per-minute. """ + def __init__(self, rotations_per_minute): + self.rotations_per_minute = rotations_per_minute + def __str__(self): - return int.__str__(self) + " rpm" + return str(self) + " rot/min" - def get_speed_pct(self, motor): + def to_native_units(self, motor): """ - Return the motor speed percentage to achieve desired rotations-per-minute + Return the native speed measurement required to achieve desired rotations-per-minute """ - assert self <= motor.max_rpm, "{} max RPM is {}, {} was requested".format(motor, motor.max_rpm, self) - return (self/motor.max_rpm) * 100 + assert abs(self.rotations_per_minute) <= motor.max_rpm, "invalid rotations-per-minute: {} max RPM is {}, {} was requested".format(motor, motor.max_rpm, self.rotations_per_minute) + return self.rotations_per_minute/motor.max_rpm * motor.max_speed -class SpeedDPS(SpeedInteger): +class SpeedDPS(SpeedValue): """ Speed in degrees-per-second. """ + def __init__(self, degrees_per_second): + self.degrees_per_second = degrees_per_second + def __str__(self): - return int.__str__(self) + " dps" + return str(self) + " deg/sec" - def get_speed_pct(self, motor): + def to_native_units(self, motor): """ - Return the motor speed percentage to achieve desired degrees-per-second + Return the native speed measurement required to achieve desired degrees-per-second """ - assert self <= motor.max_dps, "{} max DPS is {}, {} was requested".format(motor, motor.max_dps, self) - return (self/motor.max_dps) * 100 + assert abs(self.degrees_per_second) <= motor.max_dps, "invalid degrees-per-second: {} max DPS is {}, {} was requested".format(motor, motor.max_dps, self.degrees_per_second) + return self.degrees_per_second/motor.max_dps * motor.max_speed -class SpeedDPM(SpeedInteger): +class SpeedDPM(SpeedValue): """ Speed in degrees-per-minute. """ + def __init__(self, degrees_per_minute): + self.degrees_per_minute = degrees_per_minute + def __str__(self): - return int.__str__(self) + " dpm" + return int.__str__(self) + " deg/min" - def get_speed_pct(self, motor): + def to_native_units(self, motor): """ - Return the motor speed percentage to achieve desired degrees-per-minute + Return the native speed measurement required to achieve desired degrees-per-minute """ - assert self <= motor.max_dpm, "{} max DPM is {}, {} was requested".format(motor, motor.max_dpm, self) - return (self/motor.max_dpm) * 100 + assert abs(self.degrees_per_minute) <= motor.max_dpm, "invalid degrees-per-minute: {} max DPM is {}, {} was requested".format(motor, motor.max_dpm, self.degrees_per_minute) + return self.degrees_per_minute/motor.max_dpm * motor.max_speed class Motor(Device): @@ -175,11 +196,6 @@ class Motor(Device): positional and directional feedback such as the EV3 and NXT motors. This feedback allows for precise control of the motors. This is the most common type of motor, so we just call it `motor`. - - The way to configure a motor is to set the '_sp' attributes when - calling a command or before. Only in 'run_direct' mode attribute - changes are processed immediately, in the other modes they only - take place when a new command is issued. """ SYSTEM_CLASS_NAME = 'tacho-motor' @@ -841,34 +857,32 @@ def wait_while(self, s, timeout=None): """ return self.wait(lambda state: s not in state, timeout) - def _speed_pct(self, speed_pct, label=None): - - # If speed_pct is SpeedInteger object we must convert - # SpeedRPS, etc to an actual speed percentage - if isinstance(speed_pct, SpeedInteger): - speed_pct = speed_pct.get_speed_pct(self) + def _speed_native_units(self, speed, label=None): - assert -100 <= speed_pct <= 100,\ - "{}{} is an invalid speed_pct, must be between -100 and 100 (inclusive)".format(None if label is None else (label + ": ") , speed_pct) + # If speed is not a SpeedValue object we treat it as a percentage + if not isinstance(speed, SpeedValue): + assert -100 <= speed <= 100,\ + "{}{} is an invalid speed percentage, must be between -100 and 100 (inclusive)".format("" if label is None else (label + ": ") , speed) + speed = SpeedPercent(speed) - return speed_pct + return speed.to_native_units(self) - def _set_position_rotations(self, speed_pct, rotations): + def _set_position_rotations(self, speed, rotations): # +/- speed is used to control direction, rotations must be positive assert rotations >= 0, "rotations is {}, must be >= 0".format(rotations) - if speed_pct > 0: - self.position_sp = self.position + int(rotations * self.count_per_rot) + if speed > 0: + self.position_sp = self.position + int(round(rotations * self.count_per_rot)) else: - self.position_sp = self.position - int(rotations * self.count_per_rot) + self.position_sp = self.position - int(round(rotations * self.count_per_rot)) - def _set_position_degrees(self, speed_pct, degrees): + def _set_position_degrees(self, speed, degrees): # +/- speed is used to control direction, degrees must be positive assert degrees >= 0, "degrees is %s, must be >= 0" % degrees - if speed_pct > 0: + if speed > 0: self.position_sp = self.position + int((degrees * self.count_per_rot)/360) else: self.position_sp = self.position - int((degrees * self.count_per_rot)/360) @@ -879,22 +893,22 @@ def _set_brake(self, brake): else: self.stop_action = self.STOP_ACTION_COAST - def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): + def on_for_rotations(self, speed, rotations, brake=True, block=True): """ - Rotate the motor at ``speed_pct`` for ``rotations`` + Rotate the motor at ``speed`` for ``rotations`` - ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed_pct = self._speed_pct(speed_pct) + speed = self._speed_native_units(speed) - if not speed_pct or not rotations: - log.warning("({}) Either speed_pct ({}) or rotations ({}) is invalid, motor will not move" .format(self, speed_pct, rotations)) + if not speed or not rotations: + log.warning("({}) Either speed ({}) or rotations ({}) is invalid, motor will not move" .format(self, speed, rotations)) self._set_brake(brake) return - self.speed_sp = int((speed_pct * self.max_speed) / 100) - self._set_position_rotations(speed_pct, rotations) + self.speed_sp = int(round(speed)) + self._set_position_rotations(speed, rotations) self._set_brake(brake) self.run_to_abs_pos() @@ -902,22 +916,22 @@ def on_for_rotations(self, speed_pct, rotations, brake=True, block=True): self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) self.wait_until_not_moving() - def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): + def on_for_degrees(self, speed, degrees, brake=True, block=True): """ - Rotate the motor at ``speed_pct`` for ``degrees`` + Rotate the motor at ``speed`` for ``degrees`` - ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed_pct = self._speed_pct(speed_pct) + speed = self._speed_native_units(speed) - if not speed_pct or not degrees: - log.warning("({}) Either speed_pct ({}) or degrees ({}) is invalid, motor will not move" .format(self, speed_pct, degrees)) + if not speed or not degrees: + log.warning("({}) Either speed ({}) or degrees ({}) is invalid, motor will not move".format(self, speed, degrees)) self._set_brake(brake) return - self.speed_sp = int((speed_pct * self.max_speed) / 100) - self._set_position_degrees(speed_pct, degrees) + self.speed_sp = int(round(speed)) + self._set_position_degrees(speed, degrees) self._set_brake(brake) self.run_to_abs_pos() @@ -925,21 +939,21 @@ def on_for_degrees(self, speed_pct, degrees, brake=True, block=True): self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) self.wait_until_not_moving() - def on_to_position(self, speed_pct, position, brake=True, block=True): + def on_to_position(self, speed, position, brake=True, block=True): """ - Rotate the motor at ``speed_pct`` to ``position`` + Rotate the motor at ``speed`` to ``position`` - ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed_pct = self._speed_pct(speed_pct) + speed = self._speed_native_units(speed) - if not speed_pct: - log.warning("({}) speed_pct is invalid ({}), motor will not move".format(self, speed_pct)) + if not speed: + log.warning("({}) speed is invalid ({}), motor will not move".format(self, speed)) self._set_brake(brake) return - self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.speed_sp = int(round(speed)) self.position_sp = position self._set_brake(brake) self.run_to_abs_pos() @@ -948,21 +962,21 @@ def on_to_position(self, speed_pct, position, brake=True, block=True): self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) self.wait_until_not_moving() - def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): + def on_for_seconds(self, speed, seconds, brake=True, block=True): """ - Rotate the motor at ``speed_pct`` for ``seconds`` + Rotate the motor at ``speed`` for ``seconds`` - ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed_pct = self._speed_pct(speed_pct) + speed = self._speed_native_units(speed) - if not speed_pct or not seconds: - log.warning("({}) Either speed_pct ({}) or seconds ({}) is invalid, motor will not move" .format(self, speed_pct, seconds)) + if not speed or not seconds: + log.warning("({}) Either speed ({}) or seconds ({}) is invalid, motor will not move" .format(self, speed, seconds)) self._set_brake(brake) return - self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.speed_sp = int(round(speed)) self.time_sp = int(seconds * 1000) self._set_brake(brake) self.run_timed() @@ -971,24 +985,24 @@ def on_for_seconds(self, speed_pct, seconds, brake=True, block=True): self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) self.wait_until_not_moving() - def on(self, speed_pct, brake=True, block=False): + def on(self, speed, brake=True, block=False): """ - Rotate the motor at ``speed_pct`` for forever + Rotate the motor at ``speed`` for forever - ``speed_pct`` can be an integer percentage or a :class:`ev3dev2.motor.SpeedInteger` + ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. Note that `block` is False by default, this is different from the other `on_for_XYZ` methods. """ - speed_pct = self._speed_pct(speed_pct) + speed = self._speed_native_units(speed) - if not speed_pct: - log.warning("({}) speed_pct is invalid ({}), motor will not move".format(self, speed_pct)) + if not speed: + log.warning("({}) speed is invalid ({}), motor will not move".format(self, speed)) self._set_brake(brake) return - self.speed_sp = int((speed_pct * self.max_speed) / 100) + self.speed_sp = int(round(speed)) self._set_brake(brake) self.run_forever() @@ -1732,21 +1746,21 @@ def _block(self): self.right_motor.wait_until_not_moving() def _unpack_speeds_to_native_units(self, left_speed, right_speed): - left_speed_pct = self.left_motor._speed_pct(left_speed, "left_speed") - right_speed_pct = self.right_motor._speed_pct(right_speed, "right_speed") + left_speed = self.left_motor._speed_native_units(left_speed, "left_speed") + right_speed = self.right_motor._speed_native_units(right_speed, "right_speed") - assert left_speed_pct or right_speed_pct,\ + assert left_speed or right_speed,\ "Either left_speed or right_speed must be non-zero" return ( - int((left_speed_pct * self.left_motor.max_speed) / 100), - int((right_speed_pct * self.right_motor.max_speed) / 100) + left_speed, + right_speed ) def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block=True): """ Rotate the motors at 'left_speed & right_speed' for 'rotations'. Speeds - can be integer percentages or any SpeedInteger implementation. + can be percentages or any SpeedValue implementation. If the left speed is not equal to the right speed (i.e., the robot will turn), the motor on the outside of the turn will rotate for the full @@ -1766,10 +1780,10 @@ def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block right_rotations = rotations # Set all parameters - self.left_motor.speed_sp = left_speed_native_units + self.left_motor.speed_sp = int(round(left_speed_native_units)) self.left_motor._set_position_rotations(left_speed_native_units, left_rotations) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed_native_units + self.right_motor.speed_sp = int(round(right_speed_native_units)) self.right_motor._set_position_rotations(right_speed_native_units, right_rotations) self.right_motor._set_brake(brake) @@ -1783,7 +1797,7 @@ def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=True): """ Rotate the motors at 'left_speed & right_speed' for 'degrees'. Speeds - can be integer percentages or any SpeedInteger implementation. + can be percentages or any SpeedValue implementation. If the left speed is not equal to the right speed (i.e., the robot will turn), the motor on the outside of the turn will rotate for the full @@ -1800,10 +1814,10 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru right_degrees = degrees # Set all parameters - self.left_motor.speed_sp = left_speed_native_units + self.left_motor.speed_sp = int(round(left_speed_native_units)) self.left_motor._set_position_degrees(left_speed_native_units, left_degrees) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed_native_units + self.right_motor.speed_sp = int(round(right_speed_native_units)) self.right_motor._set_position_degrees(right_speed_native_units, right_degrees) self.right_motor._set_brake(brake) @@ -1817,15 +1831,15 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=True): """ Rotate the motors at 'left_speed & right_speed' for 'seconds'. Speeds - can be integer percentages or any SpeedInteger implementation. + can be percentages or any SpeedValue implementation. """ (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # Set all parameters - self.left_motor.speed_sp = left_speed_native_units + self.left_motor.speed_sp = int(round(left_speed_native_units)) self.left_motor.time_sp = int(seconds * 1000) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = right_speed_native_units + self.right_motor.speed_sp = int(round(right_speed_native_units)) self.right_motor.time_sp = int(seconds * 1000) self.right_motor._set_brake(brake) @@ -1839,12 +1853,12 @@ def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=Tru def on(self, left_speed, right_speed): """ Start rotating the motors according to ``left_speed`` and ``right_speed`` forever. - Speeds can be integer percentages or any SpeedInteger implementation. + Speeds can be percentages or any SpeedValue implementation. """ (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) - self.left_motor.speed_sp = left_speed_native_units - self.right_motor.speed_sp = right_speed_native_units + self.left_motor.speed_sp = int(round(left_speed_native_units)) + self.right_motor.speed_sp = int(round(right_speed_native_units)) # Start the motors self.left_motor.run_forever() @@ -1936,9 +1950,9 @@ def get_speed_steering(self, steering, speed): # We don't have a good way to make this generic for the pair... so we # assume that the left motor's speed stats are the same as the right # motor's. - speed_pct = self.left_motor._speed_pct(speed) - left_speed = int((speed_pct * self.max_speed) / 100) - right_speed = left_speed + speed = self.left_motor._speed_native_units(speed) + left_speed = speed + right_speed = speed speed_factor = (50 - abs(float(steering))) / 50 if steering >= 0: @@ -1970,7 +1984,7 @@ def on(self, x, y, max_speed=100.0, radius=100.0): (0,0) representing the center position. X is horizontal and Y is vertical. max_speed (default 100%): - A percentage or other SpeedInteger, controlling the maximum motor speed. + A percentage or other SpeedValue, controlling the maximum motor speed. radius (default 100): The radius of the joystick, controlling the range of the input (x, y) values. @@ -2012,7 +2026,7 @@ def on(self, x, y, max_speed=100.0, radius=100.0): # init_left_speed_percentage, init_right_speed_percentage, # left_speed_percentage, right_speed_percentage)) - MoveTank.on(self, SpeedPercent(left_speed_percentage * self.left_motor._speed_pct(max_speed) / 100), SpeedPercent(right_speed_percentage * self.right_motor._speed_pct(max_speed) / 100)) + MoveTank.on(self, SpeedNativeUnits(left_speed_percentage / 100 * self.left_motor._speed_native_units(max_speed)), SpeedNativeUnits(right_speed_percentage / 100 * self.right_motor._speed_native_units(max_speed))) @staticmethod diff --git a/tests/api_tests.py b/tests/api_tests.py index 0f91999..c70fce1 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -36,6 +36,9 @@ def test_device(self): with self.assertRaises(ev3dev2.DeviceNotFound): d = ev3dev2.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') + with self.assertRaises(ev3dev2.DeviceNotFound): + d = ev3dev2.Device('tacho-motor', 'motor*', address='this-does-not-exist') + d = ev3dev2.Device('lego-sensor', 'sensor*') with self.assertRaises(ev3dev2.DeviceNotFound): @@ -113,8 +116,8 @@ def test_move_tank(self): self.assertEqual(drive.left_motor.speed_sp, 1050 / 2) self.assertEqual(drive.right_motor.position, 0) - self.assertAlmostEqual(drive.right_motor.position_sp, 5 * 360, delta=5) - self.assertAlmostEqual(drive.right_motor.speed_sp, 1050 / 4, delta=1) + self.assertEqual(drive.right_motor.position_sp, 5 * 360) + self.assertAlmostEqual(drive.right_motor.speed_sp, 1050 / 4, delta=0.5) def test_tank_units(self): clean_arena() @@ -128,8 +131,8 @@ def test_tank_units(self): self.assertEqual(drive.left_motor.speed_sp, 400) self.assertEqual(drive.right_motor.position, 0) - self.assertAlmostEqual(drive.right_motor.position_sp, 10 * 360 * ((10000 / 60) / 400), delta=7) - self.assertAlmostEqual(drive.right_motor.speed_sp, 10000 / 60, delta=1) + self.assertAlmostEqual(drive.right_motor.position_sp, 10 * 360 * ((10000 / 60) / 400)) + self.assertAlmostEqual(drive.right_motor.speed_sp, 10000 / 60, delta=0.5) def test_steering_units(self): clean_arena() @@ -162,12 +165,12 @@ def test_units(self): m = Motor() - self.assertEqual(SpeedPercent(35).get_speed_pct(m), 35) - self.assertEqual(SpeedDPS(300).get_speed_pct(m), 300 / 1050 * 100) - self.assertEqual(SpeedNativeUnits(300).get_speed_pct(m), 300 / 1050 * 100) - self.assertEqual(SpeedDPM(30000).get_speed_pct(m), (30000 / 60) / 1050 * 100) - self.assertEqual(SpeedRPS(2).get_speed_pct(m), 360 * 2 / 1050 * 100) - self.assertEqual(SpeedRPM(100).get_speed_pct(m), (360 * 100 / 60) / 1050 * 100) + self.assertEqual(SpeedPercent(35).to_native_units(m), 35 / 100 * m.max_speed) + self.assertEqual(SpeedDPS(300).to_native_units(m), 300) + self.assertEqual(SpeedNativeUnits(300).to_native_units(m), 300) + self.assertEqual(SpeedDPM(30000).to_native_units(m), (30000 / 60)) + self.assertEqual(SpeedRPS(2).to_native_units(m), 360 * 2) + self.assertEqual(SpeedRPM(100).to_native_units(m), (360 * 100 / 60)) if __name__ == "__main__": From b59dd403fa73a1a28299bf987fb700562fce5876 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 31 Aug 2018 13:46:43 -0700 Subject: [PATCH 078/172] Use Pillow manipulation logic for grey-to-RGB conversion on EV3 to improve perf (#497) --- ev3dev2/display.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/ev3dev2/display.py b/ev3dev2/display.py index f8c2643..79ac387 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -287,16 +287,6 @@ def _img_to_rgb565_bytes(self): pixels = [self._color565(r, g, b) for (r, g, b) in self._img.getdata()] return pack('H' * len(pixels), *pixels) - def _color_xrgb(self, v): - """Convert red, green, blue components to a 32-bit XRGB value. Components - should be values 0 to 255. - """ - return ((v << 16) | (v << 8) | v) - - def _img_to_xrgb_bytes(self): - pixels = [self._color_xrgb(v) for v in self._img.getdata()] - return pack('I' * len(pixels), *pixels) - def update(self): """ Applies pending changes to the screen. @@ -308,7 +298,7 @@ def update(self): elif self.var_info.bits_per_pixel == 16: self.mmap[:] = self._img_to_rgb565_bytes() elif self.platform == "ev3" and self.var_info.bits_per_pixel == 32: - self.mmap[:] = self._img_to_xrgb_bytes() + self.mmap[:] = self._img.convert("RGB").tobytes("raw", "XRGB") else: raise Exception("Not supported") From 686f35c62e5b81f050aeddc5b4f60c73066a2fe4 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Mon, 10 Sep 2018 20:10:54 -0700 Subject: [PATCH 079/172] Handle errors in attrbute getter; fix unit __str__ --- ev3dev2/__init__.py | 13 ++++++++----- ev3dev2/motor.py | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 4da4332..59cb6f7 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -216,11 +216,14 @@ def _attribute_file_open(self, name): def _get_attribute(self, attribute, name): """Device attribute getter""" - if attribute is None: - attribute = self._attribute_file_open( name ) - else: - attribute.seek(0) - return attribute, attribute.read().strip().decode() + try: + if attribute is None: + attribute = self._attribute_file_open( name ) + else: + attribute.seek(0) + return attribute, attribute.read().strip().decode() + except Exception as ex: + self._raise_friendly_access_error(ex, name) def _set_attribute(self, attribute, name, value): """Device attribute setter""" diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index b39d00c..650c19c 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -141,7 +141,7 @@ def __init__(self, rotations_per_minute): self.rotations_per_minute = rotations_per_minute def __str__(self): - return str(self) + " rot/min" + return str(self.rotations_per_minute) + " rot/min" def to_native_units(self, motor): """ @@ -160,7 +160,7 @@ def __init__(self, degrees_per_second): self.degrees_per_second = degrees_per_second def __str__(self): - return str(self) + " deg/sec" + return str(self.degrees_per_second) + " deg/sec" def to_native_units(self, motor): """ @@ -179,7 +179,7 @@ def __init__(self, degrees_per_minute): self.degrees_per_minute = degrees_per_minute def __str__(self): - return int.__str__(self) + " deg/min" + return str(self.degrees_per_minute) + " deg/min" def to_native_units(self, motor): """ From 3434e6d16ecf5b0689c4d7461787178e14440363 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 12 Sep 2018 16:52:09 -0700 Subject: [PATCH 080/172] Fix play_tone method (#513) --- ev3dev2/sound.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 6f4e125..6408001 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -219,17 +219,7 @@ def play_tone(self, frequency, duration, delay=0.0, volume=100, duration_ms = int(duration * 1000) delay_ms = int(delay * 1000) - if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: - play = self.tone([(frequency, duration_ms, delay_ms)]) - play.wait() - - elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: - return self.tone([(frequency, duration_ms, delay_ms)]) - - elif play_type == Sound.PLAY_LOOP: - while True: - play = self.tone([(frequency, duration_ms, delay_ms)]) - play.wait() + self.tone([(frequency, duration_ms, delay_ms)], play_type=play_type) def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Plays a note, given by its name as defined in ``_NOTE_FREQUENCIES``. From be77ea3bf4f4ac219ec03d5da4452fece9729746 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 12 Sep 2018 16:53:20 -0700 Subject: [PATCH 081/172] Optimistically check the mode of the sensor before setting it on property read (#502) Plus sensor docs improvements. --- ev3dev2/sensor/__init__.py | 19 ++--- ev3dev2/sensor/lego.py | 162 ++++++++++++++++++++++++++----------- tests/api_tests.py | 25 +++++- 3 files changed, 145 insertions(+), 61 deletions(-) diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index c2ff6ee..cb54fe2 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -62,19 +62,7 @@ class Sensor(Device): """ The sensor class provides a uniform interface for using most of the - sensors available for the EV3. The various underlying device drivers will - create a `lego-sensor` device for interacting with the sensors. - - Sensors are primarily controlled by setting the `mode` and monitored by - reading the `value` attributes. Values can be converted to floating point - if needed by `value` / 10.0 ^ `decimals`. - - Since the name of the `sensor` device node does not correspond to the port - that a sensor is plugged in to, you must look at the `address` attribute if - you need to know which port a sensor is plugged in to. However, if you don't - have more than one sensor of each type, you can just look for a matching - `driver_name`. Then it will not matter which port a sensor is plugged in to - your - program will still work. + sensors available for the EV3. """ SYSTEM_CLASS_NAME = 'lego-sensor' @@ -286,7 +274,10 @@ def bin_data(self, fmt=None): if fmt is None: return raw return unpack(fmt, raw) - + + def _ensure_mode(self, mode): + if self.mode != mode: + self.mode = mode def list_sensors(name_pattern=Sensor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): """ diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 781c748..5730347 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -54,7 +54,7 @@ def is_pressed(self): A boolean indicating whether the current touch sensor is being pressed. """ - self.mode = self.MODE_TOUCH + self._ensure_mode(self.MODE_TOUCH) return self.value(0) @property @@ -182,7 +182,7 @@ def reflected_light_intensity(self): """ Reflected light intensity as a percentage. Light on sensor is red. """ - self.mode = self.MODE_COL_REFLECT + self._ensure_mode(self.MODE_COL_REFLECT) return self.value(0) @property @@ -190,7 +190,7 @@ def ambient_light_intensity(self): """ Ambient light intensity. Light on sensor is dimly lit blue. """ - self.mode = self.MODE_COL_AMBIENT + self._ensure_mode(self.MODE_COL_AMBIENT) return self.value(0) @property @@ -206,7 +206,7 @@ def color(self): - 6: White - 7: Brown """ - self.mode = self.MODE_COL_COLOR + self._ensure_mode(self.MODE_COL_COLOR) return self.value(0) @property @@ -226,7 +226,7 @@ def raw(self): If this is an issue, check out the rgb() and calibrate_white() methods. """ - self.mode = self.MODE_RGB_RAW + self._ensure_mode(self.MODE_RGB_RAW) return self.value(0), self.value(1), self.value(2) def calibrate_white(self): @@ -384,7 +384,7 @@ def red(self): """ Red component of the detected color, in the range 0-1020. """ - self.mode = self.MODE_RGB_RAW + self._ensure_mode(self.MODE_RGB_RAW) return self.value(0) @property @@ -392,7 +392,7 @@ def green(self): """ Green component of the detected color, in the range 0-1020. """ - self.mode = self.MODE_RGB_RAW + self._ensure_mode(self.MODE_RGB_RAW) return self.value(1) @property @@ -400,7 +400,7 @@ def blue(self): """ Blue component of the detected color, in the range 0-1020. """ - self.mode = self.MODE_RGB_RAW + self._ensure_mode(self.MODE_RGB_RAW) return self.value(2) @@ -440,11 +440,35 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam @property def distance_centimeters_continuous(self): - self.mode = self.MODE_US_DIST_CM + """ + Measurement of the distance detected by the sensor, + in centimeters. + + The sensor will continue to take measurements so + they are available for future reads. + + Prefer using the equivalent :meth:`UltrasonicSensor.distance_centimeters` property. + """ + self._ensure_mode(self.MODE_US_DIST_CM) return self.value(0) * self._scale('US_DIST_CM') @property def distance_centimeters_ping(self): + """ + Measurement of the distance detected by the sensor, + in centimeters. + + The sensor will take a single measurement then stop + broadcasting. + + If you use this property too frequently (e.g. every + 100msec), the sensor will sometimes lock up and writing + to the mode attribute will return an error. A delay of + 250msec between each usage seems sufficient to keep the + sensor from locking up. + """ + # This mode is special; setting the mode causes the sensor to send out + # a "ping", but the mode isn't actually changed. self.mode = self.MODE_US_SI_CM return self.value(0) * self._scale('US_DIST_CM') @@ -453,16 +477,42 @@ def distance_centimeters(self): """ Measurement of the distance detected by the sensor, in centimeters. + + Equivalent to :meth:`UltrasonicSensor.distance_centimeters_continuous`. """ return self.distance_centimeters_continuous @property def distance_inches_continuous(self): - self.mode = self.MODE_US_DIST_IN + """ + Measurement of the distance detected by the sensor, + in inches. + + The sensor will continue to take measurements so + they are available for future reads. + + Prefer using the equivalent :meth:`UltrasonicSensor.distance_inches` property. + """ + self._ensure_mode(self.MODE_US_DIST_IN) return self.value(0) * self._scale('US_DIST_IN') @property def distance_inches_ping(self): + """ + Measurement of the distance detected by the sensor, + in inches. + + The sensor will take a single measurement then stop + broadcasting. + + If you use this property too frequently (e.g. every + 100msec), the sensor will sometimes lock up and writing + to the mode attribute will return an error. A delay of + 250msec between each usage seems sufficient to keep the + sensor from locking up. + """ + # This mode is special; setting the mode causes the sensor to send out + # a "ping", but the mode isn't actually changed. self.mode = self.MODE_US_SI_IN return self.value(0) * self._scale('US_DIST_IN') @@ -471,16 +521,18 @@ def distance_inches(self): """ Measurement of the distance detected by the sensor, in inches. + + Equivalent to :meth:`UltrasonicSensor.distance_inches_continuous`. """ return self.distance_inches_continuous @property def other_sensor_present(self): """ - Value indicating whether another ultrasonic sensor could + Boolean indicating whether another ultrasonic sensor could be heard nearby. """ - self.mode = self.MODE_US_LISTEN + self._ensure_mode(self.MODE_US_LISTEN) return bool(self.value(0)) @@ -533,7 +585,7 @@ def angle(self): The number of degrees that the sensor has been rotated since it was put into this mode. """ - self.mode = self.MODE_GYRO_ANG + self._ensure_mode(self.MODE_GYRO_ANG) return self.value(0) @property @@ -541,7 +593,7 @@ def rate(self): """ The rate at which the sensor is rotating, in degrees/second. """ - self.mode = self.MODE_GYRO_RATE + self._ensure_mode(self.MODE_GYRO_RATE) return self.value(0) @property @@ -549,21 +601,21 @@ def rate_and_angle(self): """ Angle (degrees) and Rotational Speed (degrees/second). """ - self.mode = self.MODE_GYRO_G_A + self._ensure_mode(self.MODE_GYRO_G_A) return self.value(0), self.value(1) @property def tilt_angle(self): - self.mode = self.MODE_TILT_ANG + self._ensure_mode(self.MODE_TILT_ANG) return self.value(0) @property def tilt_rate(self): - self.mode = self.MODE_TILT_RATE + self._ensure_mode(self.MODE_TILT_RATE) return self.value(0) def reset(self): - self.mode = self.MODE_GYRO_ANG + self._ensure_mode(self.MODE_GYRO_ANG) self._direct = self.set_attr_raw(self._direct, 'direct', 17) def wait_until_angle_changed_by(self, delta): @@ -644,33 +696,48 @@ class InfraredSensor(Sensor, ButtonBase): TOP_LEFT_BOTTOM_LEFT = 10 TOP_RIGHT_BOTTOM_RIGHT = 11 - # See process() for an explanation on how to use these - #: Handles ``Red Up``, etc events on channel 1 + #: Handler for top-left button events on channel 1. See :meth:`InfraredSensor.process`. on_channel1_top_left = None + #: Handler for bottom-left button events on channel 1. See :meth:`InfraredSensor.process`. on_channel1_bottom_left = None + #: Handler for top-right button events on channel 1. See :meth:`InfraredSensor.process`. on_channel1_top_right = None + #: Handler for bottom-right button events on channel 1. See :meth:`InfraredSensor.process`. on_channel1_bottom_right = None + #: Handler for beacon button events on channel 1. See :meth:`InfraredSensor.process`. on_channel1_beacon = None - #: Handles ``Red Up``, etc events on channel 2 + #: Handler for top-left button events on channel 2. See :meth:`InfraredSensor.process`. on_channel2_top_left = None + #: Handler for bottom-left button events on channel 2. See :meth:`InfraredSensor.process`. on_channel2_bottom_left = None + #: Handler for top-right button events on channel 2. See :meth:`InfraredSensor.process`. on_channel2_top_right = None + #: Handler for bottom-right button events on channel 2. See :meth:`InfraredSensor.process`. on_channel2_bottom_right = None + #: Handler for beacon button events on channel 2. See :meth:`InfraredSensor.process`. on_channel2_beacon = None - #: Handles ``Red Up``, etc events on channel 3 + #: Handler for top-left button events on channel 3. See :meth:`InfraredSensor.process`. on_channel3_top_left = None + #: Handler for bottom-left button events on channel 3. See :meth:`InfraredSensor.process`. on_channel3_bottom_left = None + #: Handler for top-right button events on channel 3. See :meth:`InfraredSensor.process`. on_channel3_top_right = None + #: Handler for bottom-right button events on channel 3. See :meth:`InfraredSensor.process`. on_channel3_bottom_right = None + #: Handler for beacon button events on channel 3. See :meth:`InfraredSensor.process`. on_channel3_beacon = None - #: Handles ``Red Up``, etc events on channel 4 + #: Handler for top-left button events on channel 4. See :meth:`InfraredSensor.process`. on_channel4_top_left = None + #: Handler for bottom-left button events on channel 4. See :meth:`InfraredSensor.process`. on_channel4_bottom_left = None + #: Handler for top-right button events on channel 4. See :meth:`InfraredSensor.process`. on_channel4_top_right = None + #: Handler for bottom-right button events on channel 4. See :meth:`InfraredSensor.process`. on_channel4_bottom_right = None + #: Handler for beacon button events on channel 4. See :meth:`InfraredSensor.process`. on_channel4_beacon = None def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -687,14 +754,14 @@ def proximity(self): A measurement of the distance between the sensor and the remote, as a percentage. 100% is approximately 70cm/27in. """ - self.mode = self.MODE_IR_PROX + self._ensure_mode(self.MODE_IR_PROX) return self.value(0) def heading(self, channel=1): """ Returns heading (-25, 25) to the beacon on the given channel. """ - self.mode = self.MODE_IR_SEEK + self._ensure_mode(self.MODE_IR_SEEK) channel = self._normalize_channel(channel) return self.value(channel * 2) @@ -703,7 +770,7 @@ def distance(self, channel=1): Returns distance (0, 100) to the beacon on the given channel. Returns None when beacon is not found. """ - self.mode = self.MODE_IR_SEEK + self._ensure_mode(self.MODE_IR_SEEK) channel = self._normalize_channel(channel) ret_value = self.value((channel * 2) + 1) @@ -719,31 +786,31 @@ def heading_and_distance(self, channel=1): def top_left(self, channel=1): """ - Checks if `top_left` button is pressed. + Checks if ``top_left`` button is pressed. """ return 'top_left' in self.buttons_pressed(channel) def bottom_left(self, channel=1): """ - Checks if `bottom_left` button is pressed. + Checks if ``bottom_left`` button is pressed. """ return 'bottom_left' in self.buttons_pressed(channel) def top_right(self, channel=1): """ - Checks if `top_right` button is pressed. + Checks if ``top_right`` button is pressed. """ return 'top_right' in self.buttons_pressed(channel) def bottom_right(self, channel=1): """ - Checks if `bottom_right` button is pressed. + Checks if ``bottom_right`` button is pressed. """ return 'bottom_right' in self.buttons_pressed(channel) def beacon(self, channel=1): """ - Checks if `beacon` button is pressed. + Checks if ``beacon`` button is pressed. """ return 'beacon' in self.buttons_pressed(channel) @@ -751,7 +818,7 @@ def buttons_pressed(self, channel=1): """ Returns list of currently pressed buttons. """ - self.mode = self.MODE_IR_REMOTE + self._ensure_mode(self.MODE_IR_REMOTE) channel = self._normalize_channel(channel) return self._BUTTON_VALUES.get(self.value(channel), []) @@ -761,20 +828,23 @@ def process(self): old state, call the appropriate button event handlers. To use the on_channel1_top_left, etc handlers your program would do something like: + + .. code:: python - def top_left_channel_1_action(state): - print("top left on channel 1: %s" % state) + def top_left_channel_1_action(state): + print("top left on channel 1: %s" % state) - def bottom_right_channel_4_action(state): - print("bottom right on channel 4: %s" % state) + def bottom_right_channel_4_action(state): + print("bottom right on channel 4: %s" % state) - ir = InfraredSensor() - ir.on_channel1_top_left = top_left_channel_1_action - ir.on_channel4_bottom_right = bottom_right_channel_4_action + ir = InfraredSensor() + ir.on_channel1_top_left = top_left_channel_1_action + ir.on_channel4_bottom_right = bottom_right_channel_4_action - while True: - ir.process() - time.sleep(0.01) + while True: + ir.process() + time.sleep(0.01) + """ new_state = [] state_diff = [] @@ -834,7 +904,7 @@ def sound_pressure(self): A measurement of the measured sound pressure level, as a percent. Uses a flat weighting. """ - self.mode = self.MODE_DB + self._ensure_mode(self.MODE_DB) return self.value(0) * self._scale('DB') @property @@ -843,7 +913,7 @@ def sound_pressure_low(self): A measurement of the measured sound pressure level, as a percent. Uses A-weighting, which focuses on levels up to 55 dB. """ - self.mode = self.MODE_DBA + self._ensure_mode(self.MODE_DBA) return self.value(0) * self._scale('DBA') @@ -874,7 +944,7 @@ def reflected_light_intensity(self): """ A measurement of the reflected light intensity, as a percentage. """ - self.mode = self.MODE_REFLECT + self._ensure_mode(self.MODE_REFLECT) return self.value(0) * self._scale('REFLECT') @property @@ -882,5 +952,5 @@ def ambient_light_intensity(self): """ A measurement of the ambient light intensity, as a percentage. """ - self.mode = self.MODE_AMBIENT + self._ensure_mode(self.MODE_AMBIENT) return self.value(0) * self._scale('AMBIENT') diff --git a/tests/api_tests.py b/tests/api_tests.py index c70fce1..3d3e593 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -15,6 +15,17 @@ ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') +_internal_set_attribute = ev3dev2.Device._set_attribute +def _set_attribute(self, attribute, name, value): + # After writing, clear the rest of the file to remove any residual text from + # the last write. On the real device we're writing to sysfs attributes where + # there isn't any persistent buffer, but in the test environment they're + # normal files on disk. + attribute = _internal_set_attribute(self, attribute, name, value) + attribute.truncate() + +ev3dev2.Device._set_attribute = _set_attribute + def dummy_wait(self, cond, timeout=None): pass @@ -93,6 +104,19 @@ def test_infrared_sensor(self): self.assertEqual(s.num_values, 1) self.assertEqual(s.address, 'in1') self.assertEqual(s.value(0), 16) + self.assertEqual(s.mode, "IR-PROX") + + s.mode = "IR-REMOTE" + self.assertEqual(s.mode, "IR-REMOTE") + + val = s.proximity + # Our test environment writes to actual files on disk, so while "seek(0) write(...)" works on the real device, it leaves trailing characters from previous writes in tests. "s.mode" returns "IR-PROXTE" here. + self.assertEqual(s.mode, "IR-PROX") + self.assertEqual(val, 16) + + val = s.buttons_pressed() + self.assertEqual(s.mode, "IR-REMOTE") + self.assertEqual(val, []) def test_medium_motor_write(self): clean_arena() @@ -172,6 +196,5 @@ def test_units(self): self.assertEqual(SpeedRPS(2).to_native_units(m), 360 * 2) self.assertEqual(SpeedRPM(100).to_native_units(m), (360 * 100 / 60)) - if __name__ == "__main__": unittest.main() From 86ba6c467345a6723fe28515515de7624e2ffec7 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 14 Sep 2018 18:37:23 -0700 Subject: [PATCH 082/172] Fix IR sensor and Sound class links in upgrading guide (#522) --- docs/upgrading-to-stretch.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/upgrading-to-stretch.md b/docs/upgrading-to-stretch.md index ef2366e..3e9ba82 100644 --- a/docs/upgrading-to-stretch.md +++ b/docs/upgrading-to-stretch.md @@ -37,11 +37,11 @@ To match the name used by LEGO's "EV3-G" graphical programming tools, we have re The `RemoteControl` and `BeaconSeeker` classes have been removed; you will now use `InfraredSensor` for all purposes. -Additionally, we have renamed many of the properties on the `InfraredSensor` class to make the meaning more obvious. Check out [the `InfraredSensor` documentation](docs/sensors#infrared-sensor) for more info. +Additionally, we have renamed many of the properties on the `InfraredSensor` class to make the meaning more obvious. Check out [the `InfraredSensor` documentation](sensors.html#infrared-sensor) for more info. ## Re-designed `Sound` class -The names and interfaces of some of the `Sound` class methods have changed. Check out [the `Sound` class docs](docs/other#sound) for details. +The names and interfaces of some of the `Sound` class methods have changed. Check out [the `Sound` class docs](other.html#sound) for details. # Once you've adapted to breaking changes, check out the cool new features! @@ -52,4 +52,4 @@ The names and interfaces of some of the `Sound` class methods have changed. Chec - Easier interactivity via buttons: each button now has ``wait_for_pressed``, ``wait_for_released`` and ``wait_for_bump`` - Improved :py:class:`ev3dev2.sound.Sound` and :py:class:`ev3dev2.display.Display` interfaces - New color conversion methods in :py:class:`ev3dev2.sensor.lego.ColorSensor` -``` \ No newline at end of file +``` From b5e658ac4ab8a5f488eca76e04ac1afe947efd2f Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Sep 2018 15:31:10 -0700 Subject: [PATCH 083/172] Flexible relative movement and improved parameter handling (#523) --- ev3dev2/motor.py | 112 +++++++++++----------------------- tests/api_tests.py | 146 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 83 deletions(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 650c19c..595b1bd 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -803,14 +803,14 @@ def wait(self, cond, timeout=None): self._poll.register(self._state, select.POLLPRI) while True: + if cond(self.state): + return True + self._poll.poll(None if timeout is None else timeout) if timeout is not None and time.time() >= tic + timeout / 1000: return False - if cond(self.state): - return True - def wait_until_not_moving(self, timeout=None): """ Blocks until ``running`` is not in ``self.state`` or ``stalled`` is in @@ -867,25 +867,15 @@ def _speed_native_units(self, speed, label=None): return speed.to_native_units(self) - def _set_position_rotations(self, speed, rotations): - - # +/- speed is used to control direction, rotations must be positive - assert rotations >= 0, "rotations is {}, must be >= 0".format(rotations) + def _set_rel_position_degrees_and_speed_sp(self, degrees, speed): + degrees = degrees if speed >= 0 else -degrees + speed = abs(speed) - if speed > 0: - self.position_sp = self.position + int(round(rotations * self.count_per_rot)) - else: - self.position_sp = self.position - int(round(rotations * self.count_per_rot)) - - def _set_position_degrees(self, speed, degrees): - - # +/- speed is used to control direction, degrees must be positive - assert degrees >= 0, "degrees is %s, must be >= 0" % degrees + position_delta = int(round((degrees * self.count_per_rot)/360)) + speed_sp = int(round(speed)) - if speed > 0: - self.position_sp = self.position + int((degrees * self.count_per_rot)/360) - else: - self.position_sp = self.position - int((degrees * self.count_per_rot)/360) + self.position_sp = position_delta + self.speed_sp = speed_sp def _set_brake(self, brake): if brake: @@ -900,17 +890,13 @@ def on_for_rotations(self, speed, rotations, brake=True, block=True): ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed = self._speed_native_units(speed) - - if not speed or not rotations: - log.warning("({}) Either speed ({}) or rotations ({}) is invalid, motor will not move" .format(self, speed, rotations)) - self._set_brake(brake) - return + if speed is None or rotations is None: + raise ValueError("Either speed ({}) or rotations ({}) is None".format(self, speed, rotations)) - self.speed_sp = int(round(speed)) - self._set_position_rotations(speed, rotations) + speed_sp = self._speed_native_units(speed) + self._set_rel_position_degrees_and_speed_sp(rotations * 360, speed_sp) self._set_brake(brake) - self.run_to_abs_pos() + self.run_to_rel_pos() if block: self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) @@ -923,17 +909,13 @@ def on_for_degrees(self, speed, degrees, brake=True, block=True): ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed = self._speed_native_units(speed) - - if not speed or not degrees: - log.warning("({}) Either speed ({}) or degrees ({}) is invalid, motor will not move".format(self, speed, degrees)) - self._set_brake(brake) - return + if speed is None or degrees is None: + raise ValueError("Either speed ({}) or degrees ({}) is None".format(self, speed, degrees)) - self.speed_sp = int(round(speed)) - self._set_position_degrees(speed, degrees) + speed_sp = self._speed_native_units(speed) + self._set_rel_position_degrees_and_speed_sp(degrees, speed_sp) self._set_brake(brake) - self.run_to_abs_pos() + self.run_to_rel_pos() if block: self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) @@ -1748,9 +1730,6 @@ def _block(self): def _unpack_speeds_to_native_units(self, left_speed, right_speed): left_speed = self.left_motor._speed_native_units(left_speed, "left_speed") right_speed = self.right_motor._speed_native_units(right_speed, "right_speed") - - assert left_speed or right_speed,\ - "Either left_speed or right_speed must be non-zero" return ( left_speed, @@ -1767,32 +1746,7 @@ def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block ``rotations`` while the motor on the inside will have its requested distance calculated according to the expected turn. """ - (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) - - # proof of the following distance calculation: consider the circle formed by each wheel's path - # v_l = d_l/t, v_r = d_r/t - # therefore, t = d_l/v_l = d_r/v_r - if left_speed_native_units > right_speed_native_units: - left_rotations = rotations - right_rotations = abs(float(right_speed_native_units / left_speed_native_units)) * rotations - else: - left_rotations = abs(float(left_speed_native_units / right_speed_native_units)) * rotations - right_rotations = rotations - - # Set all parameters - self.left_motor.speed_sp = int(round(left_speed_native_units)) - self.left_motor._set_position_rotations(left_speed_native_units, left_rotations) - self.left_motor._set_brake(brake) - self.right_motor.speed_sp = int(round(right_speed_native_units)) - self.right_motor._set_position_rotations(right_speed_native_units, right_rotations) - self.right_motor._set_brake(brake) - - # Start the motors - self.left_motor.run_to_abs_pos() - self.right_motor.run_to_abs_pos() - - if block: - self._block() + MoveTank.on_for_degrees(self, left_speed, right_speed, rotations * 360, brake, block) def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=True): """ @@ -1806,24 +1760,30 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru """ (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) - if left_speed_native_units > right_speed_native_units: + # proof of the following distance calculation: consider the circle formed by each wheel's path + # v_l = d_l/t, v_r = d_r/t + # therefore, t = d_l/v_l = d_r/v_r + + if degrees == 0 or (left_speed_native_units == 0 and right_speed_native_units == 0): + left_degrees = degrees + right_degrees = degrees + # larger speed by magnitude is the "outer" wheel, and rotates the full "degrees" + elif abs(left_speed_native_units) > abs(right_speed_native_units): left_degrees = degrees - right_degrees = float(right_speed / left_speed_native_units) * degrees + right_degrees = abs(right_speed_native_units / left_speed_native_units) * degrees else: - left_degrees = float(left_speed_native_units / right_speed_native_units) * degrees + left_degrees = abs(left_speed_native_units / right_speed_native_units) * degrees right_degrees = degrees # Set all parameters - self.left_motor.speed_sp = int(round(left_speed_native_units)) - self.left_motor._set_position_degrees(left_speed_native_units, left_degrees) + self.left_motor._set_rel_position_degrees_and_speed_sp(left_degrees, left_speed_native_units) self.left_motor._set_brake(brake) - self.right_motor.speed_sp = int(round(right_speed_native_units)) - self.right_motor._set_position_degrees(right_speed_native_units, right_degrees) + self.right_motor._set_rel_position_degrees_and_speed_sp(right_degrees, right_speed_native_units) self.right_motor._set_brake(brake) # Start the motors - self.left_motor.run_to_abs_pos() - self.right_motor.run_to_abs_pos() + self.left_motor.run_to_rel_pos() + self.right_motor.run_to_rel_pos() if block: self._block() diff --git a/tests/api_tests.py b/tests/api_tests.py index 3d3e593..6df2928 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -11,7 +11,11 @@ import ev3dev2 from ev3dev2.sensor.lego import InfraredSensor -from ev3dev2.motor import Motor, MediumMotor, MoveTank, MoveSteering, MoveJoystick, SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits, OUTPUT_A, OUTPUT_B +from ev3dev2.motor import \ + Motor, MediumMotor, LargeMotor, \ + MoveTank, MoveSteering, MoveJoystick, \ + SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits, \ + OUTPUT_A, OUTPUT_B ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') @@ -128,20 +132,133 @@ def test_medium_motor_write(self): m.speed_sp = 500 self.assertEqual(m.speed_sp, 500) - def test_move_tank(self): + def test_motor_on_for_degrees(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA')]) + + m = LargeMotor() + + # simple case + m.on_for_degrees(75, 100) + self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) + self.assertEqual(m.position_sp, 100) + + # various negative cases; values act like multiplication + m.on_for_degrees(-75, 100) + self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) + self.assertEqual(m.position_sp, -100) + + m.on_for_degrees(75, -100) + self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) + self.assertEqual(m.position_sp, -100) + + m.on_for_degrees(-75, -100) + self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) + self.assertEqual(m.position_sp, 100) + + # zero speed (on-device, this will return immediately due to reported stall) + m.on_for_degrees(0, 100) + self.assertEqual(m.speed_sp, 0) + self.assertEqual(m.position_sp, 100) + + # zero distance + m.on_for_degrees(75, 0) + self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) + self.assertEqual(m.position_sp, 0) + + # zero speed and distance + m.on_for_degrees(0, 0) + self.assertEqual(m.speed_sp, 0) + self.assertEqual(m.position_sp, 0) + + # None speed + with self.assertRaises(ValueError): + m.on_for_degrees(None, 100) + + # None distance + with self.assertRaises(ValueError): + m.on_for_degrees(75, None) + + def test_motor_on_for_rotations(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA')]) + + m = LargeMotor() + + # simple case + m.on_for_rotations(75, 5) + self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) + self.assertEqual(m.position_sp, 5 * 360) + + # None speed + with self.assertRaises(ValueError): + m.on_for_rotations(None, 5) + + # None distance + with self.assertRaises(ValueError): + m.on_for_rotations(75, None) + + def test_move_tank_relative_distance(self): clean_arena() populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) drive = MoveTank(OUTPUT_A, OUTPUT_B) + + # simple case (degrees) + drive.on_for_degrees(50, 25, 100) + self.assertEqual(drive.left_motor.position_sp, 100) + self.assertEqual(drive.left_motor.speed_sp, 0.50 * 1050) + self.assertEqual(drive.right_motor.position_sp, 50) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0.25 * 1050, delta=0.5) + + # simple case (rotations, based on degrees) drive.on_for_rotations(50, 25, 10) + self.assertEqual(drive.left_motor.position_sp, 10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 0.50 * 1050) + self.assertEqual(drive.right_motor.position_sp, 5 * 360) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0.25 * 1050, delta=0.5) + + # negative distance + drive.on_for_rotations(50, 25, -10) + self.assertEqual(drive.left_motor.position_sp, -10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 0.50 * 1050) + self.assertEqual(drive.right_motor.position_sp, -5 * 360) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0.25 * 1050, delta=0.5) + + # negative speed + drive.on_for_rotations(-50, 25, 10) + self.assertEqual(drive.left_motor.position_sp, -10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 0.50 * 1050) + self.assertEqual(drive.right_motor.position_sp, 5 * 360) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0.25 * 1050, delta=0.5) - self.assertEqual(drive.left_motor.position, 0) + # negative distance and speed + drive.on_for_rotations(-50, 25, -10) self.assertEqual(drive.left_motor.position_sp, 10 * 360) - self.assertEqual(drive.left_motor.speed_sp, 1050 / 2) + self.assertEqual(drive.left_motor.speed_sp, 0.50 * 1050) + self.assertEqual(drive.right_motor.position_sp, -5 * 360) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0.25 * 1050, delta=0.5) - self.assertEqual(drive.right_motor.position, 0) - self.assertEqual(drive.right_motor.position_sp, 5 * 360) - self.assertAlmostEqual(drive.right_motor.speed_sp, 1050 / 4, delta=0.5) + # both speeds zero but nonzero distance + drive.on_for_rotations(0, 0, 10) + self.assertEqual(drive.left_motor.position_sp, 10 * 360) + self.assertAlmostEqual(drive.left_motor.speed_sp, 0) + self.assertEqual(drive.right_motor.position_sp, 10 * 360) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0) + + # zero distance + drive.on_for_rotations(25, 50, 0) + self.assertEqual(drive.left_motor.position_sp, 0) + self.assertAlmostEqual(drive.left_motor.speed_sp, 0.25 * 1050, delta=0.5) + self.assertEqual(drive.right_motor.position_sp, 0) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0.50 * 1050) + + # zero distance and zero speed + drive.on_for_rotations(0, 0, 0) + self.assertEqual(drive.left_motor.position_sp, 0) + self.assertAlmostEqual(drive.left_motor.speed_sp, 0) + self.assertEqual(drive.right_motor.position_sp, 0) + self.assertAlmostEqual(drive.right_motor.speed_sp, 0) def test_tank_units(self): clean_arena() @@ -172,6 +289,21 @@ def test_steering_units(self): self.assertEqual(drive.right_motor.position, 0) self.assertEqual(drive.right_motor.position_sp, 5 * 360) self.assertEqual(drive.right_motor.speed_sp, 200) + + def test_steering_large_value(self): + clean_arena() + populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) + + drive = MoveSteering(OUTPUT_A, OUTPUT_B) + drive.on_for_rotations(-100, SpeedDPS(400), 10) + + self.assertEqual(drive.left_motor.position, 0) + self.assertEqual(drive.left_motor.position_sp, -10 * 360) + self.assertEqual(drive.left_motor.speed_sp, 400) + + self.assertEqual(drive.right_motor.position, 0) + self.assertEqual(drive.right_motor.position_sp, 10 * 360) + self.assertEqual(drive.right_motor.speed_sp, 400) def test_joystick_units(self): clean_arena() From bbd9631e91d7f3c23a735bc0ffa5537e58a2e2fe Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Sep 2018 15:48:37 -0700 Subject: [PATCH 084/172] Micropython support (#514) Motors and sensors now support Micropython. CI server updated to run both CPython and Micropython builds. --- .travis.yml | 21 +++++++++++++++------ .travis/install-micropython.sh | 25 +++++++++++++++++++++++++ ev3dev2/__init__.py | 34 +++++++++++++++++++++++----------- ev3dev2/motor.py | 6 +++--- ev3dev2/sensor/__init__.py | 5 +---- tests/api_tests.py | 22 ++++++++++++++-------- tests/fake-sys | 2 +- 7 files changed, 82 insertions(+), 33 deletions(-) create mode 100755 .travis/install-micropython.sh diff --git a/.travis.yml b/.travis.yml index d447dcd..ae40735 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,24 @@ language: python +env: + - USE_MICROPYTHON=false + - USE_MICROPYTHON=true python: - 3.4 -cache: pip +cache: + pip: true + directories: + - ~/micropython + - ~/.micropython sudo: false git: depth: 100 install: -- pip install Pillow Sphinx sphinx_bootstrap_theme recommonmark evdev==1.0.0 -- pip install -r ./docs/requirements.txt +- if [ $USE_MICROPYTHON = false ]; then pip install Pillow Sphinx sphinx_bootstrap_theme recommonmark evdev==1.0.0 && pip install -r ./docs/requirements.txt; fi +- if [ $USE_MICROPYTHON = true ]; then ./.travis/install-micropython.sh && PYTHON=~/micropython/ports/unix/micropython; else PYTHON=python3; fi script: - chmod -R g+rw ./tests/fake-sys/devices/**/* -- ./tests/api_tests.py -- sphinx-build -nW -b html ./docs/ ./docs/_build/html +- $PYTHON tests/api_tests.py +- if [ $USE_MICROPYTHON = false ]; then sphinx-build -nW -b html ./docs/ ./docs/_build/html; fi deploy: provider: pypi user: Denis.Demidov @@ -19,5 +26,7 @@ deploy: secure: cTDN4WMJEjxZ0zwFwkLPpOIOSG/1JlHbQsHzd/tV9LfjBMR0nJR5HrmyiIO1TE5Wo6AFSFd7S3JmKdEnFO1ByvN/EFtsKGRUR9L6Yst4KfFi4nHwdZ7vgPTV0nNdvgC1YM/3bx8W8PcjFSm/k8awx7aicwXj09yDA8ENTf5XedaX22Z+9OKhb1mKola1cqEoc0GwaYzd8UX0Ruwh9/6RRbvTt7zn8BCZc9vIVqNd6mZgBWY9zAU40ZZsjYORbiZmDNCygEI+RViZ51M58WYkPPhoivzcG9em8DMRS7SC3CjWGapiOaXUHa3Fhnn+IQ+Q8Xv9YU5+qmj65MWQy3SSnMnvuxmrLf4aLoOlSJUMhDarjni4tdBOTX5PdOkdmhskyQt1DqDrw0+WhPItYfGe5zQfQwqW+YOpGbOipAeU+844arPo5jvZG/IOBX2qVUwdSxo8Y/98ocjqoZOq8b5xkWtJef0Kh1RCkp1bR2XELQVe76qeWqQxWz3OPqq+wK3xeNvj5kMQmytl3dCEB//D6UcES7Qr8YxD+LWoaIf32JIj/4LaCXXuxMVH+PJ68Oc72/ox0qLmXK0qhbea2QvaqXyGDrO+a2X6VbMdH32D2xHzH4Mg75xLnXnSaFvGovhYl1zEVcYUDioxrXZEuDmymGf9nH2mivJ24Fon6u+C3QQ= on: tags: true - condition: $TRAVIS_TAG != ev3dev-stretch/* + condition: + - $TRAVIS_TAG != ev3dev-stretch/* + - $USE_MICROPYTHON = false repo: ev3dev/ev3dev-lang-python diff --git a/.travis/install-micropython.sh b/.travis/install-micropython.sh new file mode 100755 index 0000000..28e5a82 --- /dev/null +++ b/.travis/install-micropython.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -ex + +if [ -e ~/micropython/ports/unix/micropython ]; then + # the micropython binary already exists, which means that the cached folder was restored + exit 0 +fi + +cd ~/ + +# Build micropython from source +# TODO: cache micropython build output +git clone --recurse-submodules https://github.com/micropython/micropython.git --depth 1 --branch v1.9.4 --quiet +cd ./micropython/ports/unix +make axtls +make + +# Install upip +~/micropython/tools/bootstrap_upip.sh + +# Install micropython library modules +~/micropython/ports/unix/micropython -m upip install micropython-unittest micropython-os micropython-os.path micropython-shutil micropython-io micropython-fnmatch micropython-numbers micropython-struct micropython-time micropython-logging micropython-select +# Make unittest module show error output; will run until failure then print first error +# See https://github.com/micropython/micropython-lib/blob/f20d89c6aad9443a696561ca2a01f7ef0c8fb302/unittest/unittest.py#L203 +sed -i 's/#raise/raise/g' ~/.micropython/lib/unittest.py \ No newline at end of file diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 59cb6f7..5d79932 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -28,6 +28,15 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') +def is_micropython(): + return sys.implementation.name == "micropython" + +def chain_exception(exception, cause): + if is_micropython(): + raise exception + else: + raise exception from cause + import os import io import fnmatch @@ -188,7 +197,8 @@ def get_index(file): except StopIteration: self._path = None self._device_index = None - raise DeviceNotFound("%s is not connected." % self) from None + + chain_exception(DeviceNotFound("%s is not connected." % self), None) def __str__(self): if 'address' in self.kwargs: @@ -206,13 +216,13 @@ def _attribute_file_open(self, name): w_ok = mode & stat.S_IWGRP if r_ok and w_ok: - mode = 'r+' + mode_str = 'r+' elif w_ok: - mode = 'w' + mode_str = 'w' else: - mode = 'r' + mode_str = 'r' - return io.FileIO(path, mode) + return io.FileIO(path, mode_str) def _get_attribute(self, attribute, name): """Device attribute getter""" @@ -245,20 +255,22 @@ def _raise_friendly_access_error(self, driver_error, attribute): if not isinstance(driver_error, OSError): raise driver_error - if driver_error.errno == errno.EINVAL: + driver_errorno = driver_error.args[0] if is_micropython() else driver_error.errno + + if driver_errorno == errno.EINVAL: if attribute == "speed_sp": try: max_speed = self.max_speed except (AttributeError, Exception): - raise ValueError("The given speed value was out of range") from driver_error + chain_exception(ValueError("The given speed value was out of range"), driver_error) else: - raise ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)) from driver_error - raise ValueError("One or more arguments were out of range or invalid") from driver_error - elif driver_error.errno == errno.ENODEV or driver_error.errno == errno.ENOENT: + chain_exception(ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)), driver_error) + chain_exception(ValueError("One or more arguments were out of range or invalid"), driver_error) + elif driver_errorno == errno.ENODEV or driver_errorno == errno.ENOENT: # We will assume that a file-not-found error is the result of a disconnected device # rather than a library error. If that isn't the case, at a minimum the underlying # error info will be printed for debugging. - raise DeviceNotFound("%s is no longer connected" % self) from driver_error + chain_exception(DeviceNotFound("%s is no longer connected" % self), driver_error) raise driver_error def get_attr_int(self, attribute, name): diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 595b1bd..32aec8c 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -31,7 +31,7 @@ import select import time from logging import getLogger -from math import atan2, degrees as math_degrees, hypot +from math import atan2, degrees as math_degrees, sqrt from os.path import abspath from ev3dev2 import get_current_platform, Device, list_device_names @@ -1905,7 +1905,7 @@ def get_speed_steering(self, steering, speed): """ assert steering >= -100 and steering <= 100,\ - "%{} is an invalid steering, must be between -100 and 100 (inclusive)".format(steering) + "{} is an invalid steering, must be between -100 and 100 (inclusive)".format(steering) # We don't have a good way to make this generic for the pair... so we # assume that the left motor's speed stats are the same as the right @@ -1957,7 +1957,7 @@ def on(self, x, y, max_speed=100.0, radius=100.0): MoveTank.off() return - vector_length = hypot(x, y) + vector_length = sqrt(x*x + y*y) angle = math_degrees(atan2(y, x)) if angle < 0: diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index cb54fe2..c3b9d32 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -211,10 +211,7 @@ def value(self, n=0): an error. The values are fixed point numbers, so check decimals to see if you need to divide to get the actual value. """ - if isinstance(n, numbers.Real): - n = int(n) - elif isinstance(n, str): - n = int(n) + n = int(n) self._value[n], value = self.get_attr_int(self._value[n], 'value'+str(n)) return value diff --git a/tests/api_tests.py b/tests/api_tests.py index 6df2928..66709d6 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import unittest, sys, os +import unittest, sys, os.path FAKE_SYS = os.path.join(os.path.dirname(__file__), 'fake-sys') @@ -21,15 +21,22 @@ _internal_set_attribute = ev3dev2.Device._set_attribute def _set_attribute(self, attribute, name, value): - # After writing, clear the rest of the file to remove any residual text from - # the last write. On the real device we're writing to sysfs attributes where - # there isn't any persistent buffer, but in the test environment they're - # normal files on disk. + # Follow the text with a newline to separate new content from stuff that + # already existed in the buffer. On the real device we're writing to sysfs + # attributes where there isn't any persistent buffer, but in the test + # environment they're normal files on disk which retain previous data. attribute = _internal_set_attribute(self, attribute, name, value) - attribute.truncate() - + attribute.write(b'\n') + return attribute ev3dev2.Device._set_attribute = _set_attribute +_internal_get_attribute = ev3dev2.Device._get_attribute +def _get_attribute(self, attribute, name): + # Split on newline delimiter; see _set_attribute above + attribute, value = _internal_get_attribute(self, attribute, name) + return attribute, value.split('\n', 1)[0] +ev3dev2.Device._get_attribute = _get_attribute + def dummy_wait(self, cond, timeout=None): pass @@ -114,7 +121,6 @@ def test_infrared_sensor(self): self.assertEqual(s.mode, "IR-REMOTE") val = s.proximity - # Our test environment writes to actual files on disk, so while "seek(0) write(...)" works on the real device, it leaves trailing characters from previous writes in tests. "s.mode" returns "IR-PROXTE" here. self.assertEqual(s.mode, "IR-PROX") self.assertEqual(val, 16) diff --git a/tests/fake-sys b/tests/fake-sys index 13904dc..2629188 160000 --- a/tests/fake-sys +++ b/tests/fake-sys @@ -1 +1 @@ -Subproject commit 13904dc7d0555857d4a0a85a59252e9a0812cfd6 +Subproject commit 2629188b90e83a086f55b067d16099c5783a2c03 From 49b48e8ec468952099e5d0ebf172350af370cae3 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Sep 2018 16:05:47 -0700 Subject: [PATCH 085/172] Update changelog for v2.0.0-beta2 --- debian/changelog | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/debian/changelog b/debian/changelog index d26bebb..570427f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,19 @@ +python-ev3dev2 (2.0.0~beta2) stable; urgency=medium + + * Mitigate performance regression when using Display.update() on the EV3 + * Fix erroneous values in InfraredSensor and other sensor classes caused by + rapid mode reset when using new properties + * Improve performance of reading sensor values when using new properties + * Rename SpeedInteger to SpeedValue + * Support floating-point values in SpeedValue + * Rename "speed_pct" parameters to "speed" + * Fix error when calling Sound.play_tone + * Support negative and zero speed/distance combinations in on_for_* methods + for both single motors and motor pairs + * Experimental support for motors and sensors on Micropython + + -- Kaelin Laundry Sun, 23 Sep 2018 16:03:00 -0700 + python-ev3dev2 (2.0.0~beta1) stable; urgency=medium Initial beta release for ev3dev-stretch. From 517f6e642dbd19c32f9d6ecbd65f0ef3c947bfaa Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 30 Sep 2018 11:29:30 -0700 Subject: [PATCH 086/172] Minor docs wording improvements (#530) --- ev3dev2/motor.py | 5 +++-- ev3dev2/sensor/lego.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 32aec8c..30c8a48 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1837,7 +1837,7 @@ def off(self, brake=True): class MoveSteering(MoveTank): """ - Controls a pair of motors simultaneously, via a single "steering" value. + Controls a pair of motors simultaneously, via a single "steering" value and a speed. steering [-100, 100]: * -100 means turn left on the spot (right motor at 100% forward, left motor at 100% backward), @@ -1881,7 +1881,8 @@ def on_for_seconds(self, steering, speed, seconds, brake=True, block=True): def on(self, steering, speed): """ - Start rotating the motors according to the provided ``steering`` forever. + Start rotating the motors according to the provided ``steering`` and + ``speed`` forever. """ (left_speed, right_speed) = self.get_speed_steering(steering, speed) MoveTank.on(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed)) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 5730347..4c37da9 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -180,7 +180,7 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam @property def reflected_light_intensity(self): """ - Reflected light intensity as a percentage. Light on sensor is red. + Reflected light intensity as a percentage (0 to 100). Light on sensor is red. """ self._ensure_mode(self.MODE_COL_REFLECT) return self.value(0) @@ -188,7 +188,7 @@ def reflected_light_intensity(self): @property def ambient_light_intensity(self): """ - Ambient light intensity. Light on sensor is dimly lit blue. + Ambient light intensity, as a percentage (0 to 100). Light on sensor is dimly lit blue. """ self._ensure_mode(self.MODE_COL_AMBIENT) return self.value(0) @@ -219,10 +219,12 @@ def color_name(self): @property def raw(self): """ - Red, green, and blue components of the detected color, officially in the - range 0-1020 but the values returned will never be that high. We do not - yet know why the values returned are low, but pointing the color sensor - at a well lit sheet of white paper will return values in the 250-400 range. + Red, green, and blue components of the detected color, as a tuple. + + Officially in the range 0-1020 but the values returned will never be + that high. We do not yet know why the values returned are low, but + pointing the color sensor at a well lit sheet of white paper will return + values in the 250-400 range. If this is an issue, check out the rgb() and calibrate_white() methods. """ From 43df355d6857b5e7766159ddb6b1a11d08ec8bb1 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 30 Sep 2018 11:44:17 -0700 Subject: [PATCH 087/172] Fix evdev name for buttons on EV3 (#529) Also added missing docstrings on button and touch sensor. --- ev3dev2/_platform/ev3.py | 2 +- ev3dev2/button.py | 6 ++++++ ev3dev2/sensor/lego.py | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ev3dev2/_platform/ev3.py b/ev3dev2/_platform/ev3.py index 863d4f4..f186a4d 100644 --- a/ev3dev2/_platform/ev3.py +++ b/ev3dev2/_platform/ev3.py @@ -39,7 +39,7 @@ INPUT_4 = 'ev3-ports:in4' BUTTONS_FILENAME = '/dev/input/by-path/platform-gpio_keys-event' -EVDEV_DEVICE_NAME = 'EV3 brick buttons' +EVDEV_DEVICE_NAME = 'EV3 Brick Buttons' LEDS = OrderedDict() LEDS['red_left'] = 'led0:red:brick-status' diff --git a/ev3dev2/button.py b/ev3dev2/button.py index 74b660c..01ada00 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -188,9 +188,15 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): return False def wait_for_pressed(self, buttons, timeout_ms=None): + """ + Wait for the button to be pressed down. + """ return self._wait(buttons, [], timeout_ms) def wait_for_released(self, buttons, timeout_ms=None): + """ + Wait for the button to be released. + """ return self._wait([], buttons, timeout_ms) def wait_for_bump(self, buttons, timeout_ms=None): diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 4c37da9..1432eae 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -81,9 +81,15 @@ def _wait(self, wait_for_press, timeout_ms, sleep_ms): time.sleep(sleep_ms) def wait_for_pressed(self, timeout_ms=None, sleep_ms=10): + """ + Wait for the touch sensor to be pressed down. + """ return self._wait(True, timeout_ms, sleep_ms) def wait_for_released(self, timeout_ms=None, sleep_ms=10): + """ + Wait for the touch sensor to be released. + """ return self._wait(False, timeout_ms, sleep_ms) def wait_for_bump(self, timeout_ms=None, sleep_ms=10): From 60d598c7565bb9a3f50f58f57c368aa9d8a70ba2 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 3 Oct 2018 22:16:49 -0700 Subject: [PATCH 088/172] Add note about remote two-button limit --- ev3dev2/sensor/lego.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 1432eae..bd7a8a0 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -825,6 +825,8 @@ def beacon(self, channel=1): def buttons_pressed(self, channel=1): """ Returns list of currently pressed buttons. + + Note that the sensor can only identify up to two buttons pressed at once. """ self._ensure_mode(self.MODE_IR_REMOTE) channel = self._normalize_channel(channel) From fb7b79d0c19bbc1c0a86788155db83d66b62336e Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sat, 27 Oct 2018 21:23:20 -0400 Subject: [PATCH 089/172] Rename rate_and_angle to angle_and_rate (#538) * Rename rate_and_angle to angle_and_rate --- debian/changelog | 6 ++++++ ev3dev2/sensor/lego.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index 570427f..6279bf0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + + * renamed GyroSensor rate_and_angle to angle_and_rate + + -- Kaelin Laundry Sat, 27 Oct 2018 21:18:00 -0700 + python-ev3dev2 (2.0.0~beta2) stable; urgency=medium * Mitigate performance regression when using Display.update() on the EV3 diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index bd7a8a0..0f9f98d 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -605,7 +605,7 @@ def rate(self): return self.value(0) @property - def rate_and_angle(self): + def angle_and_rate(self): """ Angle (degrees) and Rotational Speed (degrees/second). """ From ae97053ee8ba5be0c219c9865500c065e930517d Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sat, 27 Oct 2018 21:33:58 -0400 Subject: [PATCH 090/172] MoveJoyStick should use SpeedPercent (#537) --- debian/changelog | 1 + ev3dev2/control/webserver.py | 2 +- ev3dev2/motor.py | 11 ++++------- tests/api_tests.py | 6 ++++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/debian/changelog b/debian/changelog index 6279bf0..3730377 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * MoveJoyStick should use SpeedPercent * renamed GyroSensor rate_and_angle to angle_and_rate -- Kaelin Laundry Sat, 27 Oct 2018 21:18:00 -0700 diff --git a/ev3dev2/control/webserver.py b/ev3dev2/control/webserver.py index b9996db..36142bc 100644 --- a/ev3dev2/control/webserver.py +++ b/ev3dev2/control/webserver.py @@ -209,7 +209,7 @@ def do_GET(self): if joystick_engaged: if seq > max_move_xy_seq: - self.robot.on(x, y, motor_max_speed) + self.robot.on(x, y) max_move_xy_seq = seq log.debug("seq %d: (x, y) (%4d, %4d)" % (seq, x, y)) else: diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 30c8a48..da7afbd 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1929,7 +1929,7 @@ class MoveJoystick(MoveTank): Used to control a pair of motors via a single joystick vector. """ - def on(self, x, y, max_speed=100.0, radius=100.0): + def on(self, x, y, radius=100.0): """ Convert x,y joystick coordinates to left/right motor speed percentages and move the motors. @@ -1944,13 +1944,9 @@ def on(self, x, y, max_speed=100.0, radius=100.0): The X and Y coordinates of the joystick's position, with (0,0) representing the center position. X is horizontal and Y is vertical. - max_speed (default 100%): - A percentage or other SpeedValue, controlling the maximum motor speed. - radius (default 100): The radius of the joystick, controlling the range of the input (x, y) values. e.g. if "x" and "y" can be between -1 and 1, radius should be set to "1". - """ # If joystick is in the middle stop the tank @@ -1987,8 +1983,9 @@ def on(self, x, y, max_speed=100.0, radius=100.0): # init_left_speed_percentage, init_right_speed_percentage, # left_speed_percentage, right_speed_percentage)) - MoveTank.on(self, SpeedNativeUnits(left_speed_percentage / 100 * self.left_motor._speed_native_units(max_speed)), SpeedNativeUnits(right_speed_percentage / 100 * self.right_motor._speed_native_units(max_speed))) - + MoveTank.on(self, + SpeedPercent(left_speed_percentage), + SpeedPercent(right_speed_percentage)) @staticmethod def angle_to_speed_percentage(angle): diff --git a/tests/api_tests.py b/tests/api_tests.py index 66709d6..597230a 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -316,10 +316,12 @@ def test_joystick_units(self): populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) drive = MoveJoystick(OUTPUT_A, OUTPUT_B) - drive.on(100, 100, max_speed=SpeedPercent(50)) + # With the joystick at (x, y) of (0, 50) we should drive straigh ahead + # at 50% of max_speed + drive.on(0, 50) self.assertEqual(drive.left_motor.speed_sp, 1050 / 2) - self.assertAlmostEqual(drive.right_motor.speed_sp, 0) + self.assertEqual(drive.right_motor.speed_sp, 1050 / 2) def test_units(self): clean_arena() From efe9a7398d666f46fda4a6c887bd8e03dfde130f Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 6 Nov 2018 23:20:04 +0000 Subject: [PATCH 091/172] brickpi(3) raise exception if LargeMotor not used for a motor (#549) * brickpi(3) raise exception if LargeMotor not used for a motor * brickpi(3) raise exception if LargeMotor not used for a motor --- debian/changelog | 1 + ev3dev2/motor.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/debian/changelog b/debian/changelog index 3730377..4f3ae69 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * brickpi(3) raise exception if LargeMotor not used for a motor * MoveJoyStick should use SpeedPercent * renamed GyroSensor rate_and_angle to angle_and_rate diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index da7afbd..2650eba 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -311,6 +311,9 @@ class Motor(Device): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): + if platform in ('brickpi', 'brickpi3') and not isinstance(self, LargeMotor): + raise Exception("{} is unaware of different motor types, use LargeMotor instead".format(platform)) + if address is not None: kwargs['address'] = address super(Motor, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) From 95ccb36053db5099dab9a47f9b7cf3b5d7f4e3ea Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 7 Nov 2018 11:04:31 +0000 Subject: [PATCH 092/172] wait_until_not_moving should consider 'running holding' as 'not moving' (#548) --- debian/changelog | 1 + ev3dev2/motor.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/debian/changelog b/debian/changelog index 4f3ae69..371c527 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * wait_until_not_moving should consider "running holding" as "not moving" * brickpi(3) raise exception if LargeMotor not used for a motor * MoveJoyStick should use SpeedPercent * renamed GyroSensor rate_and_angle to angle_and_rate diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 2650eba..3b9a331 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -816,10 +816,13 @@ def wait(self, cond, timeout=None): def wait_until_not_moving(self, timeout=None): """ - Blocks until ``running`` is not in ``self.state`` or ``stalled`` is in - ``self.state``. The condition is checked when there is an I/O event - related to the ``state`` attribute. Exits early when ``timeout`` - (in milliseconds) is reached. + Blocks until one of the following conditions are met: + - ``running`` is not in ``self.state`` + - ``stalled`` is in ``self.state`` + - ``holding`` is in ``self.state`` + The condition is checked when there is an I/O event related to + the ``state`` attribute. Exits early when ``timeout`` (in + milliseconds) is reached. Returns ``True`` if the condition is met, and ``False`` if the timeout is reached. @@ -828,7 +831,9 @@ def wait_until_not_moving(self, timeout=None): m.wait_until_not_moving() """ - return self.wait(lambda state: self.STATE_RUNNING not in state or self.STATE_STALLED in state, timeout) + return self.wait( + lambda state: self.STATE_RUNNING not in state or self.STATE_STALLED in state or self.STATE_HOLDING in state, + timeout) def wait_until(self, s, timeout=None): """ From 245d8ae74f3c24ddcf852fb068a363b294c3d2e4 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 7 Nov 2018 11:06:57 +0000 Subject: [PATCH 093/172] Added DistanceValue classes (#550) --- debian/changelog | 1 + ev3dev2/motor.py | 3 - ev3dev2/unit.py | 210 +++++++++++++++++++++++++++++++++++++++++++++ tests/api_tests.py | 22 +++++ 4 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 ev3dev2/unit.py diff --git a/debian/changelog b/debian/changelog index 371c527..3fad644 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * Added DistanceValue classes * wait_until_not_moving should consider "running holding" as "not moving" * brickpi(3) raise exception if LargeMotor not used for a motor * MoveJoyStick should use SpeedPercent diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 3b9a331..1d82932 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1,8 +1,5 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2015 Ralph Hempel -# Copyright (c) 2015 Anton Vanhoucke -# Copyright (c) 2015 Denis Demidov -# Copyright (c) 2015 Eric Pascual # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/ev3dev2/unit.py b/ev3dev2/unit.py new file mode 100644 index 0000000..2a15abb --- /dev/null +++ b/ev3dev2/unit.py @@ -0,0 +1,210 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2015 Ralph Hempel +# +# 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 sys + +if sys.version_info < (3,4): + raise SystemError('Must be using Python 3.4 or higher') + +CENTIMETER_MM = 10 +DECIMETER_MM = 100 +METER_MM = 1000 +INCH_MM = 25.4 +FOOT_MM = 304.8 +YARD_MM = 914.4 +STUD_MM = 8 + + +class DistanceValue(object): + """ + A base class for other unit types. Don't use this directly; instead, see + :class:`DistanceMillimeters`, :class:`DistanceCentimeters`, :class:`DistanceDecimeters`, :class:`DistanceMeters`, + :class:`DistanceInches`, :class:`DistanceFeet`, :class:`DistanceYards` and :class:`DistanceStuds`. + """ + + # This allows us to sort lists of DistanceValue objects + def __lt__(self, other): + return self.mm < other.mm + + def __rmul__(self, other): + return self.__mul__(other) + + +class DistanceMillimeters(DistanceValue): + """ + Distance in millimeters + """ + + def __init__(self, millimeters): + self.millimeters = millimeters + + def __str__(self): + return str(self.millimeters) + "mm" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceMillimeters(self.millimeters * other) + + @property + def mm(self): + return self.millimeters + + +class DistanceCentimeters(DistanceValue): + """ + Distance in centimeters + """ + + def __init__(self, centimeters): + self.centimeters = centimeters + + def __str__(self): + return str(self.centimeters) + "cm" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceCentimeters(self.centimeters * other) + + @property + def mm(self): + return self.centimeters * CENTIMETER_MM + + +class DistanceDecimeters(DistanceValue): + """ + Distance in decimeters + """ + + def __init__(self, decimeters): + self.decimeters = decimeters + + def __str__(self): + return str(self.decimeters) + "dm" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceDecimeters(self.decimeters * other) + + @property + def mm(self): + return self.decimeters * DECIMETER_MM + + +class DistanceMeters(DistanceValue): + """ + Distance in meters + """ + + def __init__(self, meters): + self.meters = meters + + def __str__(self): + return str(self.meters) + "m" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceMeters(self.meters * other) + + @property + def mm(self): + return self.meters * METER_MM + + +class DistanceInches(DistanceValue): + """ + Distance in inches + """ + + def __init__(self, inches): + self.inches = inches + + def __str__(self): + return str(self.inches) + "in" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceInches(self.inches * other) + + @property + def mm(self): + return self.inches * INCH_MM + + +class DistanceFeet(DistanceValue): + """ + Distance in feet + """ + + def __init__(self, feet): + self.feet = feet + + def __str__(self): + return str(self.feet) + "ft" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceFeet(self.feet * other) + + @property + def mm(self): + return self.feet * FOOT_MM + + +class DistanceYards(DistanceValue): + """ + Distance in yards + """ + + def __init__(self, yards): + self.yards = yards + + def __str__(self): + return str(self.yards) + "yd" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceYards(self.yards * other) + + @property + def mm(self): + return self.yards * YARD_MM + + +class DistanceStuds(DistanceValue): + """ + Distance in studs + """ + + def __init__(self, studs): + self.studs = studs + + def __str__(self): + return str(self.studs) + "stud" + + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return DistanceStuds(self.studs * other) + + @property + def mm(self): + return self.studs * STUD_MM diff --git a/tests/api_tests.py b/tests/api_tests.py index 597230a..5d8f018 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -16,6 +16,16 @@ MoveTank, MoveSteering, MoveJoystick, \ SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits, \ OUTPUT_A, OUTPUT_B +from ev3dev2.unit import ( + DistanceMillimeters, + DistanceCentimeters, + DistanceDecimeters, + DistanceMeters, + DistanceInches, + DistanceFeet, + DistanceYards, + DistanceStuds +) ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') @@ -336,5 +346,17 @@ def test_units(self): self.assertEqual(SpeedRPS(2).to_native_units(m), 360 * 2) self.assertEqual(SpeedRPM(100).to_native_units(m), (360 * 100 / 60)) + self.assertEqual(DistanceMillimeters(42).mm, 42) + self.assertEqual(DistanceCentimeters(42).mm, 420) + self.assertEqual(DistanceDecimeters(42).mm, 4200) + self.assertEqual(DistanceMeters(42).mm, 42000) + + self.assertAlmostEqual(DistanceInches(42).mm, 1066.8) + self.assertAlmostEqual(DistanceFeet(42).mm, 12801.6) + self.assertAlmostEqual(DistanceYards(42).mm, 38404.8) + + self.assertEqual(DistanceStuds(42).mm, 336) + + if __name__ == "__main__": unittest.main() From 9c952776442ffbc1d7d4b6c77f6eb367f7551837 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 7 Nov 2018 11:14:06 +0000 Subject: [PATCH 094/172] suppress unittest ResourceWarning messages (#556) Resolves #433 and #554 --- .travis.yml | 3 ++- debian/changelog | 1 + tests/README.md | 15 ++++----------- tests/api_tests.py | 16 ++++++++++++++-- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index ae40735..6935367 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,8 @@ install: - if [ $USE_MICROPYTHON = true ]; then ./.travis/install-micropython.sh && PYTHON=~/micropython/ports/unix/micropython; else PYTHON=python3; fi script: - chmod -R g+rw ./tests/fake-sys/devices/**/* -- $PYTHON tests/api_tests.py +- if [ $USE_MICROPYTHON = false ]; then $PYTHON -W ignore tests/api_tests.py; fi +- if [ $USE_MICROPYTHON = true ]; then $PYTHON tests/api_tests.py; fi - if [ $USE_MICROPYTHON = false ]; then sphinx-build -nW -b html ./docs/ ./docs/_build/html; fi deploy: provider: pypi diff --git a/debian/changelog b/debian/changelog index 3fad644..3e60eb3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * suppress unittest ResourceWarning messages * Added DistanceValue classes * wait_until_not_moving should consider "running holding" as "not moving" * brickpi(3) raise exception if LargeMotor not used for a motor diff --git a/tests/README.md b/tests/README.md index 0c7adf4..400e363 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,20 +8,13 @@ already cloned the ev3dev-lang-python repo you can use the `--recursive` option when you git clone. Example: ``` -$ git clone --recursive https://github.com/rhempel/ev3dev-lang-python.git +$ git clone --recursive https://github.com/ev3dev/ev3dev-lang-python.git ``` # Running Tests To run the tests do: ``` -$ ./api_tests.py -``` - -# Misc -Commands used to copy the /sys/class node: - -``` -$ node=lego-sensor/sensor0 -$ mkdir -p ./${node} -$ cp -P --copy-contents -r /sys/class/${node}/* ./${node}/ +$ cd ev3dev-lang-python/ +$ chmod -R g+rw ./tests/fake-sys/devices/**/* +$ python3 -W ignore ./tests/api_tests.py ``` diff --git a/tests/api_tests.py b/tests/api_tests.py index 5d8f018..778e2b1 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -14,8 +14,8 @@ from ev3dev2.motor import \ Motor, MediumMotor, LargeMotor, \ MoveTank, MoveSteering, MoveJoystick, \ - SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits, \ - OUTPUT_A, OUTPUT_B + SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits + from ev3dev2.unit import ( DistanceMillimeters, DistanceCentimeters, @@ -27,6 +27,10 @@ DistanceStuds ) +# We force OUTPUT_A and OUTPUT_B to be imported from platform "fake" so that +# we can run these test cases on an EV3, brickpi3, etc. +from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B + ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') _internal_set_attribute = ev3dev2.Device._set_attribute @@ -53,6 +57,14 @@ def dummy_wait(self, cond, timeout=None): Motor.wait = dummy_wait class TestAPI(unittest.TestCase): + + def setUp(self): + # micropython does not have _testMethodName + try: + print("\n\n{}\n{}".format(self._testMethodName, "=" * len(self._testMethodName,))) + except AttributeError: + pass + def test_device(self): clean_arena() populate_arena([('medium_motor', 0, 'outA'), ('infrared_sensor', 0, 'in1')]) From 3a5054ec3539a3cdb40f838aec2ca4cacb5751d5 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 7 Nov 2018 11:15:43 +0000 Subject: [PATCH 095/172] motor: remove speed 0, degrees 0, etc checks (#553) Resolves issue #544 --- ev3dev2/motor.py | 30 +++++++----------------------- tests/api_tests.py | 16 ---------------- 2 files changed, 7 insertions(+), 39 deletions(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 1d82932..d2fdb09 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -895,9 +895,6 @@ def on_for_rotations(self, speed, rotations, brake=True, block=True): ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - if speed is None or rotations is None: - raise ValueError("Either speed ({}) or rotations ({}) is None".format(self, speed, rotations)) - speed_sp = self._speed_native_units(speed) self._set_rel_position_degrees_and_speed_sp(rotations * 360, speed_sp) self._set_brake(brake) @@ -914,9 +911,6 @@ def on_for_degrees(self, speed, degrees, brake=True, block=True): ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - if speed is None or degrees is None: - raise ValueError("Either speed ({}) or degrees ({}) is None".format(self, speed, degrees)) - speed_sp = self._speed_native_units(speed) self._set_rel_position_degrees_and_speed_sp(degrees, speed_sp) self._set_brake(brake) @@ -934,12 +928,6 @@ def on_to_position(self, speed, position, brake=True, block=True): object, enabling use of other units. """ speed = self._speed_native_units(speed) - - if not speed: - log.warning("({}) speed is invalid ({}), motor will not move".format(self, speed)) - self._set_brake(brake) - return - self.speed_sp = int(round(speed)) self.position_sp = position self._set_brake(brake) @@ -956,13 +944,11 @@ def on_for_seconds(self, speed, seconds, brake=True, block=True): ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. """ - speed = self._speed_native_units(speed) - if not speed or not seconds: - log.warning("({}) Either speed ({}) or seconds ({}) is invalid, motor will not move" .format(self, speed, seconds)) - self._set_brake(brake) - return + if seconds < 0: + raise ValueError("seconds is negative ({})".format(seconds)) + speed = self._speed_native_units(speed) self.speed_sp = int(round(speed)) self.time_sp = int(seconds * 1000) self._set_brake(brake) @@ -983,12 +969,6 @@ def on(self, speed, brake=True, block=False): other `on_for_XYZ` methods. """ speed = self._speed_native_units(speed) - - if not speed: - log.warning("({}) speed is invalid ({}), motor will not move".format(self, speed)) - self._set_brake(brake) - return - self.speed_sp = int(round(speed)) self._set_brake(brake) self.run_forever() @@ -1798,6 +1778,10 @@ def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=Tru Rotate the motors at 'left_speed & right_speed' for 'seconds'. Speeds can be percentages or any SpeedValue implementation. """ + + if seconds < 0: + raise ValueError("seconds is negative ({})".format(seconds)) + (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # Set all parameters diff --git a/tests/api_tests.py b/tests/api_tests.py index 778e2b1..431a6e2 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -199,14 +199,6 @@ def test_motor_on_for_degrees(self): self.assertEqual(m.speed_sp, 0) self.assertEqual(m.position_sp, 0) - # None speed - with self.assertRaises(ValueError): - m.on_for_degrees(None, 100) - - # None distance - with self.assertRaises(ValueError): - m.on_for_degrees(75, None) - def test_motor_on_for_rotations(self): clean_arena() populate_arena([('large_motor', 0, 'outA')]) @@ -218,14 +210,6 @@ def test_motor_on_for_rotations(self): self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) self.assertEqual(m.position_sp, 5 * 360) - # None speed - with self.assertRaises(ValueError): - m.on_for_rotations(None, 5) - - # None distance - with self.assertRaises(ValueError): - m.on_for_rotations(75, None) - def test_move_tank_relative_distance(self): clean_arena() populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) From f3fe81e8a0cd6dc60e651dd53a4c7ef16913e455 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 7 Nov 2018 11:20:43 +0000 Subject: [PATCH 096/172] Sound: removed play() in favor of play_file() (#552) Resolves issue #532 --- debian/changelog | 1 + ev3dev2/sound.py | 36 ++++++++++++++++-------------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/debian/changelog b/debian/changelog index 3e60eb3..bdeafca 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * Sound: removed play() in favor of play_file() * suppress unittest ResourceWarning messages * Added DistanceValue classes * wait_until_not_moving should consider "running holding" as "not moving" diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 6408001..fbe0fd1 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -56,7 +56,7 @@ class Sound(object): Examples:: # Play 'bark.wav': - Sound.play('bark.wav') + Sound.play_file('bark.wav') # Introduce yourself: Sound.speak('Hello, I am Robot') @@ -98,7 +98,7 @@ def beep(self, args='', play_type=PLAY_WAIT_FOR_COMPLETE): See `beep man page`_ and google `linux beep music`_ for inspiration. :param string args: Any additional arguments to be passed to ``beep`` (see the `beep man page`_ for details) - + :param play_type: The behavior of ``beep`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` @@ -247,16 +247,27 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE return self.play_tone(freq, duration=duration, volume=volume, play_type=play_type) - def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): - """ Play a sound file (wav format). + def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): + """ Play a sound file (wav format) at a given volume. :param string wav_file: The sound file path + :param int volume: The play volume, in percent of maximum volume - :param play_type: The behavior of ``play`` once playback has been initiated + :param play_type: The behavior of ``play_file`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ + if not 0 < volume <= 100: + raise ValueError('invalid volume (%s)' % volume) + + if not wav_file.endswith(".wav"): + raise ValueError('invalid sound file (%s), only .wav files are supported' % wav_file) + + if not os.path.isfile(wav_file): + raise ValueError("%s does not exist" % wav_file) + + self.set_volume(volume) self._validate_play_type(play_type) with open(os.devnull, 'w') as n: @@ -274,21 +285,6 @@ def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE): pid = Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) pid.wait() - def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): - """ Play a sound file (wav format) at a given volume. - - - :param string wav_file: The sound file path - :param int volume: The play volume, in percent of maximum volume - - :param play_type: The behavior of ``play_file`` once playback has been initiated - :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - - :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise - """ - self.set_volume(volume) - self.play(wav_file, play_type) - def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Speak the given text aloud. From dede0b9e627498f1d54b2106a024b109acd1dff4 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 7 Nov 2018 11:42:05 +0000 Subject: [PATCH 097/172] MoveDifferential (#536) Resolves issue #392 --- debian/changelog | 1 + docs/other.rst | 35 ++++ ev3dev2/__init__.py | 4 + ev3dev2/motor.py | 375 +++++++++++++++++++++++++++++++------ ev3dev2/wheel.py | 78 ++++++++ tests/api_tests.py | 16 +- utils/move_differential.py | 45 +++++ 7 files changed, 492 insertions(+), 62 deletions(-) create mode 100644 ev3dev2/wheel.py create mode 100755 utils/move_differential.py diff --git a/debian/changelog b/debian/changelog index bdeafca..dec2462 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * Added MoveDifferential * Sound: removed play() in favor of play_file() * suppress unittest ResourceWarning messages * Added DistanceValue classes diff --git a/docs/other.rst b/docs/other.rst index c74a0a7..295781d 100644 --- a/docs/other.rst +++ b/docs/other.rst @@ -141,3 +141,38 @@ Lego Port .. autoclass:: ev3dev2.port.LegoPort :members: + +Wheels +------ +All Wheel class units are in millimeters. The diameter and width for various lego wheels can be found at http://wheels.sariel.pl/ + +.. autoclass:: ev3dev2.wheel.Wheel + :members: + +EV3 Rim +~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3Rim + :members: + :show-inheritance: + +EV3 Tire +~~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3Tire + :members: + :show-inheritance: + +EV3 Education Set Rim +~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3EducationSetRim + :members: + :show-inheritance: + +EV3 Education Set Tire +~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3EducationSetTire + :members: + :show-inheritance: diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 5d79932..fe367c8 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -209,6 +209,10 @@ def __str__(self): def __repr__(self): return self.__str__() + # This allows us to sort lists of Device objects + def __lt__(self, other): + return str(self) < str(other) + def _attribute_file_open(self, name): path = os.path.join(self._path, name) mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE]) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index d2fdb09..b87e8e0 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -27,8 +27,16 @@ import select import time + +# python3 uses collections +# micropython uses ucollections +try: + from collections import OrderedDict +except ImportError: + from ucollections import OrderedDict + from logging import getLogger -from math import atan2, degrees as math_degrees, sqrt +from math import atan2, degrees as math_degrees, sqrt, pi from os.path import abspath from ev3dev2 import get_current_platform, Device, list_device_names @@ -64,13 +72,19 @@ raise Exception("Unsupported platform '%s'" % platform) -class SpeedValue(): +class SpeedValue(object): """ A base class for other unit types. Don't use this directly; instead, see :class:`SpeedPercent`, :class:`SpeedRPS`, :class:`SpeedRPM`, :class:`SpeedDPS`, and :class:`SpeedDPM`. """ - pass + + def __lt__(self, other): + return self.to_native_units() < other.to_native_units() + + def __rmul__(self, other): + return self.__mul__(other) + class SpeedPercent(SpeedValue): """ @@ -80,12 +94,16 @@ class SpeedPercent(SpeedValue): def __init__(self, percent): assert -100 <= percent <= 100,\ "{} is an invalid percentage, must be between -100 and 100 (inclusive)".format(percent) - + self.percent = percent def __str__(self): return str(self.percent) + "%" + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return SpeedPercent(self.percent * other) + def to_native_units(self, motor): """ Return this SpeedPercent in native motor units @@ -100,16 +118,21 @@ class SpeedNativeUnits(SpeedValue): def __init__(self, native_counts): self.native_counts = native_counts - + def __str__(self): return str(self.native_counts) + " counts/sec" + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return SpeedNativeUnits(self.native_counts * other) + def to_native_units(self, motor): """ Return this SpeedNativeUnits as a number """ return self.native_counts + class SpeedRPS(SpeedValue): """ Speed in rotations-per-second. @@ -121,11 +144,17 @@ def __init__(self, rotations_per_second): def __str__(self): return str(self.rotations_per_second) + " rot/sec" + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return SpeedRPS(self.rotations_per_second * other) + def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired rotations-per-second """ - assert abs(self.rotations_per_second) <= motor.max_rps, "invalid rotations-per-second: {} max RPS is {}, {} was requested".format(motor, motor.max_rps, self.rotations_per_second) + assert abs(self.rotations_per_second) <= motor.max_rps,\ + "invalid rotations-per-second: {} max RPS is {}, {} was requested".format( + motor, motor.max_rps, self.rotations_per_second) return self.rotations_per_second/motor.max_rps * motor.max_speed @@ -136,15 +165,21 @@ class SpeedRPM(SpeedValue): def __init__(self, rotations_per_minute): self.rotations_per_minute = rotations_per_minute - + def __str__(self): return str(self.rotations_per_minute) + " rot/min" + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return SpeedRPM(self.rotations_per_minute * other) + def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired rotations-per-minute """ - assert abs(self.rotations_per_minute) <= motor.max_rpm, "invalid rotations-per-minute: {} max RPM is {}, {} was requested".format(motor, motor.max_rpm, self.rotations_per_minute) + assert abs(self.rotations_per_minute) <= motor.max_rpm,\ + "invalid rotations-per-minute: {} max RPM is {}, {} was requested".format( + motor, motor.max_rpm, self.rotations_per_minute) return self.rotations_per_minute/motor.max_rpm * motor.max_speed @@ -155,15 +190,21 @@ class SpeedDPS(SpeedValue): def __init__(self, degrees_per_second): self.degrees_per_second = degrees_per_second - + def __str__(self): return str(self.degrees_per_second) + " deg/sec" + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return SpeedDPS(self.degrees_per_second * other) + def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired degrees-per-second """ - assert abs(self.degrees_per_second) <= motor.max_dps, "invalid degrees-per-second: {} max DPS is {}, {} was requested".format(motor, motor.max_dps, self.degrees_per_second) + assert abs(self.degrees_per_second) <= motor.max_dps,\ + "invalid degrees-per-second: {} max DPS is {}, {} was requested".format( + motor, motor.max_dps, self.degrees_per_second) return self.degrees_per_second/motor.max_dps * motor.max_speed @@ -178,11 +219,17 @@ def __init__(self, degrees_per_minute): def __str__(self): return str(self.degrees_per_minute) + " deg/min" + def __mul__(self, other): + assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) + return SpeedDPM(self.degrees_per_minute * other) + def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired degrees-per-minute """ - assert abs(self.degrees_per_minute) <= motor.max_dpm, "invalid degrees-per-minute: {} max DPM is {}, {} was requested".format(motor, motor.max_dpm, self.degrees_per_minute) + assert abs(self.degrees_per_minute) <= motor.max_dpm,\ + "invalid degrees-per-minute: {} max DPM is {}, {} was requested".format( + motor, motor.max_dpm, self.degrees_per_minute) return self.degrees_per_minute/motor.max_dpm * motor.max_speed @@ -697,14 +744,16 @@ def time_sp(self, value): self._time_sp = self.set_attr_int(self._time_sp, 'time_sp', value) def run_forever(self, **kwargs): - """Run the motor until another command is sent. + """ + Run the motor until another command is sent. """ for key in kwargs: setattr(self, key, kwargs[key]) self.command = self.COMMAND_RUN_FOREVER def run_to_abs_pos(self, **kwargs): - """Run to an absolute position specified by `position_sp` and then + """ + Run to an absolute position specified by `position_sp` and then stop using the action specified in `stop_action`. """ for key in kwargs: @@ -712,7 +761,8 @@ def run_to_abs_pos(self, **kwargs): self.command = self.COMMAND_RUN_TO_ABS_POS def run_to_rel_pos(self, **kwargs): - """Run to a position relative to the current `position` value. + """ + Run to a position relative to the current `position` value. The new position will be current `position` + `position_sp`. When the new position is reached, the motor will stop using the action specified by `stop_action`. @@ -722,7 +772,8 @@ def run_to_rel_pos(self, **kwargs): self.command = self.COMMAND_RUN_TO_REL_POS def run_timed(self, **kwargs): - """Run the motor for the amount of time specified in `time_sp` + """ + Run the motor for the amount of time specified in `time_sp` and then stop the motor using the action specified by `stop_action`. """ for key in kwargs: @@ -730,7 +781,8 @@ def run_timed(self, **kwargs): self.command = self.COMMAND_RUN_TIMED def run_direct(self, **kwargs): - """Run the motor at the duty cycle specified by `duty_cycle_sp`. + """ + Run the motor at the duty cycle specified by `duty_cycle_sp`. Unlike other run commands, changing `duty_cycle_sp` while running *will* take effect immediately. """ @@ -739,7 +791,8 @@ def run_direct(self, **kwargs): self.command = self.COMMAND_RUN_DIRECT def stop(self, **kwargs): - """Stop any of the run commands before they are complete using the + """ + Stop any of the run commands before they are complete using the action specified by `stop_action`. """ for key in kwargs: @@ -747,7 +800,8 @@ def stop(self, **kwargs): self.command = self.COMMAND_STOP def reset(self, **kwargs): - """Reset all of the motor parameter attributes to their default value. + """ + Reset all of the motor parameter attributes to their default value. This will also have the effect of stopping the motor. """ for key in kwargs: @@ -756,31 +810,36 @@ def reset(self, **kwargs): @property def is_running(self): - """Power is being sent to the motor. + """ + Power is being sent to the motor. """ return self.STATE_RUNNING in self.state @property def is_ramping(self): - """The motor is ramping up or down and has not yet reached a constant output level. + """ + The motor is ramping up or down and has not yet reached a constant output level. """ return self.STATE_RAMPING in self.state @property def is_holding(self): - """The motor is not turning, but rather attempting to hold a fixed position. + """ + The motor is not turning, but rather attempting to hold a fixed position. """ return self.STATE_HOLDING in self.state @property def is_overloaded(self): - """The motor is turning, but cannot reach its `speed_sp`. + """ + The motor is turning, but cannot reach its `speed_sp`. """ return self.STATE_OVERLOADED in self.state @property def is_stalled(self): - """The motor is not turning when it should be. + """ + The motor is not turning when it should be. """ return self.STATE_STALLED in self.state @@ -805,7 +864,7 @@ def wait(self, cond, timeout=None): while True: if cond(self.state): return True - + self._poll.poll(None if timeout is None else timeout) if timeout is not None and time.time() >= tic + timeout / 1000: @@ -1303,14 +1362,16 @@ def time_sp(self, value): STOP_ACTION_BRAKE = 'brake' def run_forever(self, **kwargs): - """Run the motor until another command is sent. + """ + Run the motor until another command is sent. """ for key in kwargs: setattr(self, key, kwargs[key]) self.command = self.COMMAND_RUN_FOREVER def run_timed(self, **kwargs): - """Run the motor for the amount of time specified in `time_sp` + """ + Run the motor for the amount of time specified in `time_sp` and then stop the motor using the action specified by `stop_action`. """ for key in kwargs: @@ -1318,7 +1379,8 @@ def run_timed(self, **kwargs): self.command = self.COMMAND_RUN_TIMED def run_direct(self, **kwargs): - """Run the motor at the duty cycle specified by `duty_cycle_sp`. + """ + Run the motor at the duty cycle specified by `duty_cycle_sp`. Unlike other run commands, changing `duty_cycle_sp` while running *will* take effect immediately. """ @@ -1327,7 +1389,8 @@ def run_direct(self, **kwargs): self.command = self.COMMAND_RUN_DIRECT def stop(self, **kwargs): - """Stop any of the run commands before they are complete using the + """ + Stop any of the run commands before they are complete using the action specified by `stop_action`. """ for key in kwargs: @@ -1523,14 +1586,16 @@ def state(self): POLARITY_INVERSED = 'inversed' def run(self, **kwargs): - """Drive servo to the position set in the `position_sp` attribute. + """ + Drive servo to the position set in the `position_sp` attribute. """ for key in kwargs: setattr(self, key, kwargs[key]) self.command = self.COMMAND_RUN def float(self, **kwargs): - """Remove power from the motor. + """ + Remove power from the motor. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -1547,10 +1612,11 @@ def __init__(self, motor_specs, desc=None): OUTPUT_C : LargeMotor, } """ - self.motors = {} + self.motors = OrderedDict() for motor_port in sorted(motor_specs.keys()): motor_class = motor_specs[motor_port] self.motors[motor_port] = motor_class(motor_port) + self.motors[motor_port].reset() self.desc = desc @@ -1681,6 +1747,10 @@ def wait_while(self, s, timeout=None, motors=None): for motor in motors: motor.wait_while(s, timeout) + def _block(self): + self.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) + self.wait_until_not_moving() + class MoveTank(MotorSet): """ @@ -1706,33 +1776,15 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar self.right_motor = self.motors[right_motor_port] self.max_speed = self.left_motor.max_speed - def _block(self): - self.left_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.right_motor.wait_until('running', timeout=WAIT_RUNNING_TIMEOUT) - self.left_motor.wait_until_not_moving() - self.right_motor.wait_until_not_moving() - def _unpack_speeds_to_native_units(self, left_speed, right_speed): left_speed = self.left_motor._speed_native_units(left_speed, "left_speed") right_speed = self.right_motor._speed_native_units(right_speed, "right_speed") - + return ( left_speed, right_speed ) - def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block=True): - """ - Rotate the motors at 'left_speed & right_speed' for 'rotations'. Speeds - can be percentages or any SpeedValue implementation. - - If the left speed is not equal to the right speed (i.e., the robot will - turn), the motor on the outside of the turn will rotate for the full - ``rotations`` while the motor on the inside will have its requested - distance calculated according to the expected turn. - """ - MoveTank.on_for_degrees(self, left_speed, right_speed, rotations * 360, brake, block) - def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=True): """ Rotate the motors at 'left_speed & right_speed' for 'degrees'. Speeds @@ -1748,14 +1800,16 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru # proof of the following distance calculation: consider the circle formed by each wheel's path # v_l = d_l/t, v_r = d_r/t # therefore, t = d_l/v_l = d_r/v_r - + if degrees == 0 or (left_speed_native_units == 0 and right_speed_native_units == 0): left_degrees = degrees right_degrees = degrees + # larger speed by magnitude is the "outer" wheel, and rotates the full "degrees" elif abs(left_speed_native_units) > abs(right_speed_native_units): left_degrees = degrees right_degrees = abs(right_speed_native_units / left_speed_native_units) * degrees + else: left_degrees = abs(left_speed_native_units / right_speed_native_units) * degrees right_degrees = degrees @@ -1766,6 +1820,17 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru self.right_motor._set_rel_position_degrees_and_speed_sp(right_degrees, right_speed_native_units) self.right_motor._set_brake(brake) + log.debug("{}: on_for_degrees {}".format(self, degrees)) + + # These debugs involve disk I/O to pull position and position_sp so only uncomment + # if you need to troubleshoot in more detail. + # log.debug("{}: left_speed {}, left_speed_native_units {}, left_degrees {}, left-position {}->{}".format( + # self, left_speed, left_speed_native_units, left_degrees, + # self.left_motor.position, self.left_motor.position_sp)) + # log.debug("{}: right_speed {}, right_speed_native_units {}, right_degrees {}, right-position {}->{}".format( + # self, right_speed, right_speed_native_units, right_degrees, + # self.right_motor.position, self.right_motor.position_sp)) + # Start the motors self.left_motor.run_to_rel_pos() self.right_motor.run_to_rel_pos() @@ -1773,6 +1838,18 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru if block: self._block() + def on_for_rotations(self, left_speed, right_speed, rotations, brake=True, block=True): + """ + Rotate the motors at 'left_speed & right_speed' for 'rotations'. Speeds + can be percentages or any SpeedValue implementation. + + If the left speed is not equal to the right speed (i.e., the robot will + turn), the motor on the outside of the turn will rotate for the full + ``rotations`` while the motor on the inside will have its requested + distance calculated according to the expected turn. + """ + MoveTank.on_for_degrees(self, left_speed, right_speed, rotations * 360, brake, block) + def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=True): """ Rotate the motors at 'left_speed & right_speed' for 'seconds'. Speeds @@ -1792,6 +1869,9 @@ def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=Tru self.right_motor.time_sp = int(seconds * 1000) self.right_motor._set_brake(brake) + log.debug("%s: on_for_seconds %ss at left-speed %s, right-speed %s" % + (self, seconds, left_speed, right_speed)) + # Start the motors self.left_motor.run_timed() self.right_motor.run_timed() @@ -1806,9 +1886,15 @@ def on(self, left_speed, right_speed): """ (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) + # Set all parameters self.left_motor.speed_sp = int(round(left_speed_native_units)) self.right_motor.speed_sp = int(round(right_speed_native_units)) + # This debug involves disk I/O to pull speed_sp so only uncomment + # if you need to troubleshoot in more detail. + # log.debug("%s: on at left-speed %s, right-speed %s" % + # (self, self.left_motor.speed_sp, self.right_motor.speed_sp)) + # Start the motors self.left_motor.run_forever() self.right_motor.run_forever() @@ -1832,7 +1918,7 @@ class MoveSteering(MoveTank): * -100 means turn left on the spot (right motor at 100% forward, left motor at 100% backward), * 0 means drive in a straight line, and * 100 means turn right on the spot (left motor at 100% forward, right motor at 100% backward). - + "steering" can be any number between -100 and 100. Example: @@ -1909,10 +1995,191 @@ def get_speed_steering(self, steering, speed): right_speed *= speed_factor else: left_speed *= speed_factor - + return (left_speed, right_speed) +class MoveDifferential(MoveTank): + """ + MoveDifferential is a child of MoveTank that adds the following capabilities: + + - drive in a straight line for a specified distance + + - rotate in place in a circle (clockwise or counter clockwise) for a + specified number of degrees + + - drive in an arc (clockwise or counter clockwise) of a specified radius + for a specified distance + + New arguments: + + wheel_class - Typically a child class of :class:`ev3dev2.wheel.Wheel`. This is used to + get the circumference of the wheels of the robot. The circumference is + needed for several calculations in this class. + + wheel_distance_mm - The distance between the mid point of the two + wheels of the robot. You may need to do some test drives to find + the correct value for your robot. It is not as simple as measuring + the distance between the midpoints of the two wheels. The weight of + the robot, center of gravity, etc come into play. + + You can use utils/move_differential.py to call on_arc_left() to do + some test drives of circles with a radius of 200mm. Adjust your + wheel_distance_mm until your robot can drive in a perfect circle + and stop exactly where it started. It does not have to be a circle + with a radius of 200mm, you can test with any size circle but you do + not want it to be too small or it will be difficult to test small + adjustments to wheel_distance_mm. + + Example: + + .. code:: python + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveDifferential, SpeedRPM + from ev3dev2.wheel import EV3Tire + + STUD_MM = 8 + + # test with a robot that: + # - uses the standard wheels known as EV3Tire + # - wheels are 16 studs apart + mdiff = MoveDifferential(OUTPUT_A, OUTPUT_B, EV3Tire, 16 * STUD_MM) + + # Rotate 90 degrees clockwise + mdiff.turn_right(SpeedRPM(40), 90) + + # Drive forward 500 mm + mdiff.on_for_distance(SpeedRPM(40), 500) + + # Drive in arc to the right along an imaginary circle of radius 150 mm. + # Drive for 700 mm around this imaginary circle. + mdiff.on_arc_right(SpeedRPM(80), 150, 700) + """ + + def __init__(self, left_motor_port, right_motor_port, + wheel_class, wheel_distance_mm, + desc=None, motor_class=LargeMotor): + + MoveTank.__init__(self, left_motor_port, right_motor_port, desc, motor_class) + self.wheel = wheel_class() + self.wheel_distance_mm = wheel_distance_mm + + # The circumference of the circle made if this robot were to rotate in place + self.circumference_mm = self.wheel_distance_mm * pi + + self.min_circle_radius_mm = self.wheel_distance_mm / 2 + + def on_for_distance(self, speed, distance_mm, brake=True, block=True): + """ + Drive distance_mm + """ + rotations = distance_mm / self.wheel.circumference_mm + log.debug("%s: on_for_rotations distance_mm %s, rotations %s, speed %s" % (self, distance_mm, rotations, speed)) + + MoveTank.on_for_rotations(self, speed, speed, rotations, brake, block) + + def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): + """ + Drive in a circle with 'radius' for 'distance' + """ + + if radius_mm < self.min_circle_radius_mm: + raise ValueError("{}: radius_mm {} is less than min_circle_radius_mm {}" .format( + self, radius_mm, self.min_circle_radius_mm)) + + # The circle formed at the halfway point between the two wheels is the + # circle that must have a radius of radius_mm + circle_outer_mm = 2 * pi * (radius_mm + (self.wheel_distance_mm / 2)) + circle_middle_mm = 2 * pi * radius_mm + circle_inner_mm = 2 * pi * (radius_mm - (self.wheel_distance_mm / 2)) + + if arc_right: + # The left wheel is making the larger circle and will move at 'speed' + # The right wheel is making a smaller circle so its speed will be a fraction of the left motor's speed + left_speed = speed + right_speed = float(circle_inner_mm/circle_outer_mm) * left_speed + + else: + # The right wheel is making the larger circle and will move at 'speed' + # The left wheel is making a smaller circle so its speed will be a fraction of the right motor's speed + right_speed = speed + left_speed = float(circle_inner_mm/circle_outer_mm) * right_speed + + log.debug("%s: arc %s, radius %s, distance %s, left-speed %s, right-speed %s, circle_outer_mm %s, circle_middle_mm %s, circle_inner_mm %s" % + (self, "right" if arc_right else "left", + radius_mm, distance_mm, left_speed, right_speed, + circle_outer_mm, circle_middle_mm, circle_inner_mm + ) + ) + + # We know we want the middle circle to be of length distance_mm so + # calculate the percentage of circle_middle_mm we must travel for the + # middle of the robot to travel distance_mm. + circle_middle_percentage = float(distance_mm / circle_middle_mm) + + # Now multiple that percentage by circle_outer_mm to calculate how + # many mm the outer wheel should travel. + circle_outer_final_mm = circle_middle_percentage * circle_outer_mm + + outer_wheel_rotations = float(circle_outer_final_mm / self.wheel.circumference_mm) + outer_wheel_degrees = outer_wheel_rotations * 360 + + log.debug("%s: arc %s, circle_middle_percentage %s, circle_outer_final_mm %s, outer_wheel_rotations %s, outer_wheel_degrees %s" % + (self, "right" if arc_right else "left", + circle_middle_percentage, circle_outer_final_mm, + outer_wheel_rotations, outer_wheel_degrees + ) + ) + + MoveTank.on_for_degrees(self, left_speed, right_speed, outer_wheel_degrees, brake, block) + + def on_arc_right(self, speed, radius_mm, distance_mm, brake=True, block=True): + """ + Drive clockwise in a circle with 'radius_mm' for 'distance_mm' + """ + self._on_arc(speed, radius_mm, distance_mm, brake, block, True) + + def on_arc_left(self, speed, radius_mm, distance_mm, brake=True, block=True): + """ + Drive counter-clockwise in a circle with 'radius_mm' for 'distance_mm' + """ + self._on_arc(speed, radius_mm, distance_mm, brake, block, False) + + def _turn(self, speed, degrees, brake=True, block=True): + """ + Rotate in place 'degrees'. Both wheels must turn at the same speed for us + to rotate in place. + """ + + # The distance each wheel needs to travel + distance_mm = (abs(degrees) / 360) * self.circumference_mm + + # The number of rotations to move distance_mm + rotations = distance_mm/self.wheel.circumference_mm + + log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % (self, degrees, distance_mm, rotations, degrees)) + + # If degrees is positive rotate clockwise + if degrees > 0: + MoveTank.on_for_rotations(self, speed, speed * -1, rotations, brake, block) + + # If degrees is negative rotate counter-clockwise + else: + rotations = distance_mm / self.wheel.circumference_mm + MoveTank.on_for_rotations(self, speed * -1, speed, rotations, brake, block) + + def turn_right(self, speed, degrees, brake=True, block=True): + """ + Rotate clockwise 'degrees' in place + """ + self._turn(speed, abs(degrees), brake, block) + + def turn_left(self, speed, degrees, brake=True, block=True): + """ + Rotate counter-clockwise 'degrees' in place + """ + self._turn(speed, abs(degrees) * -1, brake, block) + + class MoveJoystick(MoveTank): """ Used to control a pair of motors via a single joystick vector. @@ -1932,7 +2199,7 @@ def on(self, x, y, radius=100.0): "x", "y": The X and Y coordinates of the joystick's position, with (0,0) representing the center position. X is horizontal and Y is vertical. - + radius (default 100): The radius of the joystick, controlling the range of the input (x, y) values. e.g. if "x" and "y" can be between -1 and 1, radius should be set to "1". diff --git a/ev3dev2/wheel.py b/ev3dev2/wheel.py new file mode 100644 index 0000000..0f01828 --- /dev/null +++ b/ev3dev2/wheel.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +""" +Wheel and Rim classes + +A great reference when adding new wheels is http://wheels.sariel.pl/ +""" +from math import pi + + +class Wheel(object): + """ + A base class for various types of wheels, tires, etc. All units are in mm. + + One scenario where one of the child classes below would be used is when the + user needs their robot to drive at a specific speed or drive for a specific + distance. Both of those calculations require the circumference of the wheel + of the robot. + + Example: + + .. code:: python + + from ev3dev2.wheel import EV3Tire + + tire = EV3Tire() + + # calculate the number of rotations needed to travel forward 500 mm + rotations_for_500mm = 500 / tire.circumference_mm + """ + + def __init__(self, diameter_mm, width_mm): + self.diameter_mm = float(diameter_mm) + self.width_mm = float(width_mm) + self.circumference_mm = diameter_mm * pi + + @property + def radius_mm(self): + return float(self.diameter_mm / 2) + + +class EV3Rim(Wheel): + """ + part number 56145 + comes in set 31313 + """ + def __init__(self): + Wheel.__init__(self, 30, 20) + + +class EV3Tire(Wheel): + """ + part number 44309 + comes in set 31313 + """ + + def __init__(self): + Wheel.__init__(self, 43.2, 21) + + +class EV3EducationSetRim(Wheel): + """ + part number 56908 + comes in set 45544 + """ + + def __init__(self): + Wheel.__init__(self, 43, 26) + + +class EV3EducationSetTire(Wheel): + """ + part number 41897 + comes in set 45544 + """ + + def __init__(self): + Wheel.__init__(self, 56, 28) diff --git a/tests/api_tests.py b/tests/api_tests.py index 431a6e2..b2fe599 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -170,16 +170,16 @@ def test_motor_on_for_degrees(self): m.on_for_degrees(75, 100) self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) self.assertEqual(m.position_sp, 100) - + # various negative cases; values act like multiplication m.on_for_degrees(-75, 100) self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) self.assertEqual(m.position_sp, -100) - + m.on_for_degrees(75, -100) self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) self.assertEqual(m.position_sp, -100) - + m.on_for_degrees(-75, -100) self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) self.assertEqual(m.position_sp, 100) @@ -188,7 +188,7 @@ def test_motor_on_for_degrees(self): m.on_for_degrees(0, 100) self.assertEqual(m.speed_sp, 0) self.assertEqual(m.position_sp, 100) - + # zero distance m.on_for_degrees(75, 0) self.assertEqual(m.speed_sp, int(round(0.75 * 1050))) @@ -222,7 +222,7 @@ def test_move_tank_relative_distance(self): self.assertEqual(drive.left_motor.speed_sp, 0.50 * 1050) self.assertEqual(drive.right_motor.position_sp, 50) self.assertAlmostEqual(drive.right_motor.speed_sp, 0.25 * 1050, delta=0.5) - + # simple case (rotations, based on degrees) drive.on_for_rotations(50, 25, 10) self.assertEqual(drive.left_motor.position_sp, 10 * 360) @@ -257,7 +257,7 @@ def test_move_tank_relative_distance(self): self.assertAlmostEqual(drive.left_motor.speed_sp, 0) self.assertEqual(drive.right_motor.position_sp, 10 * 360) self.assertAlmostEqual(drive.right_motor.speed_sp, 0) - + # zero distance drive.on_for_rotations(25, 50, 0) self.assertEqual(drive.left_motor.position_sp, 0) @@ -271,7 +271,7 @@ def test_move_tank_relative_distance(self): self.assertAlmostEqual(drive.left_motor.speed_sp, 0) self.assertEqual(drive.right_motor.position_sp, 0) self.assertAlmostEqual(drive.right_motor.speed_sp, 0) - + def test_tank_units(self): clean_arena() populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) @@ -301,7 +301,7 @@ def test_steering_units(self): self.assertEqual(drive.right_motor.position, 0) self.assertEqual(drive.right_motor.position_sp, 5 * 360) self.assertEqual(drive.right_motor.speed_sp, 200) - + def test_steering_large_value(self): clean_arena() populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) diff --git a/utils/move_differential.py b/utils/move_differential.py new file mode 100755 index 0000000..e1765ef --- /dev/null +++ b/utils/move_differential.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +""" +Used to experiment with the MoveDifferential class +""" + +from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveDifferential, SpeedRPM +from ev3dev2.wheel import EV3Tire +from math import pi +import logging +import sys + +# logging +logging.basicConfig(level=logging.DEBUG, + format="%(asctime)s %(levelname)5s: %(message)s") +log = logging.getLogger(__name__) + +STUD_MM = 8 +INCH_MM = 25.4 + +ONE_FOOT_CICLE_RADIUS_MM = (12 * INCH_MM) / 2 +ONE_FOOT_CICLE_CIRCUMFERENCE_MM = 2 * pi * ONE_FOOT_CICLE_RADIUS_MM + +# Testing with RileyRover +# http://www.damienkee.com/home/2013/8/2/rileyrover-ev3-classroom-robot-design.html +# +# The centers of the wheels are 16 studs apart but this is not the +# "effective" wheel seperation. Test drives of circles with +# a diameter of 1-foot shows that the effective wheel seperation is +# closer to 16.3 studs. ndward has a writeup that goes into effective +# wheel seperation. +# https://sites.google.com/site/ev3basic/ev3-basic-programming/going-further/writerbot-v1/drawing-arcs +mdiff = MoveDifferential(OUTPUT_A, OUTPUT_B, EV3Tire, 16.3 * STUD_MM) + +# This goes crazy on brickpi3, does it do the same on ev3? +#mdiff.on_for_distance(SpeedRPM(-40), 720, brake=False) +#mdiff.on_for_distance(SpeedRPM(40), 720, brake=False) + +# Test arc left/right turns +#mdiff.on_arc_right(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM / 4) +mdiff.on_arc_left(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM) + +# Test turning in place +#mdiff.turn_right(SpeedRPM(40), 180) +#mdiff.turn_left(SpeedRPM(40), 180) From 21205eaafe299e53c7b0e0e73f60ee3c56dfa6e7 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 8 Nov 2018 10:07:02 +0000 Subject: [PATCH 098/172] GyroSensor wait_until_angle_changed_by added direction_sensitive option (#551) Resolves issue #534 --- debian/changelog | 1 + ev3dev2/sensor/lego.py | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index dec2462..1a8f094 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,6 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + * GyroSensor wait_until_angle_changed_by added direction_sensitive option * Added MoveDifferential * Sound: removed play() in favor of play_file() * suppress unittest ResourceWarning messages diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 0f9f98d..88cba61 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -626,16 +626,32 @@ def reset(self): self._ensure_mode(self.MODE_GYRO_ANG) self._direct = self.set_attr_raw(self._direct, 'direct', 17) - def wait_until_angle_changed_by(self, delta): + def wait_until_angle_changed_by(self, delta, direction_sensitive=False): """ Wait until angle has changed by specified amount. + + If ``direction_sensitive`` is True we will wait until angle has changed + by ``delta`` and with the correct sign. + + If ``direction_sensitive`` is False (default) we will wait until angle has changed + by ``delta`` in either direction. """ assert self.mode in (self.MODE_GYRO_G_A, self.MODE_GYRO_ANG, self.MODE_TILT_ANG),\ 'Gyro mode should be MODE_GYRO_ANG, MODE_GYRO_G_A or MODE_TILT_ANG' start_angle = self.value(0) - while abs(start_angle - self.value(0)) < delta: - time.sleep(0.01) + + if direction_sensitive: + if delta > 0: + while (self.value(0) - start_angle) < delta: + time.sleep(0.01) + else: + delta *= -1 + while (start_angle - self.value(0)) < delta: + time.sleep(0.01) + else: + while abs(start_angle - self.value(0)) < delta: + time.sleep(0.01) class InfraredSensor(Sensor, ButtonBase): From 47f1074ed92bba9541aeca82fb90f4e283f78dc8 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 8 Nov 2018 10:10:09 +0000 Subject: [PATCH 099/172] Display: correct xy variable names passed to Display.rectangle() (#557) Resolves issue #533 --- debian/changelog | 2 ++ ev3dev2/display.py | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index 1a8f094..511a74b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,7 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium + [Daniel Walton] + * Display: correct xy variable names passed to Display.rectangle() * GyroSensor wait_until_angle_changed_by added direction_sensitive option * Added MoveDifferential * Sound: removed play() in favor of play_file() diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 79ac387..6c91ec7 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -339,15 +339,16 @@ def circle(self, clear_screen=True, x=50, y=50, radius=40, fill_color='black', o return self.draw.ellipse((x1, y1, x2, y2), fill=fill_color, outline=outline_color) - def rectangle(self, clear_screen=True, x=10, y=10, width=80, height=40, fill_color='black', outline_color='black'): + def rectangle(self, clear_screen=True, x1=10, y1=10, x2=80, y2=40, fill_color='black', outline_color='black'): """ - Draw a rectangle 'width x height' where the top left corner is at (x, y) + Draw a rectangle where the top left corner is at (x1, y1) and the + bottom right corner is at (x2, y2) """ if clear_screen: self.clear() - return self.draw.rectangle((x, y, width, height), fill=fill_color, outline=outline_color) + return self.draw.rectangle((x1, y1, x2, y2), fill=fill_color, outline=outline_color) def point(self, clear_screen=True, x=10, y=10, point_color='black'): """ From cfda62c7ef3850d872e047591dc3de7ecc2e1c20 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 9 Nov 2018 16:25:02 -0800 Subject: [PATCH 100/172] Use specific filter to ignore only ResourceWarnings in test runs (#560) Also adds instructions for running tests on Micropython. --- .travis.yml | 6 +++--- tests/README.md | 24 +++++++++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6935367..7329875 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,11 +14,11 @@ git: depth: 100 install: - if [ $USE_MICROPYTHON = false ]; then pip install Pillow Sphinx sphinx_bootstrap_theme recommonmark evdev==1.0.0 && pip install -r ./docs/requirements.txt; fi -- if [ $USE_MICROPYTHON = true ]; then ./.travis/install-micropython.sh && PYTHON=~/micropython/ports/unix/micropython; else PYTHON=python3; fi +- if [ $USE_MICROPYTHON = true ]; then ./.travis/install-micropython.sh && MICROPYTHON=~/micropython/ports/unix/micropython; fi script: - chmod -R g+rw ./tests/fake-sys/devices/**/* -- if [ $USE_MICROPYTHON = false ]; then $PYTHON -W ignore tests/api_tests.py; fi -- if [ $USE_MICROPYTHON = true ]; then $PYTHON tests/api_tests.py; fi +- if [ $USE_MICROPYTHON = false ]; then python3 -W ignore::ResourceWarning tests/api_tests.py; fi +- if [ $USE_MICROPYTHON = true ]; then $MICROPYTHON tests/api_tests.py; fi - if [ $USE_MICROPYTHON = false ]; then sphinx-build -nW -b html ./docs/ ./docs/_build/html; fi deploy: provider: pypi diff --git a/tests/README.md b/tests/README.md index 400e363..f84a677 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,10 +11,28 @@ when you git clone. Example: $ git clone --recursive https://github.com/ev3dev/ev3dev-lang-python.git ``` -# Running Tests -To run the tests do: +# Running Tests with CPython (default) +To run the tests: ``` $ cd ev3dev-lang-python/ $ chmod -R g+rw ./tests/fake-sys/devices/**/* -$ python3 -W ignore ./tests/api_tests.py +$ python3 -W ignore::ResourceWarning tests/api_tests.py +``` + +If on Windows, the `chmod` command can be ignored. + +# Running Tests with Micropython + +This library also supports a subset of functionality on [Micropython](http://micropython.org/). + +You can follow the instructions on [the Micropython wiki](https://github.com/micropython/micropython/wiki/Getting-Started) +or check out our [installation script for Travis CI workers](https://github.com/ev3dev/ev3dev-lang-python/blob/ev3dev-stretch/.travis/install-micropython.sh) +to get Micropython installed. If following the official instructions, +make sure you install the relevant micropython-lib modules listed in the linked script as well. + +Once Micropython is installed, you can run the tests with: + +``` +$ cd ev3dev-lang-python/ +$ micropython tests/api_tests.py ``` From 0d60fca09efadd17eeb6505177fe1fd3073b897c Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 13 Nov 2018 16:49:10 -0500 Subject: [PATCH 101/172] cache attributes that never change (#564) For issue #365 --- debian/changelog | 1 + ev3dev2/__init__.py | 37 +++++++++++++++++++++++++++++++++++-- ev3dev2/motor.py | 14 +++++++------- ev3dev2/port.py | 4 ++-- ev3dev2/sensor/__init__.py | 8 ++++---- tests/api_tests.py | 8 ++++---- 6 files changed, 53 insertions(+), 19 deletions(-) diff --git a/debian/changelog b/debian/changelog index 511a74b..477d3a7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium [Daniel Walton] + * cache attributes that never change * Display: correct xy variable names passed to Display.rectangle() * GyroSensor wait_until_angle_changed_by added direction_sensitive option * Added MoveDifferential diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index fe367c8..9d7c301 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -57,7 +57,7 @@ def get_current_platform(): """ board_info_dir = '/sys/class/board-info/' - if not os.path.exists(board_info_dir): + if not os.path.exists(board_info_dir) or os.environ.get("FAKE_SYS"): return 'fake' for board in os.listdir(board_info_dir): @@ -145,7 +145,12 @@ class DeviceNotFound(Exception): class Device(object): """The ev3dev device base class""" - __slots__ = ['_path', '_device_index', 'kwargs'] + __slots__ = [ + '_path', + '_device_index', + '_attr_cache', + 'kwargs', + ] DEVICE_ROOT_PATH = '/sys/class' @@ -178,6 +183,7 @@ def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs): classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) self.kwargs = kwargs + self._attr_cache = {} def get_index(file): match = Device._DEVICE_INDEX.match(file) @@ -281,6 +287,15 @@ def get_attr_int(self, attribute, name): attribute, value = self._get_attribute(attribute, name) return attribute, int(value) + def get_cached_attr_int(self, filehandle, keyword): + value = self._attr_cache.get(keyword) + + if value is None: + (filehandle, value) = self.get_attr_int(filehandle, keyword) + self._attr_cache[keyword] = value + + return (filehandle, value) + def set_attr_int(self, attribute, name, value): return self._set_attribute(attribute, name, str(int(value))) @@ -290,6 +305,15 @@ def set_attr_raw(self, attribute, name, value): def get_attr_string(self, attribute, name): return self._get_attribute(attribute, name) + def get_cached_attr_string(self, filehandle, keyword): + value = self._attr_cache.get(keyword) + + if value is None: + (filehandle, value) = self.get_attr_string(filehandle, keyword) + self._attr_cache[keyword] = value + + return (filehandle, value) + def set_attr_string(self, attribute, name, value): return self._set_attribute(attribute, name, value) @@ -300,6 +324,15 @@ def get_attr_set(self, attribute, name): attribute, value = self.get_attr_line(attribute, name) return attribute, [v.strip('[]') for v in value.split()] + def get_cached_attr_set(self, filehandle, keyword): + value = self._attr_cache.get(keyword) + + if value is None: + (filehandle, value) = self.get_attr_set(filehandle, keyword) + self._attr_cache[keyword] = value + + return (filehandle, value) + def get_attr_from_set(self, attribute, name): attribute, value = self.get_attr_line(attribute, name) for a in value.split(): diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index b87e8e0..0150243 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -438,7 +438,7 @@ def commands(self): - `reset` will reset all of the motor parameter attributes to their default value. This will also have the effect of stopping the motor. """ - self._commands, value = self.get_attr_set(self._commands, 'commands') + (self._commands, value) = self.get_cached_attr_set(self._commands, 'commands') return value @property @@ -448,7 +448,7 @@ def count_per_rot(self): are used by the position and speed attributes, so you can use this value to convert rotations or degrees to tacho counts. (rotation motors only) """ - self._count_per_rot, value = self.get_attr_int(self._count_per_rot, 'count_per_rot') + (self._count_per_rot, value) = self.get_cached_attr_int(self._count_per_rot, 'count_per_rot') return value @property @@ -458,7 +458,7 @@ def count_per_m(self): counts are used by the position and speed attributes, so you can use this value to convert from distance to tacho counts. (linear motors only) """ - self._count_per_m, value = self.get_attr_int(self._count_per_m, 'count_per_m') + (self._count_per_m, value) = self.get_cached_attr_int(self._count_per_m, 'count_per_m') return value @property @@ -466,7 +466,7 @@ def driver_name(self): """ Returns the name of the driver that provides this tacho motor device. """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + (self._driver_name, value) = self.get_cached_attr_string(self._driver_name, 'driver_name') return value @property @@ -499,7 +499,7 @@ def full_travel_count(self): combined with the `count_per_m` atribute, you can use this value to calculate the maximum travel distance of the motor. (linear motors only) """ - self._full_travel_count, value = self.get_attr_int(self._full_travel_count, 'full_travel_count') + (self._full_travel_count, value) = self.get_cached_attr_int(self._full_travel_count, 'full_travel_count') return value @property @@ -590,7 +590,7 @@ def max_speed(self): may be slightly different than the maximum speed that a particular motor can reach - it's the maximum theoretical speed. """ - self._max_speed, value = self.get_attr_int(self._max_speed, 'max_speed') + (self._max_speed, value) = self.get_cached_attr_int(self._max_speed, 'max_speed') return value @property @@ -726,7 +726,7 @@ def stop_actions(self): position. If an external force tries to turn the motor, the motor will 'push back' to maintain its position. """ - self._stop_actions, value = self.get_attr_set(self._stop_actions, 'stop_actions') + (self._stop_actions, value) = self.get_cached_attr_set(self._stop_actions, 'stop_actions') return value @property diff --git a/ev3dev2/port.py b/ev3dev2/port.py index 08c078a..faeccc7 100644 --- a/ev3dev2/port.py +++ b/ev3dev2/port.py @@ -98,7 +98,7 @@ def driver_name(self): Returns the name of the driver that loaded this device. You can find the complete list of drivers in the [list of port drivers]. """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + (self._driver_name, value) = self.get_cached_attr_string(self._driver_name, 'driver_name') return value @property @@ -106,7 +106,7 @@ def modes(self): """ Returns a list of the available modes of the port. """ - self._modes, value = self.get_attr_set(self._modes, 'modes') + (self._modes, value) = self.get_cached_attr_set(self._modes, 'modes') return value @property diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index c3b9d32..d91ae6a 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -144,7 +144,7 @@ def commands(self): Returns a list of the valid commands for the sensor. Returns -EOPNOTSUPP if no commands are supported. """ - self._commands, value = self.get_attr_set(self._commands, 'commands') + (self._commands, value) = self.get_cached_attr_set(self._commands, 'commands') return value @property @@ -162,7 +162,7 @@ def driver_name(self): Returns the name of the sensor device/driver. See the list of [supported sensors] for a complete list of drivers. """ - self._driver_name, value = self.get_attr_string(self._driver_name, 'driver_name') + (self._driver_name, value) = self.get_cached_attr_string(self._driver_name, 'driver_name') return value @property @@ -183,7 +183,7 @@ def modes(self): """ Returns a list of the valid modes for the sensor. """ - self._modes, value = self.get_attr_set(self._modes, 'modes') + (self._modes, value) = self.get_cached_attr_set(self._modes, 'modes') return value @property @@ -313,7 +313,7 @@ def fw_version(self): Returns the firmware version of the sensor if available. Currently only I2C/NXT sensors support this. """ - self._fw_version, value = self.get_attr_string(self._fw_version, 'fw_version') + (self._fw_version, value) = self.get_cached_attr_string(self._fw_version, 'fw_version') return value @property diff --git a/tests/api_tests.py b/tests/api_tests.py index b2fe599..2c371f6 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import unittest, sys, os.path +import os FAKE_SYS = os.path.join(os.path.dirname(__file__), 'fake-sys') +os.environ["FAKE_SYS"] = "1" sys.path.append(FAKE_SYS) sys.path.append(os.path.join(os.path.dirname(__file__), '..')) @@ -12,6 +14,7 @@ import ev3dev2 from ev3dev2.sensor.lego import InfraredSensor from ev3dev2.motor import \ + OUTPUT_A, OUTPUT_B, \ Motor, MediumMotor, LargeMotor, \ MoveTank, MoveSteering, MoveJoystick, \ SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits @@ -27,10 +30,6 @@ DistanceStuds ) -# We force OUTPUT_A and OUTPUT_B to be imported from platform "fake" so that -# we can run these test cases on an EV3, brickpi3, etc. -from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B - ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') _internal_set_attribute = ev3dev2.Device._set_attribute @@ -356,3 +355,4 @@ def test_units(self): if __name__ == "__main__": unittest.main() + del os.environ["FAKE_SYS"] From 04f705f4ea981f08b6382ff4e01c1861ac365b2b Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 13 Nov 2018 16:53:27 -0500 Subject: [PATCH 102/172] MotorSet off() vs stop() (#563) For issue #540 --- debian/changelog | 1 + ev3dev2/control/rc_tank.py | 2 +- ev3dev2/motor.py | 26 +++++++++++++++----------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/debian/changelog b/debian/changelog index 477d3a7..b5c3b05 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium [Daniel Walton] + * Moved MoveTank.off() to MotorSet * cache attributes that never change * Display: correct xy variable names passed to Display.rectangle() * GyroSensor wait_until_angle_changed_by added direction_sensitive option diff --git a/ev3dev2/control/rc_tank.py b/ev3dev2/control/rc_tank.py index a498ba6..4e367b8 100644 --- a/ev3dev2/control/rc_tank.py +++ b/ev3dev2/control/rc_tank.py @@ -43,4 +43,4 @@ def main(self): # Exit cleanly so that all motors are stopped except (KeyboardInterrupt, Exception) as e: log.exception(e) - self.stop() + self.off() diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 0150243..bc8007f 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1688,12 +1688,26 @@ def reset(self, motors=None): for motor in motors: motor.reset() - def stop(self, motors=None): + def off(self, motors=None, brake=True): + """ + Stop motors immediately. Configure motors to brake if ``brake`` is set. + """ motors = motors if motors is not None else self.motors.values() + for motor in motors: + motor._set_brake(brake) + for motor in motors: motor.stop() + def stop(self, motors=None, brake=True): + """ + ``stop`` is an alias of ``off``. This is deprecated but helps keep + the API for MotorSet somewhat similar to Motor which has both ``stop`` + and ``off``. + """ + self.off(motors, brake) + def _is_state(self, motors, state): motors = motors if motors is not None else self.motors.values() @@ -1899,16 +1913,6 @@ def on(self, left_speed, right_speed): self.left_motor.run_forever() self.right_motor.run_forever() - def off(self, brake=True): - """ - Stop both motors immediately. Configure both to brake if ``brake`` is - set. - """ - self.left_motor._set_brake(brake) - self.right_motor._set_brake(brake) - self.left_motor.stop() - self.right_motor.stop() - class MoveSteering(MoveTank): """ From 5dae14c61a5559cd41c87395a2057a55ed0ec5c7 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 21 Nov 2018 09:06:00 -0500 Subject: [PATCH 103/172] Add port name constants for stacked brickpi3s (#562) --- debian/changelog | 1 + ev3dev2/_platform/brickpi3.py | 37 ++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index b5c3b05..0d72cd6 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium [Daniel Walton] + * Add port name constants for stacked brickpi3s * Moved MoveTank.off() to MotorSet * cache attributes that never change * Display: correct xy variable names passed to Display.rectangle() diff --git a/ev3dev2/_platform/brickpi3.py b/ev3dev2/_platform/brickpi3.py index 4447e4a..4156560 100644 --- a/ev3dev2/_platform/brickpi3.py +++ b/ev3dev2/_platform/brickpi3.py @@ -1,15 +1,46 @@ from collections import OrderedDict +# Up to four brickpi3s can be stacked OUTPUT_A = 'spi0.1:MA' OUTPUT_B = 'spi0.1:MB' OUTPUT_C = 'spi0.1:MC' OUTPUT_D = 'spi0.1:MD' -INPUT_1 = 'spi0.1:S1' -INPUT_2 = 'spi0.1:S2' -INPUT_3 = 'spi0.1:S3' +OUTPUT_E = 'spi0.1:ME' +OUTPUT_F = 'spi0.1:MF' +OUTPUT_G = 'spi0.1:MG' +OUTPUT_H = 'spi0.1:MH' + +OUTPUT_I = 'spi0.1:MI' +OUTPUT_J = 'spi0.1:MJ' +OUTPUT_K = 'spi0.1:MK' +OUTPUT_L = 'spi0.1:ML' + +OUTPUT_M = 'spi0.1:MM' +OUTPUT_N = 'spi0.1:MN' +OUTPUT_O = 'spi0.1:MO' +OUTPUT_P = 'spi0.1:MP' + +INPUT_1 = 'spi0.1:S1' +INPUT_2 = 'spi0.1:S2' +INPUT_3 = 'spi0.1:S3' INPUT_4 = 'spi0.1:S4' +INPUT_5 = 'spi0.1:S5' +INPUT_6 = 'spi0.1:S6' +INPUT_7 = 'spi0.1:S7' +INPUT_8 = 'spi0.1:S8' + +INPUT_9 = 'spi0.1:S9' +INPUT_10 = 'spi0.1:S10' +INPUT_11 = 'spi0.1:S11' +INPUT_12 = 'spi0.1:S12' + +INPUT_13 = 'spi0.1:S13' +INPUT_14 = 'spi0.1:S14' +INPUT_15 = 'spi0.1:S15' +INPUT_16 = 'spi0.1:S16' + BUTTONS_FILENAME = None EVDEV_DEVICE_NAME = None From 17bae7f8fec888ca29e717e0a51ce48733ab4432 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 22 Nov 2018 10:15:27 -0500 Subject: [PATCH 104/172] brickpi(3) support use of the Motor class (#566) --- debian/changelog | 1 + ev3dev2/motor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index 0d72cd6..381a6a4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium [Daniel Walton] + * brickpi(3) support use of the Motor class * Add port name constants for stacked brickpi3s * Moved MoveTank.off() to MotorSet * cache attributes that never change diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index bc8007f..09e7235 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -355,7 +355,7 @@ class Motor(Device): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - if platform in ('brickpi', 'brickpi3') and not isinstance(self, LargeMotor): + if platform in ('brickpi', 'brickpi3') and type(self).__name__ != 'Motor' and not isinstance(self, LargeMotor): raise Exception("{} is unaware of different motor types, use LargeMotor instead".format(platform)) if address is not None: From f8f4b860953336c56852557a42894f6985282a01 Mon Sep 17 00:00:00 2001 From: Olivier Martin Date: Fri, 14 Dec 2018 02:15:14 -0500 Subject: [PATCH 105/172] Make new stacked BrickPi port name constants available to import (#572) --- ev3dev2/motor.py | 5 ++++- ev3dev2/sensor/__init__.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 09e7235..8f8af50 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -63,7 +63,10 @@ from ev3dev2._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D elif platform == 'brickpi3': - from ev3dev2._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, \ + OUTPUT_E, OUTPUT_F, OUTPUT_G, OUTPUT_H, \ + OUTPUT_I, OUTPUT_J, OUTPUT_K, OUTPUT_L, \ + OUTPUT_M, OUTPUT_N, OUTPUT_O, OUTPUT_P elif platform == 'fake': from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index d91ae6a..d082bc9 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -50,7 +50,10 @@ from ev3dev2._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 elif platform == 'brickpi3': - from ev3dev2._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4, \ + INPUT_5, INPUT_6, INPUT_7, INPUT_8, \ + INPUT_9, INPUT_10, INPUT_11, INPUT_12, \ + INPUT_13, INPUT_14, INPUT_15, INPUT_16 elif platform == 'fake': from ev3dev2._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 From 7e14dfdf32809a0a37ecf004d61f8d4be066fe12 Mon Sep 17 00:00:00 2001 From: Viktor Garske Date: Sat, 15 Dec 2018 19:56:14 +0100 Subject: [PATCH 106/172] motors.py: added missing parameter 'motors' to MotorSet.is_stalled (#573) --- ev3dev2/motor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 8f8af50..0ff6889 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1737,7 +1737,7 @@ def is_overloaded(self, motors=None): return self._is_state(motors, LargeMotor.STATE_OVERLOADED) @property - def is_stalled(self): + def is_stalled(self, motors=None): return self._is_state(motors, LargeMotor.STATE_STALLED) def wait(self, cond, timeout=None, motors=None): From 8f58cc97a4e5d49828d7365ab4a86e18f0032cac Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 15 Dec 2018 11:35:01 -0800 Subject: [PATCH 107/172] Update changelog for is_stalled fix --- debian/changelog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/debian/changelog b/debian/changelog index 381a6a4..5afff38 100644 --- a/debian/changelog +++ b/debian/changelog @@ -15,6 +15,9 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium * brickpi(3) raise exception if LargeMotor not used for a motor * MoveJoyStick should use SpeedPercent * renamed GyroSensor rate_and_angle to angle_and_rate + + [Viktor Garske] + * Fixed error when using Motor.is_stalled -- Kaelin Laundry Sat, 27 Oct 2018 21:18:00 -0700 From 5536725751c4398f4f39a25c02b4a3e2b12cdd1e Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 19 Dec 2018 00:07:50 -0800 Subject: [PATCH 108/172] docs: Replace deprecated pip call with launching in separate process (#576) Fixes documentation builds on RTD. --- docs/conf.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1291fc3..20620bf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,6 +16,7 @@ import sys import os import shlex +import subprocess sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from git_version import git_version @@ -23,10 +24,7 @@ on_rtd = os.environ.get('READTHEDOCS', None) == 'True' if on_rtd: - import pip - pip.main(['install', 'sphinx_bootstrap_theme']) - pip.main(['install', 'recommonmark']) - pip.main(['install', 'evdev']) + subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'sphinx_bootstrap_theme', 'recommonmark', 'evdev']) import sphinx_bootstrap_theme from recommonmark.parser import CommonMarkParser From 056f721c383a8a4331f7cc7f9958ffdcf794e573 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 19 Dec 2018 00:36:53 -0800 Subject: [PATCH 109/172] Fix call to "off" when MoveJoystick.on is called with joystick centered (#578) * Fix call to "off" when MoveJoystick.on is called with joystick centered Fixes #575 --- ev3dev2/motor.py | 2 +- tests/api_tests.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 0ff6889..c5c18ea 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -2214,7 +2214,7 @@ def on(self, x, y, radius=100.0): # If joystick is in the middle stop the tank if not x and not y: - MoveTank.off() + self.off() return vector_length = sqrt(x*x + y*y) diff --git a/tests/api_tests.py b/tests/api_tests.py index 2c371f6..e979742 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -316,7 +316,7 @@ def test_steering_large_value(self): self.assertEqual(drive.right_motor.position_sp, 10 * 360) self.assertEqual(drive.right_motor.speed_sp, 400) - def test_joystick_units(self): + def test_joystick(self): clean_arena() populate_arena([('large_motor', 0, 'outA'), ('large_motor', 1, 'outB')]) @@ -327,6 +327,13 @@ def test_joystick_units(self): drive.on(0, 50) self.assertEqual(drive.left_motor.speed_sp, 1050 / 2) self.assertEqual(drive.right_motor.speed_sp, 1050 / 2) + self.assertEqual(drive.left_motor._get_attribute(None, 'command')[1], 'run-forever') + self.assertEqual(drive.right_motor._get_attribute(None, 'command')[1], 'run-forever') + + # With the joystick centered, motors should both be stopped + drive.on(0, 0) + self.assertEqual(drive.left_motor._get_attribute(None, 'command')[1], 'stop') + self.assertEqual(drive.right_motor._get_attribute(None, 'command')[1], 'stop') def test_units(self): clean_arena() From 11a3c508b5c92e4b54ea2e584b50d6fc40e6a0d9 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Dec 2018 12:03:26 -0800 Subject: [PATCH 110/172] Split other.rst into individual pages for each class/purpose (#574) --- docs/button.rst | 19 +++++ docs/display.rst | 30 ++++++++ docs/leds.rst | 73 +++++++++++++++++++ docs/other.rst | 160 +++--------------------------------------- docs/ports.rst | 5 ++ docs/power-supply.rst | 5 ++ docs/sound.rst | 5 ++ docs/spec.rst | 8 ++- docs/wheels.rst | 35 +++++++++ 9 files changed, 187 insertions(+), 153 deletions(-) create mode 100644 docs/button.rst create mode 100644 docs/display.rst create mode 100644 docs/leds.rst create mode 100644 docs/ports.rst create mode 100644 docs/power-supply.rst create mode 100644 docs/sound.rst create mode 100644 docs/wheels.rst diff --git a/docs/button.rst b/docs/button.rst new file mode 100644 index 0000000..037dd8d --- /dev/null +++ b/docs/button.rst @@ -0,0 +1,19 @@ +Button +====== + +.. autoclass:: ev3dev2.button.Button + :members: + :inherited-members: + + .. rubric:: Event handlers + + These will be called when state of the corresponding button is changed: + + .. py:data:: on_up + .. py:data:: on_down + .. py:data:: on_left + .. py:data:: on_right + .. py:data:: on_enter + .. py:data:: on_backspace + + .. rubric:: Member functions and properties \ No newline at end of file diff --git a/docs/display.rst b/docs/display.rst new file mode 100644 index 0000000..943286f --- /dev/null +++ b/docs/display.rst @@ -0,0 +1,30 @@ +Display +======= + +.. autoclass:: ev3dev2.display.Display + :members: + :show-inheritance: + + +Bitmap fonts +------------ + +The :py:class:`ev3dev2.display.Display` class allows to write text on the LCD using python +imaging library (PIL) interface (see description of the ``text()`` method +`here `_). +The ``ev3dev2.fonts`` module contains bitmap fonts in PIL format that should +look good on a tiny EV3 screen: + +.. code-block:: py + + import ev3dev2.fonts as fonts + display.draw.text((10,10), 'Hello World!', font=fonts.load('luBS14')) + +.. autofunction:: ev3dev2.fonts.available + +.. autofunction:: ev3dev2.fonts.load + +The following image lists all available fonts. The grid lines correspond +to EV3 screen size: + +.. image:: _static/fonts.png diff --git a/docs/leds.rst b/docs/leds.rst new file mode 100644 index 0000000..655e732 --- /dev/null +++ b/docs/leds.rst @@ -0,0 +1,73 @@ +.. _led-classes: + +Leds +==== + +.. autoclass:: ev3dev2.led.Led + :members: + +.. autoclass:: ev3dev2.led.Leds + :members: + +LED group and color names +------------------------- + +.. rubric:: EV3 platform + +Led groups: + +- ``LEFT`` +- ``RIGHT`` + +Colors: + +- ``BLACK`` +- ``RED`` +- ``GREEN`` +- ``AMBER`` +- ``ORANGE`` +- ``YELLOW`` + +.. rubric:: BrickPI platform + +Led groups: + +- ``LED1`` +- ``LED2`` + +Colors: + +- ``BLACK`` +- ``BLUE`` + +.. rubric:: BrickPI3 platform + +Led groups: + +- ``LED`` + +Colors: + +- ``BLACK`` +- ``BLUE`` + +.. rubric:: PiStorms platform + +Led groups: + +- ``LEFT`` +- ``RIGHT`` + +Colors: + +- ``BLACK`` +- ``RED`` +- ``GREEN`` +- ``BLUE`` +- ``YELLOW`` +- ``CYAN`` +- ``MAGENTA`` + +.. rubric:: EVB platform + +None. diff --git a/docs/other.rst b/docs/other.rst index 295781d..e7dc2bf 100644 --- a/docs/other.rst +++ b/docs/other.rst @@ -1,178 +1,34 @@ +:orphan: + Other classes ============= Button ------ -.. autoclass:: ev3dev2.button.Button - :members: - :inherited-members: - - .. rubric:: Event handlers - - These will be called when state of the corresponding button is changed: - - .. py:data:: on_up - .. py:data:: on_down - .. py:data:: on_left - .. py:data:: on_right - .. py:data:: on_enter - .. py:data:: on_backspace - - .. rubric:: Member functions and properties +See :py:class:`ev3dev2.button.Button`. Leds ---- -.. autoclass:: ev3dev2.led.Led - :members: - -.. autoclass:: ev3dev2.led.Leds - :members: - -LED group and color names -~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. rubric:: EV3 platform - -Led groups: - -- ``LEFT`` -- ``RIGHT`` - -Colors: - -- ``BLACK`` -- ``RED`` -- ``GREEN`` -- ``AMBER`` -- ``ORANGE`` -- ``YELLOW`` - -.. rubric:: BrickPI platform - -Led groups: - -- ``LED1`` -- ``LED2`` - -Colors: - -- ``BLACK`` -- ``BLUE`` - -.. rubric:: BrickPI3 platform - -Led groups: - -- ``LED`` - -Colors: - -- ``BLACK`` -- ``BLUE`` - -.. rubric:: PiStorms platform - -Led groups: - -- ``LEFT`` -- ``RIGHT`` - -Colors: - -- ``BLACK`` -- ``RED`` -- ``GREEN`` -- ``BLUE`` -- ``YELLOW`` -- ``CYAN`` -- ``MAGENTA`` - -.. rubric:: EVB platform - -None. - +See :ref:`led-classes`. Power Supply ------------ -.. autoclass:: ev3dev2.power.PowerSupply - :members: +See :py:class:`ev3dev2.power.PowerSupply`. Sound ----- -.. autoclass:: ev3dev2.sound.Sound - :members: +See :py:class:`ev3dev2.sound.Sound`. Display ------- -.. autoclass:: ev3dev2.display.Display - :members: - :show-inheritance: - -Bitmap fonts -~~~~~~~~~~~~ - -The :py:class:`ev3dev2.display.Display` class allows to write text on the LCD using python -imaging library (PIL) interface (see description of the ``text()`` method -`here `_). -The ``ev3dev2.fonts`` module contains bitmap fonts in PIL format that should -look good on a tiny EV3 screen: - -.. code-block:: py - - import ev3dev2.fonts as fonts - display.draw.text((10,10), 'Hello World!', font=fonts.load('luBS14')) - -.. autofunction:: ev3dev2.fonts.available - -.. autofunction:: ev3dev2.fonts.load - -The following image lists all available fonts. The grid lines correspond -to EV3 screen size: - -.. image:: _static/fonts.png +See :py:class:`ev3dev2.display.Display`. Lego Port --------- -.. autoclass:: ev3dev2.port.LegoPort - :members: - -Wheels ------- -All Wheel class units are in millimeters. The diameter and width for various lego wheels can be found at http://wheels.sariel.pl/ - -.. autoclass:: ev3dev2.wheel.Wheel - :members: - -EV3 Rim -~~~~~~~ - -.. autoclass:: ev3dev2.wheel.EV3Rim - :members: - :show-inheritance: - -EV3 Tire -~~~~~~~~ - -.. autoclass:: ev3dev2.wheel.EV3Tire - :members: - :show-inheritance: - -EV3 Education Set Rim -~~~~~~~~~~~~~~~~~~~~~ - -.. autoclass:: ev3dev2.wheel.EV3EducationSetRim - :members: - :show-inheritance: - -EV3 Education Set Tire -~~~~~~~~~~~~~~~~~~~~~~ - -.. autoclass:: ev3dev2.wheel.EV3EducationSetTire - :members: - :show-inheritance: +See :py:class:`ev3dev2.port.LegoPort`. \ No newline at end of file diff --git a/docs/ports.rst b/docs/ports.rst new file mode 100644 index 0000000..5ebe770 --- /dev/null +++ b/docs/ports.rst @@ -0,0 +1,5 @@ +Lego Port +========= + +.. autoclass:: ev3dev2.port.LegoPort + :members: \ No newline at end of file diff --git a/docs/power-supply.rst b/docs/power-supply.rst new file mode 100644 index 0000000..0a92305 --- /dev/null +++ b/docs/power-supply.rst @@ -0,0 +1,5 @@ +Power Supply +============ + +.. autoclass:: ev3dev2.power.PowerSupply + :members: \ No newline at end of file diff --git a/docs/sound.rst b/docs/sound.rst new file mode 100644 index 0000000..7ea2bcb --- /dev/null +++ b/docs/sound.rst @@ -0,0 +1,5 @@ +Sound +===== + +.. autoclass:: ev3dev2.sound.Sound + :members: \ No newline at end of file diff --git a/docs/spec.rst b/docs/spec.rst index 773acea..b66323b 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -20,4 +20,10 @@ Each class in ev3dev module inherits from the base :py:class:`ev3dev2.Device` cl motors sensors - other + button + leds + power-supply + sound + display + ports + wheels diff --git a/docs/wheels.rst b/docs/wheels.rst new file mode 100644 index 0000000..b24a4b0 --- /dev/null +++ b/docs/wheels.rst @@ -0,0 +1,35 @@ +Wheels +====== + +All Wheel class units are in millimeters. The diameter and width for various lego wheels can be found at http://wheels.sariel.pl/ + +.. autoclass:: ev3dev2.wheel.Wheel + :members: + +EV3 Rim +~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3Rim + :members: + :show-inheritance: + +EV3 Tire +~~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3Tire + :members: + :show-inheritance: + +EV3 Education Set Rim +~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3EducationSetRim + :members: + :show-inheritance: + +EV3 Education Set Tire +~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ev3dev2.wheel.EV3EducationSetTire + :members: + :show-inheritance: From 19616bfd569b16c195143a7da7373d4e7f60b148 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Dec 2018 12:07:43 -0800 Subject: [PATCH 111/172] IR sensor "proximity" is object proximity, not distance to the remote (#579) Fixes #518 --- ev3dev2/sensor/lego.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 88cba61..f0083e9 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -775,8 +775,8 @@ def _normalize_channel(self, channel): @property def proximity(self): """ - A measurement of the distance between the sensor and the remote, - as a percentage. 100% is approximately 70cm/27in. + An estimate of the distance between the sensor and objects in front of + it, as a percentage. 100% is approximately 70cm/27in. """ self._ensure_mode(self.MODE_IR_PROX) return self.value(0) From aad7f6665828248a3d9a14189cd207368b9648e8 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Dec 2018 12:08:18 -0800 Subject: [PATCH 112/172] Remove obsolete INPUT_AUTO and OUTPUT_AUTO (#580) --- ev3dev2/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 9d7c301..a268ecc 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -45,10 +45,6 @@ def chain_exception(exception, cause): import errno from os.path import abspath -INPUT_AUTO = '' -OUTPUT_AUTO = '' - - def get_current_platform(): """ Look in /sys/class/board-info/ to determine the platform type. From 055ba0d3491c0e1fb6fd8ca7c06e1078173e05bd Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Dec 2018 12:09:33 -0800 Subject: [PATCH 113/172] Update CONTRIBUTING.rst (#582) --- CONTRIBUTING.rst | 49 ++++++++++++++++-------------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e365a2a..539fc8b 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -6,42 +6,24 @@ devices that use the drivers available in the ev3dev_ distribution. for embedded systems. Contributions are welcome in the form of pull requests - but please -take a moment to read our suggestions for a happy maintainer (me) and +take a moment to read our suggestions for happy maintainers and even happier users. -The ``master`` branch +The ``ev3dev-stretch`` branch --------------------- -This is where the latest tagged version lives - it changes whenever -we release a tag that gets released. - -The ``develop`` branch ----------------------- - -This is the branch that is undergoing active development intended -for the next tagged version that gets released to ``master``. - -Please make sure that your pull requests are against the -``develop`` branch. +This is where the latest version of our library lives. It targets +`ev3dev-stretch`, which is currently considered a beta. Nonetheless, +it is very stable and isn't expected to have significant breaking +changes. We publish releases from this branch. Before you issue a Pull Request ------------------------------- -This is a hobby for me, I get no compensation for the work I do -on this repo or any other contributions to ev3dev_. That does not -make me special, it's the same situation that everyone involved -in the project is in. - -Therefore, do not count on me to test your PR before I do the -merge - I will certainly review the code and if it looks OK I will -just merge automatically. - -I would ask that you have at least tested your changes by running -a test script on your target of choice as the generic ``robot`` user -that is used by the ``Brickman`` UI for ev3dev_. - -Please do not run as ``root`` or your own user that may have group -memberships or special privileges not enjoyed by ``robot``. +Sometimes, it isn't easy for us to pull your suggested change and run +rigorous testing on it. So please help us out by validating your changes +and mentioning what kinds of testing you did when you open your PR. +Please also consider adding relevant tests to `api_tests.py`. If your change breaks or changes an API --------------------------------------- @@ -50,9 +32,9 @@ Breaking changes are discouraged, but sometimes they are necessary. A more common change is to add a new function or property to a class. Either way, if it's more than a bug fix, please add enough text to the -comments in the pull request message so that I can paste it into the -draft release notes that I keep running for the next release out of -master. +comments in the pull request message so that we know what was updated +and can easily discuss the breaking change and add it to the release +notes. If your change addresses an Issue --------------------------------- @@ -61,8 +43,9 @@ Bug fixes are always welcome, especially if they are against known issues! When you send a pull request that addresses an issue, please add a -note something like ``Fixes #24`` in the PR so that we get linkage -back to the specific issue. +note of the format ``Fixes #24`` in the PR so that the PR links back +to its relevant issue and will automatically close the issue when the +PR is merged. .. _ev3dev: http://ev3dev.org From 89f36c5e48138d0504d39205793bca92a93e526e Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 23 Dec 2018 12:37:47 -0800 Subject: [PATCH 114/172] Expose ev3dev2.version.__version__ as ev3dev2.__version__ (#581) --- ev3dev2/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index a268ecc..84cddec 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -37,6 +37,13 @@ def chain_exception(exception, cause): else: raise exception from cause +try: + # if we are in a released build, there will be an auto-generated "version" + # module + from .version import __version__ +except ImportError: + __version__ = "" + import os import io import fnmatch From e859d0ef79471467bb4456baa6f4ddb60e27910c Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Tue, 25 Dec 2018 15:23:07 -0700 Subject: [PATCH 115/172] Add new debian binary package for MicroPython (#577) * Add new debian binary package for MicroPython * depend on micropython-lib * update changelog --- debian/changelog | 5 ++++- debian/control | 11 ++++++++++- debian/rules | 51 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index 5afff38..b84bedb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -python-ev3dev2 (2.0.0~beta3) stable; urgency=medium +python-ev3dev2 (2.0.0~beta3) UNRELEASED; urgency=medium [Daniel Walton] * brickpi(3) support use of the Motor class @@ -16,6 +16,9 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium * MoveJoyStick should use SpeedPercent * renamed GyroSensor rate_and_angle to angle_and_rate + [Kaelin Laundry] + * Added new binary package for Micropython + [Viktor Garske] * Fixed error when using Motor.is_stalled diff --git a/debian/control b/debian/control index d4ea249..c189a01 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Maintainer: ev3dev Python team Section: python Priority: optional Standards-Version: 3.9.8 -Build-Depends: python3-setuptools (>= 0.6b3), python3-all (>= 3.4), debhelper (>= 9), dh-python, python3-pillow +Build-Depends: python3-setuptools (>= 0.6b3), python3-all (>= 3.4), debhelper (>= 9), dh-python, python3-pillow, mpy-cross VCS-Git: https://github.com/ev3dev/ev3dev-lang-python.git VCS-Browser: https://github.com/ev3dev/ev3dev-lang-python @@ -15,3 +15,12 @@ Description: Python language bindings for ev3dev devices on hardware that is supported by ev3dev.org - a minimal Debian distribution optimized for the LEGO MINDSTORMS EV3. + +Package: micropython-ev3dev2 +Architecture: all +Depends: ${misc:Depends}, micropython, micropython-lib +Description: Python language bindings for ev3dev for MicroPython + This package is a pure Python binding to the peripheral + devices on hardware that is supported by ev3dev.org - a + minimal Debian distribution optimized for the LEGO + MINDSTORMS EV3. This package is designed to run on MicroPython. diff --git a/debian/rules b/debian/rules index e97e845..e0f2efe 100755 --- a/debian/rules +++ b/debian/rules @@ -1,12 +1,61 @@ #!/usr/bin/make -f +#export DH_VERBOSE=1 -export PYBUILD_NAME=python3-ev3dev +export PYBUILD_NAME=ev3dev2 VERSION=$(shell dpkg-parsechangelog | sed -rne 's,^Version: (.*),\1,p' | sed 's,~,,') +mpy_files = \ + ev3dev2/__init__.mpy \ + ev3dev2/_platform/__init__.mpy \ + ev3dev2/_platform/brickpi.mpy \ + ev3dev2/_platform/brickpi3.mpy \ + ev3dev2/_platform/ev3.mpy \ + ev3dev2/_platform/evb.mpy \ + ev3dev2/_platform/fake.mpy \ + ev3dev2/_platform/pistorms.mpy \ + ev3dev2/auto.mpy \ + ev3dev2/button.mpy \ + ev3dev2/control/__init__.mpy \ + ev3dev2/control/GyroBalancer.mpy \ + ev3dev2/control/rc_tank.mpy \ + ev3dev2/control/webserver.mpy \ + ev3dev2/display.mpy \ + ev3dev2/fonts/__init__.mpy \ + ev3dev2/led.mpy \ + ev3dev2/motor.mpy \ + ev3dev2/port.mpy \ + ev3dev2/power.mpy \ + ev3dev2/sensor/__init__.mpy \ + ev3dev2/sensor/lego.mpy \ + ev3dev2/sound.mpy \ + ev3dev2/unit.mpy \ + ev3dev2/version.mpy \ + ev3dev2/wheel.mpy + %: dh $@ --with python3 --buildsystem=pybuild +%.mpy: %.py + mpy-cross -v -v -mcache-lookup-bc $< + +# compile .py > .mpy +override_dh_auto_build: $(mpy_files) + # build python3 package + dh_auto_build + +# fail build if any files aren't installed into a package +override_dh_install: + dh_install --fail-missing + +override_dh_auto_install: + # install python3 package + dh_auto_install + # install .mpy files for micropython + for d in $(mpy_files); do \ + install -D --mode=644 $$d debian/micropython-ev3dev2/usr/lib/micropython/ev3dev2/$${d#*/}; \ + done + override_dh_auto_configure: echo $(VERSION) > RELEASE-VERSION dh_auto_configure From 338c48d1e520b4e5a5b5fb0f364c9e44dd010bf5 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Mon, 31 Dec 2018 08:55:35 -0800 Subject: [PATCH 116/172] Add docs page on port name constants (#585) --- docs/port-names.rst | 38 ++++++++++++++++++++++++++++++++++++++ docs/ports.rst | 3 +++ docs/spec.rst | 30 +++++++++++++++++++----------- 3 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 docs/port-names.rst diff --git a/docs/port-names.rst b/docs/port-names.rst new file mode 100644 index 0000000..039a022 --- /dev/null +++ b/docs/port-names.rst @@ -0,0 +1,38 @@ +.. _port-names: + +Port names +========== + +Classes such as :py:class:`ev3dev2.motor.Motor` and those based on +:py:class:`ev3dev2.sensor.Sensor` accept parameters to specify which port the +target device is connected to. This parameter is typically caled ``address``. + +The following constants are available on all platforms: + +.. rubric:: Output + +- ``ev3dev2.motor.OUTPUT_A`` +- ``ev3dev2.motor.OUTPUT_B`` +- ``ev3dev2.motor.OUTPUT_C`` +- ``ev3dev2.motor.OUTPUT_D`` + +.. rubric:: Input + +- ``ev3dev2.sensor.INPUT_1`` +- ``ev3dev2.sensor.INPUT_2`` +- ``ev3dev2.sensor.INPUT_3`` +- ``ev3dev2.sensor.INPUT_4`` + +Additionally, on BrickPi3, the ports of up to four stacked BrickPi's can be +referenced as `OUTPUT_E` through `OUTPUT_P` and `INPUT_5` through `INPUT_16`. + +.. rubric:: Example + +.. code-block:: python + + from ev3dev2.motor import LargeMotor, OUTPUT_A, OUTPUT_B + from ev3dev2.sensor import INPUT_1 + from ev3dev2.sensor.lego import TouchSensor + + m = LargeMotor(OUTPUT_A) + s = TouchSensor(INPUT_1) \ No newline at end of file diff --git a/docs/ports.rst b/docs/ports.rst index 5ebe770..33f5d93 100644 --- a/docs/ports.rst +++ b/docs/ports.rst @@ -1,5 +1,8 @@ Lego Port ========= +The `LegoPort` class is only needed when manually reconfiguring input/output +ports. Most users can ignore this page. + .. autoclass:: ev3dev2.port.LegoPort :members: \ No newline at end of file diff --git a/docs/spec.rst b/docs/spec.rst index b66323b..1247c80 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -1,17 +1,8 @@ API reference ============= -Each class in ev3dev module inherits from the base :py:class:`ev3dev2.Device` class. - -.. autoclass:: ev3dev2.Device - -.. autofunction:: ev3dev2.list_device_names - -.. autofunction:: ev3dev2.list_devices - -.. autofunction:: ev3dev2.motor.list_motors - -.. autofunction:: ev3dev2.sensor.list_sensors +Device interfaces +----------------- .. rubric:: Contents: @@ -26,4 +17,21 @@ Each class in ev3dev module inherits from the base :py:class:`ev3dev2.Device` cl sound display ports + port-names wheels + + +Other APIs +---------- + +Each class in ev3dev module inherits from the base :py:class:`ev3dev2.Device` class. + +.. autoclass:: ev3dev2.Device + +.. autofunction:: ev3dev2.list_device_names + +.. autofunction:: ev3dev2.list_devices + +.. autofunction:: ev3dev2.motor.list_motors + +.. autofunction:: ev3dev2.sensor.list_sensors \ No newline at end of file From b7bf3f32ee01426ffcf6805843ab3e5716b03e97 Mon Sep 17 00:00:00 2001 From: Remco van 't Veer Date: Sun, 20 Jan 2019 19:36:47 +0100 Subject: [PATCH 117/172] Rename namespace: ev3dev -> ev3dev2 (#590) This file was missed in PR #412. --- ev3dev2/control/GyroBalancer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ev3dev2/control/GyroBalancer.py b/ev3dev2/control/GyroBalancer.py index 1d516e6..1d4929e 100644 --- a/ev3dev2/control/GyroBalancer.py +++ b/ev3dev2/control/GyroBalancer.py @@ -35,10 +35,10 @@ import math import signal from collections import deque -from ev3dev.power import PowerSupply -from ev3dev.motor import LargeMotor, OUTPUT_A, OUTPUT_D -from ev3dev.sensor.lego import GyroSensor, TouchSensor -from ev3dev.sound import Sound +from ev3dev2.power import PowerSupply +from ev3dev2.motor import LargeMotor, OUTPUT_A, OUTPUT_D +from ev3dev2.sensor.lego import GyroSensor, TouchSensor +from ev3dev2.sound import Sound from collections import OrderedDict log = logging.getLogger(__name__) From e9a1598802e24d6c353b5416fd0588005f9421dc Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 2 Feb 2019 01:58:33 -0800 Subject: [PATCH 118/172] Update changelog for v2.0.0-beta3 --- debian/changelog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index b84bedb..b6b8b3e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -python-ev3dev2 (2.0.0~beta3) UNRELEASED; urgency=medium +python-ev3dev2 (2.0.0~beta3) stable; urgency=medium [Daniel Walton] * brickpi(3) support use of the Motor class @@ -22,7 +22,7 @@ python-ev3dev2 (2.0.0~beta3) UNRELEASED; urgency=medium [Viktor Garske] * Fixed error when using Motor.is_stalled - -- Kaelin Laundry Sat, 27 Oct 2018 21:18:00 -0700 + -- Kaelin Laundry Sat, 2 Feb 2019 1:58:00 -0800 python-ev3dev2 (2.0.0~beta2) stable; urgency=medium From daad42a0eb8b29e2fd275df8ccace34defb09bc7 Mon Sep 17 00:00:00 2001 From: fractal13 Date: Wed, 20 Mar 2019 14:17:32 -0600 Subject: [PATCH 119/172] Allow Display.text_pixels() to receive a loaded font or a font string. (#601) * Allow Display.text_pixels() to receive a loaded font or a font string. This will allow the users to preload a font and eliminate the constant reloading of fonts, which can produce lag when displaying in a loop. * doc-string for Display.text_pixels and Display.text_grid updated for sphinx. --- ev3dev2/display.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 6c91ec7..67b845b 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -365,6 +365,7 @@ def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', fon Display `text` starting at pixel (x, y). The EV3 display is 178x128 pixels + - (0, 0) would be the top left corner of the display - (89, 64) would be right in the middle of the display @@ -374,15 +375,22 @@ def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', fon https://www.w3schools.com/colors/colors_names.asp 'font' : can be any font displayed here - http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/other.html#bitmap-fonts + http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/display.html#bitmap-fonts + + - If font is a string, it is the name of a font to be loaded. + - If font is a Font object, returned from :meth:`ev3dev2.fonts.load`, then it is + used directly. This is desirable for faster display times. + """ if clear_screen: self.clear() if font is not None: - assert font in fonts.available(), "%s is an invalid font" % font - return self.draw.text((x, y), text, fill=text_color, font=fonts.load(font)) + if isinstance(font, str): + assert font in fonts.available(), "%s is an invalid font" % font + font = fonts.load(font) + return self.draw.text((x, y), text, fill=text_color, font=font) else: return self.draw.text((x, y), text, fill=text_color) @@ -400,7 +408,12 @@ def text_grid(self, text, clear_screen=True, x=0, y=0, text_color='black', font= https://www.w3schools.com/colors/colors_names.asp 'font' : can be any font displayed here - http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/other.html#bitmap-fonts + http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/display.html#bitmap-fonts + + - If font is a string, it is the name of a font to be loaded. + - If font is a Font object, returned from :meth:`ev3dev2.fonts.load`, then it is + used directly. This is desirable for faster display times. + """ assert 0 <= x < Display.GRID_COLUMNS,\ From f75f1a525eedfe84ff97909116ab6bf0f03880a6 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 24 Mar 2019 00:54:27 -0400 Subject: [PATCH 120/172] =?UTF-8?q?Revert=20"wait=5Funtil=5Fnot=5Fmoving?= =?UTF-8?q?=20should=20consider=20'running=20holding'=20as=20'n=E2=80=A6?= =?UTF-8?q?=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "wait_until_not_moving should consider 'running holding' as 'not moving' (#548)" This reverts commit 95ccb36053db5099dab9a47f9b7cf3b5d7f4e3ea. For issue #605 * Restore changelog entry * Updated changelog --- debian/changelog | 7 +++++++ ev3dev2/motor.py | 15 +++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/debian/changelog b/debian/changelog index b6b8b3e..b728abe 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium + + [Daniel Walton] + * wait_until_not_moving should consider "running holding" as "moving" + + -- Kaelin Laundry Sun, 24 Mar 2019 00:25:00 -0800 + python-ev3dev2 (2.0.0~beta3) stable; urgency=medium [Daniel Walton] diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index c5c18ea..6f79500 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -875,13 +875,10 @@ def wait(self, cond, timeout=None): def wait_until_not_moving(self, timeout=None): """ - Blocks until one of the following conditions are met: - - ``running`` is not in ``self.state`` - - ``stalled`` is in ``self.state`` - - ``holding`` is in ``self.state`` - The condition is checked when there is an I/O event related to - the ``state`` attribute. Exits early when ``timeout`` (in - milliseconds) is reached. + Blocks until ``running`` is not in ``self.state`` or ``stalled`` is in + ``self.state``. The condition is checked when there is an I/O event + related to the ``state`` attribute. Exits early when ``timeout`` + (in milliseconds) is reached. Returns ``True`` if the condition is met, and ``False`` if the timeout is reached. @@ -890,9 +887,7 @@ def wait_until_not_moving(self, timeout=None): m.wait_until_not_moving() """ - return self.wait( - lambda state: self.STATE_RUNNING not in state or self.STATE_STALLED in state or self.STATE_HOLDING in state, - timeout) + return self.wait(lambda state: self.STATE_RUNNING not in state or self.STATE_STALLED in state, timeout) def wait_until(self, s, timeout=None): """ From 081ed9633b15e9c707a7638e1f570f87014b56d1 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 24 Mar 2019 00:57:23 -0400 Subject: [PATCH 121/172] Sound: fallback to regular case of 'note' if uppercase is not found (#606) * Sound: fallback to regular case of 'note' if uppercase is not found * Updated changelog --- debian/changelog | 1 + ev3dev2/sound.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index b728abe..a3bf766 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * Sound: fallback to regular case of 'note' if uppercase is not found * wait_until_not_moving should consider "running holding" as "moving" -- Kaelin Laundry Sun, 24 Mar 2019 00:25:00 -0800 diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index fbe0fd1..c2597cb 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -237,9 +237,10 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE """ self._validate_play_type(play_type) try: - freq = self._NOTE_FREQUENCIES[note.upper()] + freq = self._NOTE_FREQUENCIES.get(note.upper(), self._NOTE_FREQUENCIES[note]) except KeyError: raise ValueError('invalid note (%s)' % note) + if duration <= 0: raise ValueError('invalid duration (%s)' % duration) if not 0 < volume <= 100: @@ -457,7 +458,8 @@ def beep_args(note, value): Returns: str: the arguments to be passed to the beep command """ - freq = self._NOTE_FREQUENCIES[note.upper()] + freq = self._NOTE_FREQUENCIES.get(note.upper(), self._NOTE_FREQUENCIES[note]) + if '/' in value: base, factor = value.split('/') duration_ms = meas_duration_ms * self._NOTE_VALUES[base] / float(factor) From 195259f1c54b1fecf08257e762f748406acd2538 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 25 Mar 2019 15:12:03 -0500 Subject: [PATCH 122/172] Fix GyroAngle.reset() (#609) * Fix GyroAngle.reset() `Sensor.set_attr_raw()` takes a bytes-like object for the `value` parameter. Also add a comment about where the value comes from while we are at it. Note: this requires a kernel driver fix before the reset will actually work See ev3dev/ev3dev#1236. Suggested-by: @matskew Fixes: #510 * Document GyroSensor.reset() * Don't set mode in GyroSensor.reset() Drop the check for `GYRO-ANG` mode. This allows the function to be used with `GYRO-G&A` mode as well. Tested working on a 22N5 sensor. * changelog: add entry for GyroSensor.reset() fixes --- debian/changelog | 3 +++ ev3dev2/sensor/lego.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index a3bf766..cae4d35 100644 --- a/debian/changelog +++ b/debian/changelog @@ -4,6 +4,9 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium * Sound: fallback to regular case of 'note' if uppercase is not found * wait_until_not_moving should consider "running holding" as "moving" + [David Lechner] + * Fix and document ev3dev2.sensors.lego.GyroSensor.reset() + -- Kaelin Laundry Sun, 24 Mar 2019 00:25:00 -0800 python-ev3dev2 (2.0.0~beta3) stable; urgency=medium diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index f0083e9..67d0339 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -623,8 +623,15 @@ def tilt_rate(self): return self.value(0) def reset(self): - self._ensure_mode(self.MODE_GYRO_ANG) - self._direct = self.set_attr_raw(self._direct, 'direct', 17) + """Resets the angle to 0. + + Caveats: + - This function only resets the angle to 0, it does not fix drift. + - This function only works on EV3, it does not work on BrickPi, + PiStorms, or with any sensor multiplexors. + """ + # 17 comes from inspecting the .vix file of the Gyro sensor block in EV3-G + self._direct = self.set_attr_raw(self._direct, 'direct', bytes(17,)) def wait_until_angle_changed_by(self, delta, direction_sensitive=False): """ From fecbe4b3ccb160516dd7453711fa73542592c3c1 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 28 Mar 2019 12:42:41 -0400 Subject: [PATCH 123/172] LED animation support (#567) * Resolves #357 and #324 --- debian/changelog | 1 + ev3dev2/_platform/brickpi.py | 2 + ev3dev2/_platform/brickpi3.py | 8 +- ev3dev2/_platform/ev3.py | 2 + ev3dev2/_platform/evb.py | 1 + ev3dev2/_platform/fake.py | 1 + ev3dev2/_platform/pistorms.py | 8 +- ev3dev2/led.py | 315 +++++++++++++++++++++++++++++++--- ev3dev2/motor.py | 1 + 9 files changed, 310 insertions(+), 29 deletions(-) diff --git a/debian/changelog b/debian/changelog index cae4d35..15b5685 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * LED animation support, brickpi3 LED corrected from BLUE to AMBER * Sound: fallback to regular case of 'note' if uppercase is not found * wait_until_not_moving should consider "running holding" as "moving" diff --git a/ev3dev2/_platform/brickpi.py b/ev3dev2/_platform/brickpi.py index 583442b..47768ae 100644 --- a/ev3dev2/_platform/brickpi.py +++ b/ev3dev2/_platform/brickpi.py @@ -52,3 +52,5 @@ LED_COLORS = OrderedDict() LED_COLORS['BLACK'] = (0,) LED_COLORS['BLUE'] = (1,) + +LED_DEFAULT_COLOR = 'BLUE' diff --git a/ev3dev2/_platform/brickpi3.py b/ev3dev2/_platform/brickpi3.py index 4156560..25a414c 100644 --- a/ev3dev2/_platform/brickpi3.py +++ b/ev3dev2/_platform/brickpi3.py @@ -45,11 +45,13 @@ EVDEV_DEVICE_NAME = None LEDS = OrderedDict() -LEDS['blue_led'] = 'led0:blue:brick-status' +LEDS['amber_led'] = 'led1:amber:brick-status' LED_GROUPS = OrderedDict() -LED_GROUPS['LED'] = ('blue_led',) +LED_GROUPS['LED'] = ('amber_led',) LED_COLORS = OrderedDict() LED_COLORS['BLACK'] = (0,) -LED_COLORS['BLUE'] = (1,) +LED_COLORS['AMBER'] = (1,) + +LED_DEFAULT_COLOR = 'AMBER' diff --git a/ev3dev2/_platform/ev3.py b/ev3dev2/_platform/ev3.py index f186a4d..6bf166b 100644 --- a/ev3dev2/_platform/ev3.py +++ b/ev3dev2/_platform/ev3.py @@ -58,3 +58,5 @@ LED_COLORS['AMBER'] = (1, 1) LED_COLORS['ORANGE'] = (1, 0.5) LED_COLORS['YELLOW'] = (0.1, 1) + +LED_DEFAULT_COLOR = 'GREEN' diff --git a/ev3dev2/_platform/evb.py b/ev3dev2/_platform/evb.py index 62297cf..9350470 100644 --- a/ev3dev2/_platform/evb.py +++ b/ev3dev2/_platform/evb.py @@ -20,3 +20,4 @@ LEDS = {} LED_GROUPS = {} LED_COLORS = {} +LED_DEFAULT_COLOR = '' diff --git a/ev3dev2/_platform/fake.py b/ev3dev2/_platform/fake.py index 2780a55..d13a1fd 100644 --- a/ev3dev2/_platform/fake.py +++ b/ev3dev2/_platform/fake.py @@ -15,3 +15,4 @@ LEDS = {} LED_GROUPS = {} LED_COLORS = {} +LED_DEFAULT_COLOR = '' diff --git a/ev3dev2/_platform/pistorms.py b/ev3dev2/_platform/pistorms.py index 40d556e..38a4f91 100644 --- a/ev3dev2/_platform/pistorms.py +++ b/ev3dev2/_platform/pistorms.py @@ -36,6 +36,8 @@ LED_COLORS['RED'] = (1, 0, 0) LED_COLORS['GREEN'] = (0, 1, 0) LED_COLORS['BLUE'] = (0, 0, 1) -LED_COLORS['YELLOW'] = (1, 1, 0) -LED_COLORS['CYAN'] = (0, 1, 1) -LED_COLORS['MAGENTA'] = (1, 0, 1) \ No newline at end of file +LED_COLORS['YELLOW'] = (1, 1, 0) +LED_COLORS['CYAN'] = (0, 1, 1) +LED_COLORS['MAGENTA'] = (1, 0, 1) + +LED_DEFAULT_COLOR = 'GREEN' diff --git a/ev3dev2/led.py b/ev3dev2/led.py index 69b516e..c41b47c 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -28,37 +28,69 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') +import datetime as dt import os import stat import time +import _thread from collections import OrderedDict from ev3dev2 import get_current_platform, Device +from time import sleep # Import the LED settings, this is platform specific platform = get_current_platform() if platform == 'ev3': - from ev3dev2._platform.ev3 import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.ev3 import LEDS, LED_GROUPS, LED_COLORS, LED_DEFAULT_COLOR elif platform == 'evb': - from ev3dev2._platform.evb import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.evb import LEDS, LED_GROUPS, LED_COLORS, LED_DEFAULT_COLOR elif platform == 'pistorms': - from ev3dev2._platform.pistorms import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.pistorms import LEDS, LED_GROUPS, LED_COLORS, LED_DEFAULT_COLOR elif platform == 'brickpi': - from ev3dev2._platform.brickpi import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.brickpi import LEDS, LED_GROUPS, LED_COLORS, LED_DEFAULT_COLOR elif platform == 'brickpi3': - from ev3dev2._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.brickpi3 import LEDS, LED_GROUPS, LED_COLORS, LED_DEFAULT_COLOR elif platform == 'fake': - from ev3dev2._platform.fake import LEDS, LED_GROUPS, LED_COLORS + from ev3dev2._platform.fake import LEDS, LED_GROUPS, LED_COLORS, LED_DEFAULT_COLOR else: raise Exception("Unsupported platform '%s'" % platform) +def datetime_delta_to_ms(delta): + """ + Given a datetime.timedelta object, return the delta in milliseconds + """ + delta_ms = delta.days * 24 * 60 * 60 * 1000 + delta_ms += delta.seconds * 1000 + delta_ms += delta.microseconds / 1000 + delta_ms = int(delta_ms) + return delta_ms + + +def datetime_delta_to_seconds(delta): + return int(datetime_delta_to_ms(delta) / 1000) + + +def duration_expired(start_time, duration_seconds): + """ + Return True if ``duration_seconds`` have expired since ``start_time`` + """ + + if duration_seconds is not None: + delta_seconds = datetime_delta_to_seconds(dt.datetime.now() - start_time) + + if delta_seconds >= duration_seconds: + return True + + return False + + class Led(Device): """ Any device controlled by the generic LED driver. @@ -81,15 +113,14 @@ class Led(Device): def __init__(self, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, desc=None, **kwargs): + self.desc = desc super(Led, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) - self._max_brightness = None self._brightness = None self._triggers = None self._trigger = None self._delay_on = None self._delay_off = None - self.desc = desc def __str__(self): if self.desc: @@ -102,7 +133,7 @@ def max_brightness(self): """ Returns the maximum allowable brightness value. """ - self._max_brightness, value = self.get_attr_int(self._max_brightness, 'max_brightness') + self._max_brightness, value = self.get_cached_attr_int(self._max_brightness, 'max_brightness') return value @property @@ -128,11 +159,11 @@ def triggers(self): @property def trigger(self): """ - Sets the led trigger. A trigger - is a kernel based source of led events. Triggers can either be simple or - complex. A simple trigger isn't configurable and is designed to slot into - existing subsystems with minimal additional code. Examples are the `ide-disk` and - `nand-disk` triggers. + Sets the LED trigger. A trigger is a kernel based source of LED events. + Triggers can either be simple or complex. A simple trigger isn't + configurable and is designed to slot into existing subsystems with + minimal additional code. Examples are the `ide-disk` and `nand-disk` + triggers. Complex triggers whilst available to all LEDs have LED specific parameters and work on a per LED basis. The `timer` trigger is an example. @@ -257,7 +288,7 @@ def delay_off(self, value): @property def brightness_pct(self): """ - Returns led brightness as a fraction of max_brightness + Returns LED brightness as a fraction of max_brightness """ return float(self.brightness) / self.max_brightness @@ -272,6 +303,8 @@ def __init__(self): self.leds = OrderedDict() self.led_groups = OrderedDict() self.led_colors = LED_COLORS + self.animate_thread_id = None + self.animate_thread_stop = False for (key, value) in LEDS.items(): self.leds[key] = Led(name_pattern=value, desc=key) @@ -287,15 +320,15 @@ def __str__(self): def set_color(self, group, color, pct=1): """ - Sets brigthness of leds in the given group to the values specified in - color tuple. When percentage is specified, brightness of each led is + Sets brightness of LEDs in the given group to the values specified in + color tuple. When percentage is specified, brightness of each LED is reduced proportionally. Example:: my_leds = Leds() my_leds.set_color('LEFT', 'AMBER') - + With a custom color:: my_leds = Leds() @@ -307,17 +340,21 @@ def set_color(self, group, color, pct=1): color_tuple = color if isinstance(color, str): - assert color in self.led_colors, "%s is an invalid LED color, valid choices are %s" % (color, ','.join(self.led_colors.keys())) + assert color in self.led_colors, \ + "%s is an invalid LED color, valid choices are %s" % \ + (color, ', '.join(self.led_colors.keys())) color_tuple = self.led_colors[color] - assert group in self.led_groups, "%s is an invalid LED group, valid choices are %s" % (group, ','.join(self.led_groups.keys())) + assert group in self.led_groups, \ + "%s is an invalid LED group, valid choices are %s" % \ + (group, ', '.join(self.led_groups.keys())) for led, value in zip(self.led_groups[group], color_tuple): led.brightness_pct = value * pct def set(self, group, **kwargs): """ - Set attributes for each led in group. + Set attributes for each LED in group. Example:: @@ -329,7 +366,9 @@ def set(self, group, **kwargs): if not self.leds: return - assert group in self.led_groups, "%s is an invalid LED group, valid choices are %s" % (group, ','.join(self.led_groups.keys())) + assert group in self.led_groups, \ + "%s is an invalid LED group, valid choices are %s" % \ + (group, ', '.join(self.led_groups.keys())) for led in self.led_groups[group]: for k in kwargs: @@ -337,7 +376,7 @@ def set(self, group, **kwargs): def all_off(self): """ - Turn all leds off + Turn all LEDs off """ # If this is a platform without LEDs there is nothing to do @@ -346,3 +385,233 @@ def all_off(self): for led in self.leds.values(): led.brightness = 0 + + def reset(self): + """ + Put all LEDs back to their default color + """ + + if not self.leds: + return + + self.animate_stop() + + for group in self.led_groups: + self.set_color(group, LED_DEFAULT_COLOR) + + def animate_stop(self): + """ + Signal the current animation thread to exit and wait for it to exit + """ + + if self.animate_thread_id: + self.animate_thread_stop = True + + while self.animate_thread_id: + pass + + def animate_police_lights(self, color1, color2, group1='LEFT', group2='RIGHT', sleeptime=0.5, duration=5, block=True): + """ + Cycle the ``group1`` and ``group2`` LEDs between ``color1`` and ``color2`` + to give the effect of police lights. Alternate the ``group1`` and ``group2`` + LEDs every ``sleeptime`` seconds. + + Animate for ``duration`` seconds. If ``duration`` is None animate for forever. + + Example: + + .. code-block:: python + + from ev3dev2.led import Leds + leds = Leds() + leds.animate_police_lights('RED', 'GREEN', sleeptime=0.75, duration=10) + """ + + def _animate_police_lights(): + self.all_off() + even = True + start_time = dt.datetime.now() + + while True: + if even: + self.set_color(group1, color1) + self.set_color(group2, color2) + else: + self.set_color(group1, color2) + self.set_color(group2, color1) + + if self.animate_thread_stop or duration_expired(start_time, duration): + break + + even = not even + sleep(sleeptime) + + self.animate_thread_stop = False + self.animate_thread_id = None + + self.animate_stop() + + if block: + _animate_police_lights() + else: + self.animate_thread_id = _thread.start_new_thread(_animate_police_lights, ()) + + def animate_flash(self, color, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duration=5, block=True): + """ + Turn all LEDs in ``groups`` off/on to ``color`` every ``sleeptime`` seconds + + Animate for ``duration`` seconds. If ``duration`` is None animate for forever. + + Example: + + .. code-block:: python + + from ev3dev2.led import Leds + leds = Leds() + leds.animate_flash('AMBER', sleeptime=0.75, duration=10) + """ + + def _animate_flash(): + even = True + start_time = dt.datetime.now() + + while True: + if even: + for group in groups: + self.set_color(group, color) + else: + self.all_off() + + if self.animate_thread_stop or duration_expired(start_time, duration): + break + + even = not even + sleep(sleeptime) + + self.animate_thread_stop = False + self.animate_thread_id = None + + self.animate_stop() + + if block: + _animate_flash() + else: + self.animate_thread_id = _thread.start_new_thread(_animate_flash, ()) + + def animate_cycle(self, colors, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duration=5, block=True): + """ + Cycle ``groups`` LEDs through ``colors``. Do this in a loop where + we display each color for ``sleeptime`` seconds. + + Animate for ``duration`` seconds. If ``duration`` is None animate for forever. + + Example: + + .. code-block:: python + + from ev3dev2.led import Leds + leds = Leds() + leds.animate_cyle(('RED', 'GREEN', 'AMBER')) + """ + def _animate_cycle(): + index = 0 + max_index = len(colors) + start_time = dt.datetime.now() + + while True: + for group in groups: + self.set_color(group, colors[index]) + + index += 1 + + if index == max_index: + index = 0 + + if self.animate_thread_stop or duration_expired(start_time, duration): + break + + sleep(sleeptime) + + self.animate_thread_stop = False + self.animate_thread_id = None + + self.animate_stop() + + if block: + _animate_cycle() + else: + self.animate_thread_id = _thread.start_new_thread(_animate_cycle, ()) + + def animate_rainbow(self, group1='LEFT', group2='RIGHT', increment_by=0.1, sleeptime=0.1, duration=5, block=True): + """ + Gradually fade from one color to the next + + Animate for ``duration`` seconds. If ``duration`` is None animate for forever. + + Example: + + .. code-block:: python + + from ev3dev2.led import Leds + leds = Leds() + leds.animate_rainbow() + """ + + def _animate_rainbow(): + # state 0: (LEFT,RIGHT) from (0,0) to (1,0)...RED + # state 1: (LEFT,RIGHT) from (1,0) to (1,1)...AMBER + # state 2: (LEFT,RIGHT) from (1,1) to (0,1)...GREEN + # state 3: (LEFT,RIGHT) from (0,1) to (0,0)...OFF + state = 0 + left_value = 0 + right_value = 0 + MIN_VALUE = 0 + MAX_VALUE = 1 + self.all_off() + start_time = dt.datetime.now() + + while True: + + if state == 0: + left_value += increment_by + elif state == 1: + right_value += increment_by + elif state == 2: + left_value -= increment_by + elif state == 3: + right_value -= increment_by + else: + raise Exception("Invalid state {}".format(state)) + + # Keep left_value and right_value within the MIN/MAX values + left_value = min(left_value, MAX_VALUE) + right_value = min(right_value, MAX_VALUE) + left_value = max(left_value, MIN_VALUE) + right_value = max(right_value, MIN_VALUE) + + self.set_color(group1, (left_value, right_value)) + self.set_color(group2, (left_value, right_value)) + + if state == 0 and left_value == MAX_VALUE: + state = 1 + elif state == 1 and right_value == MAX_VALUE: + state = 2 + elif state == 2 and left_value == MIN_VALUE: + state = 3 + elif state == 3 and right_value == MIN_VALUE: + state = 0 + + if self.animate_thread_stop or duration_expired(start_time, duration): + break + + sleep(sleeptime) + + self.animate_thread_stop = False + self.animate_thread_id = None + + self.animate_stop() + + if block: + _animate_rainbow() + else: + self.animate_thread_id = _thread.start_new_thread(_animate_rainbow, ()) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 6f79500..b1db3ea 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -2036,6 +2036,7 @@ class MoveDifferential(MoveTank): Example: .. code:: python + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveDifferential, SpeedRPM from ev3dev2.wheel import EV3Tire From b16b826f37990454d275e9aa917bfd5e382669dc Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 28 Mar 2019 15:37:57 -0400 Subject: [PATCH 124/172] Update tests/README to include sphinx-build instructions (#604) * Update tests/README to include sphinx-build instructions * Update tests/README to include sphinx-build instructions * Updated docs.conf and README --- debian/changelog | 1 + docs/conf.py | 4 +++- tests/README.md | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 15b5685..3ad9c85 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * Update tests/README to include sphinx-build instructions * LED animation support, brickpi3 LED corrected from BLUE to AMBER * Sound: fallback to regular case of 'note' if uppercase is not found * wait_until_not_moving should consider "running holding" as "moving" diff --git a/docs/conf.py b/docs/conf.py index 20620bf..5af6eb7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -313,6 +313,8 @@ autodoc_member_order = 'bysource' +suppress_warnings = ['image.nonlocal_uri'] + nitpick_ignore = [ ('py:class', 'ev3dev2.display.FbMem'), ('py:class', 'ev3dev2.button.ButtonBase'), @@ -329,4 +331,4 @@ def setup(app): app.add_config_value('recommonmark_config', { 'enable_eval_rst': True, }, True) - app.add_transform(AutoStructify) \ No newline at end of file + app.add_transform(AutoStructify) diff --git a/tests/README.md b/tests/README.md index f84a677..6f2f00c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,13 +12,20 @@ $ git clone --recursive https://github.com/ev3dev/ev3dev-lang-python.git ``` # Running Tests with CPython (default) -To run the tests: +To run the API tests: ``` $ cd ev3dev-lang-python/ $ chmod -R g+rw ./tests/fake-sys/devices/**/* $ python3 -W ignore::ResourceWarning tests/api_tests.py ``` +To run the docs, docstring, etc tests: +``` +$ sudo apt-get install python3-sphinx python3-sphinx-bootstrap-theme python3-recommonmark +$ cd ev3dev-lang-python/ +$ sudo sphinx-build -nW -b html ./docs/ ./docs/_build/html +``` + If on Windows, the `chmod` command can be ignored. # Running Tests with Micropython From e821b475c4f2558867c847820f4cfef8cc28797e Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sat, 6 Apr 2019 16:05:08 -0400 Subject: [PATCH 125/172] HDMI display support for raspberry pi based platforms (#610) --- debian/changelog | 1 + ev3dev2/display.py | 38 +++++++++++++++++++++++--------------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/debian/changelog b/debian/changelog index 3ad9c85..e34e97a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * Display support via raspberry pi HDMI * Update tests/README to include sphinx-build instructions * LED animation support, brickpi3 LED corrected from BLUE to AMBER * Sound: fallback to regular case of 'note' if uppercase is not found diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 67b845b..41d4ccf 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -47,6 +47,7 @@ except ImportError: log.warning(library_load_warning_message("fcntl", "Display")) + class FbMem(object): """The framebuffer memory object. @@ -114,6 +115,10 @@ class FbBitField(ctypes.Structure): ('msb_right', ctypes.c_uint32), ] + def __str__(self): + return "%s (offset %s, length %s, msg_right %s)" %\ + (self.__class__.__name__, self.offset, self.length, self.msb_right) + """The fb_var_screeninfo struct from fb.h.""" _fields_ = [ @@ -133,6 +138,11 @@ class FbBitField(ctypes.Structure): ('transp', FbBitField), ] + def __str__(self): + return ("%sx%s at (%s,%s), bpp %s, grayscale %s, red %s, green %s, blue %s, transp %s" % + (self.xres, self.yres, self.xoffset, self.yoffset, self.bits_per_pixel, self.grayscale, + self.red, self.green, self.blue, self.transp)) + def __init__(self, fbdev=None): """Create the FbMem framebuffer memory object.""" fid = FbMem._open_fbdev(fbdev) @@ -143,11 +153,6 @@ def __init__(self, fbdev=None): self.var_info = FbMem._get_var_info(fid) self.mmap = fbmmap - def __del__(self): - """Close the FbMem framebuffer memory object.""" - self.mmap.close() - FbMem._close_fbdev(self.fid) - @staticmethod def _open_fbdev(fbdev=None): """Return the framebuffer file descriptor. @@ -160,11 +165,6 @@ def _open_fbdev(fbdev=None): fbfid = os.open(dev, os.O_RDWR) return fbfid - @staticmethod - def _close_fbdev(fbfid): - """Close the framebuffer file descriptor.""" - os.close(fbfid) - @staticmethod def _get_fix_info(fbfid): """Return the fix screen info from the framebuffer file descriptor.""" @@ -209,12 +209,16 @@ def __init__(self, desc='Display'): if self.var_info.bits_per_pixel == 1: im_type = "1" - elif self.var_info.bits_per_pixel == 16: - im_type = "RGB" + elif self.platform == "ev3" and self.var_info.bits_per_pixel == 32: im_type = "L" + + elif self.var_info.bits_per_pixel == 16 or self.var_info.bits_per_pixel == 32: + im_type = "RGB" + else: - raise Exception("Not supported") + raise Exception("Not supported - platform %s with bits_per_pixel %s" % + (self.platform, self.var_info.bits_per_pixel)) self._img = Image.new( im_type, @@ -295,12 +299,16 @@ def update(self): if self.var_info.bits_per_pixel == 1: b = self._img.tobytes("raw", "1;R") self.mmap[:len(b)] = b + elif self.var_info.bits_per_pixel == 16: self.mmap[:] = self._img_to_rgb565_bytes() - elif self.platform == "ev3" and self.var_info.bits_per_pixel == 32: + + elif self.var_info.bits_per_pixel == 32: self.mmap[:] = self._img.convert("RGB").tobytes("raw", "XRGB") + else: - raise Exception("Not supported") + raise Exception("Not supported - platform %s with bits_per_pixel %s" % + (self.platform, self.var_info.bits_per_pixel)) def image_filename(self, filename, clear_screen=True, x1=0, y1=0, x2=None, y2=None): From e41fca4cf4060f98a6ff6156f61181ce8a715649 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 13 Apr 2019 13:12:54 -0500 Subject: [PATCH 126/172] Fix name of EV3 Gyro sensor TILT-ANG mode (#618) Fixes #616 --- ev3dev2/sensor/lego.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 67d0339..1bd1efe 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -568,9 +568,9 @@ class GyroSensor(Sensor): MODE_GYRO_CAL = 'GYRO-CAL' # Newer versions of the Gyro sensor also have an additional second axis - # accessible via the TILT-ANGLE and TILT-RATE modes that is not usable + # accessible via the TILT-ANG and TILT-RATE modes that is not usable # using the official EV3-G blocks - MODE_TILT_ANG = 'TILT-ANGLE' + MODE_TILT_ANG = 'TILT-ANG' MODE_TILT_RATE = 'TILT-RATE' MODES = ( From 73b7b716ee6f5e847b77d093fa280c8acbda5f15 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 17 Apr 2019 22:11:26 -0400 Subject: [PATCH 127/172] MoveDifferential odometry support (#615) * MoveDifferential odometry support, tracks robot's (x,y) position --- debian/changelog | 1 + ev3dev2/motor.py | 191 +++++++++++++++++++++++++++++++++++-- utils/move_differential.py | 43 ++++++++- 3 files changed, 226 insertions(+), 9 deletions(-) diff --git a/debian/changelog b/debian/changelog index e34e97a..71ebfac 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * MoveDifferential odometry support, tracks robot's (x,y) position * Display support via raspberry pi HDMI * Update tests/README to include sphinx-build instructions * LED animation support, brickpi3 LED corrected from BLUE to AMBER diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index b1db3ea..c795ba7 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -25,8 +25,10 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') +import math import select import time +import _thread # python3 uses collections # micropython uses ucollections @@ -36,7 +38,6 @@ from ucollections import OrderedDict from logging import getLogger -from math import atan2, degrees as math_degrees, sqrt, pi from os.path import abspath from ev3dev2 import get_current_platform, Device, list_device_names @@ -2013,6 +2014,9 @@ class MoveDifferential(MoveTank): - drive in an arc (clockwise or counter clockwise) of a specified radius for a specified distance + Odometry can be use to enable driving to specific coordinates and + rotating to a specific angle. + New arguments: wheel_class - Typically a child class of :class:`ev3dev2.wheel.Wheel`. This is used to @@ -2056,6 +2060,21 @@ class MoveDifferential(MoveTank): # Drive in arc to the right along an imaginary circle of radius 150 mm. # Drive for 700 mm around this imaginary circle. mdiff.on_arc_right(SpeedRPM(80), 150, 700) + + # Enable odometry + mdiff.odometry_start() + + # Use odometry to drive to specific coordinates + mdiff.on_to_coordinates(SpeedRPM(40), 300, 300) + + # Use odometry to go back to where we started + mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) + + # Use odometry to rotate in place to 90 degrees + mdiff.turn_to_angle(SpeedRPM(40), 90) + + # Disable odometry + mdiff.odometry_stop() """ def __init__(self, left_motor_port, right_motor_port, @@ -2067,10 +2086,17 @@ def __init__(self, left_motor_port, right_motor_port, self.wheel_distance_mm = wheel_distance_mm # The circumference of the circle made if this robot were to rotate in place - self.circumference_mm = self.wheel_distance_mm * pi + self.circumference_mm = self.wheel_distance_mm * math.pi self.min_circle_radius_mm = self.wheel_distance_mm / 2 + # odometry variables + self.x_pos_mm = 0.0 # robot X position in mm + self.y_pos_mm = 0.0 # robot Y position in mm + self.odometry_thread_run = False + self.odometry_thread_id = None + self.theta = 0.0 + def on_for_distance(self, speed, distance_mm, brake=True, block=True): """ Drive distance_mm @@ -2091,9 +2117,9 @@ def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): # The circle formed at the halfway point between the two wheels is the # circle that must have a radius of radius_mm - circle_outer_mm = 2 * pi * (radius_mm + (self.wheel_distance_mm / 2)) - circle_middle_mm = 2 * pi * radius_mm - circle_inner_mm = 2 * pi * (radius_mm - (self.wheel_distance_mm / 2)) + circle_outer_mm = 2 * math.pi * (radius_mm + (self.wheel_distance_mm / 2)) + circle_middle_mm = 2 * math.pi * radius_mm + circle_inner_mm = 2 * math.pi * (radius_mm - (self.wheel_distance_mm / 2)) if arc_right: # The left wheel is making the larger circle and will move at 'speed' @@ -2159,7 +2185,8 @@ def _turn(self, speed, degrees, brake=True, block=True): # The number of rotations to move distance_mm rotations = distance_mm/self.wheel.circumference_mm - log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % (self, degrees, distance_mm, rotations, degrees)) + log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % + (self, degrees, distance_mm, rotations, degrees)) # If degrees is positive rotate clockwise if degrees > 0: @@ -2182,6 +2209,154 @@ def turn_left(self, speed, degrees, brake=True, block=True): """ self._turn(speed, abs(degrees) * -1, brake, block) + def odometry_coordinates_log(self): + log.debug("%s: odometry angle %s at (%d, %d)" % + (self, math.degrees(self.theta), self.x_pos_mm, self.y_pos_mm)) + + def odometry_start(self, theta_degrees_start=90.0, + x_pos_start=0.0, y_pos_start=0.0, + SLEEP_TIME=0.005): # 5ms + """ + Ported from: + http://seattlerobotics.org/encoder/200610/Article3/IMU%20Odometry,%20by%20David%20Anderson.htm + + A thread is started that will run until the user calls odometry_stop() + which will set odometry_thread_run to False + """ + + def _odometry_monitor(): + left_previous = 0 + right_previous = 0 + self.theta = math.radians(theta_degrees_start) # robot heading + self.x_pos_mm = x_pos_start # robot X position in mm + self.y_pos_mm = y_pos_start # robot Y position in mm + TWO_PI = 2 * math.pi + + while self.odometry_thread_run: + + # sample the left and right encoder counts as close together + # in time as possible + left_current = self.left_motor.position + right_current = self.right_motor.position + + # determine how many ticks since our last sampling + left_ticks = left_current - left_previous + right_ticks = right_current - right_previous + + # Have we moved? + if not left_ticks and not right_ticks: + if SLEEP_TIME: + time.sleep(SLEEP_TIME) + continue + + # log.debug("%s: left_ticks %s (from %s to %s)" % + # (self, left_ticks, left_previous, left_current)) + # log.debug("%s: right_ticks %s (from %s to %s)" % + # (self, right_ticks, right_previous, right_current)) + + # update _previous for next time + left_previous = left_current + right_previous = right_current + + # rotations = distance_mm/self.wheel.circumference_mm + left_rotations = float(left_ticks / self.left_motor.count_per_rot) + right_rotations = float(right_ticks / self.right_motor.count_per_rot) + + # convert longs to floats and ticks to mm + left_mm = float(left_rotations * self.wheel.circumference_mm) + right_mm = float(right_rotations * self.wheel.circumference_mm) + + # calculate distance we have traveled since last sampling + mm = (left_mm + right_mm) / 2.0 + + # accumulate total rotation around our center + self.theta += (right_mm - left_mm) / self.wheel_distance_mm + + # and clip the rotation to plus or minus 360 degrees + self.theta -= float(int(self.theta/TWO_PI) * TWO_PI) + + # now calculate and accumulate our position in mm + self.x_pos_mm += mm * math.cos(self.theta) + self.y_pos_mm += mm * math.sin(self.theta) + + if SLEEP_TIME: + time.sleep(SLEEP_TIME) + + self.odometry_thread_id = None + + self.odometry_thread_run = True + self.odometry_thread_id = _thread.start_new_thread(_odometry_monitor, ()) + + def odometry_stop(self): + """ + Signal the odometry thread to exit and wait for it to exit + """ + + if self.odometry_thread_id: + self.odometry_thread_run = False + + while self.odometry_thread_id: + pass + + def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): + """ + Rotate in place to `angle_target_degrees` at `speed` + """ + assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" + + # Make both target and current angles positive numbers between 0 and 360 + if angle_target_degrees < 0: + angle_target_degrees += 360 + + angle_current_degrees = math.degrees(self.theta) + + if angle_current_degrees < 0: + angle_current_degrees += 360 + + # Is it shorter to rotate to the right or left + # to reach angle_target_degrees? + if angle_current_degrees > angle_target_degrees: + turn_right = True + angle_delta = angle_current_degrees - angle_target_degrees + else: + turn_right = False + angle_delta = angle_target_degrees - angle_current_degrees + + if angle_delta > 180: + angle_delta = 360 - angle_delta + turn_right = not turn_right + + log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % + (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) + self.odometry_coordinates_log() + + if turn_right: + self.turn_right(speed, angle_delta, brake, block) + else: + self.turn_left(speed, angle_delta, brake, block) + + self.odometry_coordinates_log() + + def on_to_coordinates(self, speed, x_target_mm, y_target_mm, brake=True, block=True): + """ + Drive to (`x_target_mm`, `y_target_mm`) coordinates at `speed` + """ + assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" + + # stop moving + self.off(brake='hold') + + # rotate in place so we are pointed straight at our target + x_delta = x_target_mm - self.x_pos_mm + y_delta = y_target_mm - self.y_pos_mm + angle_target_radians = math.atan2(y_delta, x_delta) + angle_target_degrees = math.degrees(angle_target_radians) + self.turn_to_angle(speed, angle_target_degrees, brake=True, block=True) + + # drive in a straight line to the target coordinates + distance_mm = math.sqrt(pow(self.x_pos_mm - x_target_mm, 2) + pow(self.y_pos_mm - y_target_mm, 2)) + self.on_for_distance(speed, distance_mm, brake, block) + class MoveJoystick(MoveTank): """ @@ -2213,8 +2388,8 @@ def on(self, x, y, radius=100.0): self.off() return - vector_length = sqrt(x*x + y*y) - angle = math_degrees(atan2(y, x)) + vector_length = math.sqrt((x * x) + (y * y)) + angle = math.degrees(math.atan2(y, x)) if angle < 0: angle += 360 diff --git a/utils/move_differential.py b/utils/move_differential.py index e1765ef..0466a30 100755 --- a/utils/move_differential.py +++ b/utils/move_differential.py @@ -6,6 +6,7 @@ from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveDifferential, SpeedRPM from ev3dev2.wheel import EV3Tire +from ev3dev2.unit import DistanceFeet from math import pi import logging import sys @@ -22,7 +23,7 @@ ONE_FOOT_CICLE_CIRCUMFERENCE_MM = 2 * pi * ONE_FOOT_CICLE_RADIUS_MM # Testing with RileyRover -# http://www.damienkee.com/home/2013/8/2/rileyrover-ev3-classroom-robot-design.html +# http://www.damienkee.com/rileyrover-ev3-classroom-robot-design/ # # The centers of the wheels are 16 studs apart but this is not the # "effective" wheel seperation. Test drives of circles with @@ -43,3 +44,43 @@ # Test turning in place #mdiff.turn_right(SpeedRPM(40), 180) #mdiff.turn_left(SpeedRPM(40), 180) + +# Test odometry +#mdiff.odometry_start() +#mdiff.odometry_coordinates_log() + +#mdiff.turn_to_angle(SpeedRPM(40), 0) +#mdiff.on_for_distance(SpeedRPM(40), DistanceFeet(2).mm) +#mdiff.turn_right(SpeedRPM(40), 180) +#mdiff.turn_left(SpeedRPM(30), 90) +#mdiff.on_arc_left(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM) + +# Drive in a quarter arc to the right then go back to where you started +#log.info("turn on arc to the right") +#mdiff.on_arc_right(SpeedRPM(40), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM / 4) +#mdiff.odometry_coordinates_log() +#log.info("\n\n\n\n") +#log.info("go back to (0, 0)") +#mdiff.odometry_coordinates_log() +#mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) +#mdiff.turn_to_angle(SpeedRPM(40), 90) + +# Drive in a rectangle +#mdiff.turn_to_angle(SpeedRPM(40), 120) +#mdiff.on_to_coordinates(SpeedRPM(40), 0, DistanceFeet(1).mm) +#mdiff.on_to_coordinates(SpeedRPM(40), DistanceFeet(2).mm, DistanceFeet(1).mm) +#mdiff.on_to_coordinates(SpeedRPM(40), DistanceFeet(2).mm, 0) +#mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) +#mdiff.turn_to_angle(SpeedRPM(40), 90) + + +# Use odometry to drive to specific coordinates +#mdiff.on_to_coordinates(SpeedRPM(40), 600, 300) + +# Now go back to where we started and rotate in place to 90 degrees +#mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) +#mdiff.turn_to_angle(SpeedRPM(40), 90) + + +#mdiff.odometry_coordinates_log() +#mdiff.odometry_stop() From afc98d35004b533dc161a01f7c966e78607d7c1e Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Mon, 22 Apr 2019 08:21:24 -0400 Subject: [PATCH 128/172] Avoid poll(None) if no timeout specified (#622) Resolves issue #583 --- debian/changelog | 1 + ev3dev2/motor.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 71ebfac..f78a4b2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * Avoid race condition due to poll(None) * MoveDifferential odometry support, tracks robot's (x,y) position * Display support via raspberry pi HDMI * Update tests/README to include sphinx-build instructions diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index c795ba7..bc612be 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -865,14 +865,23 @@ def wait(self, cond, timeout=None): self._poll = select.poll() self._poll.register(self._state, select.POLLPRI) + # Set poll timeout to something small. For more details, see + # https://github.com/ev3dev/ev3dev-lang-python/issues/583 + if timeout: + poll_tm = min(timeout, 100) + else: + poll_tm = 100 + while True: + # This check is now done every poll_tm even if poll has nothing to report: if cond(self.state): return True - self._poll.poll(None if timeout is None else timeout) + self._poll.poll(poll_tm) if timeout is not None and time.time() >= tic + timeout / 1000: - return False + # Final check when user timeout is reached + return cond(self.state) def wait_until_not_moving(self, timeout=None): """ From 2be6c87e0b07c4b74cafbf6319c55e0e2662baed Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 12 May 2019 20:11:52 -0400 Subject: [PATCH 129/172] StopWatch class (#631) --- debian/changelog | 1 + ev3dev2/stopwatch.py | 84 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 ev3dev2/stopwatch.py diff --git a/debian/changelog b/debian/changelog index f78a4b2..86627bd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * StopWatch class * Avoid race condition due to poll(None) * MoveDifferential odometry support, tracks robot's (x,y) position * Display support via raspberry pi HDMI diff --git a/ev3dev2/stopwatch.py b/ev3dev2/stopwatch.py new file mode 100644 index 0000000..d9a8fde --- /dev/null +++ b/ev3dev2/stopwatch.py @@ -0,0 +1,84 @@ +""" +A StopWatch class for tracking the amount of time between events +""" + +try: + import datetime as dt + micropython = False +except ImportError: + import utime + micropython = True + + +def get_ticks_ms(): + if micropython: + return utime.ticks_ms() + else: + return int(dt.datetime.timestamp(dt.datetime.now()) * 1000) + + +class StopWatch(object): + + def __init__(self, desc=None): + self.desc = desc + self._value = 0 + self.start_time = None + self.prev_update_time = None + + def __str__(self): + if self.desc is not None: + return self.desc + else: + return self.__class__.__name__ + + def start(self): + assert self.start_time is None, "%s is already running" % self + self.start_time = get_ticks_ms() + + def update(self): + + if self.start_time is None: + return + + current_time = get_ticks_ms() + + if self.prev_update_time is None: + delta = current_time - self.start_time + else: + delta = current_time - self.prev_update_time + + self._value += delta + self.prev_update_time = current_time + + def stop(self): + + if self.start_time is None: + return + + self.update() + self.start_time = None + self.prev_update_time = None + + def reset(self): + self.stop() + self._value = 0 + + @property + def value_ms(self): + """ + Returns the value of the stopwatch in milliseconds + """ + self.update() + return self._value + + @property + def value_hms(self): + """ + Returns the value of the stopwatch in HH:MM:SS.msec format + """ + self.update() + (hours, x) = divmod(int(self._value), 3600000) + (mins, x) = divmod(x, 60000) + (secs, x) = divmod(x, 1000) + + return '%02d:%02d:%02d.%03d' % (hours, mins, secs, x) From 56dc7cad705525da122d5cb314fd2bf49a09d4e5 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 14 May 2019 06:27:21 -0400 Subject: [PATCH 130/172] micropython support for LED animations (#634) micropython support for LED animations --- debian/changelog | 1 + ev3dev2/led.py | 55 ++++++++++++++------------------------------ ev3dev2/stopwatch.py | 12 +++++----- 3 files changed, 24 insertions(+), 44 deletions(-) diff --git a/debian/changelog b/debian/changelog index 86627bd..7d6c431 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * micropython support for LED animations * StopWatch class * Avoid race condition due to poll(None) * MoveDifferential odometry support, tracks robot's (x,y) position diff --git a/ev3dev2/led.py b/ev3dev2/led.py index c41b47c..d6669cc 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -28,13 +28,13 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') -import datetime as dt import os import stat import time import _thread from collections import OrderedDict from ev3dev2 import get_current_platform, Device +from ev3dev2.stopwatch import StopWatch from time import sleep # Import the LED settings, this is platform specific @@ -62,35 +62,6 @@ raise Exception("Unsupported platform '%s'" % platform) -def datetime_delta_to_ms(delta): - """ - Given a datetime.timedelta object, return the delta in milliseconds - """ - delta_ms = delta.days * 24 * 60 * 60 * 1000 - delta_ms += delta.seconds * 1000 - delta_ms += delta.microseconds / 1000 - delta_ms = int(delta_ms) - return delta_ms - - -def datetime_delta_to_seconds(delta): - return int(datetime_delta_to_ms(delta) / 1000) - - -def duration_expired(start_time, duration_seconds): - """ - Return True if ``duration_seconds`` have expired since ``start_time`` - """ - - if duration_seconds is not None: - delta_seconds = datetime_delta_to_seconds(dt.datetime.now() - start_time) - - if delta_seconds >= duration_seconds: - return True - - return False - - class Led(Device): """ Any device controlled by the generic LED driver. @@ -430,7 +401,9 @@ def animate_police_lights(self, color1, color2, group1='LEFT', group2='RIGHT', s def _animate_police_lights(): self.all_off() even = True - start_time = dt.datetime.now() + duration_ms = duration * 1000 + stopwatch = StopWatch() + stopwatch.start() while True: if even: @@ -440,7 +413,7 @@ def _animate_police_lights(): self.set_color(group1, color2) self.set_color(group2, color1) - if self.animate_thread_stop or duration_expired(start_time, duration): + if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: break even = not even @@ -473,7 +446,9 @@ def animate_flash(self, color, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duration def _animate_flash(): even = True - start_time = dt.datetime.now() + duration_ms = duration * 1000 + stopwatch = StopWatch() + stopwatch.start() while True: if even: @@ -482,7 +457,7 @@ def _animate_flash(): else: self.all_off() - if self.animate_thread_stop or duration_expired(start_time, duration): + if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: break even = not even @@ -516,7 +491,9 @@ def animate_cycle(self, colors, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duratio def _animate_cycle(): index = 0 max_index = len(colors) - start_time = dt.datetime.now() + duration_ms = duration * 1000 + stopwatch = StopWatch() + stopwatch.start() while True: for group in groups: @@ -527,7 +504,7 @@ def _animate_cycle(): if index == max_index: index = 0 - if self.animate_thread_stop or duration_expired(start_time, duration): + if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: break sleep(sleeptime) @@ -568,7 +545,9 @@ def _animate_rainbow(): MIN_VALUE = 0 MAX_VALUE = 1 self.all_off() - start_time = dt.datetime.now() + duration_ms = duration * 1000 + stopwatch = StopWatch() + stopwatch.start() while True: @@ -601,7 +580,7 @@ def _animate_rainbow(): elif state == 3 and right_value == MIN_VALUE: state = 0 - if self.animate_thread_stop or duration_expired(start_time, duration): + if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: break sleep(sleeptime) diff --git a/ev3dev2/stopwatch.py b/ev3dev2/stopwatch.py index d9a8fde..3029b84 100644 --- a/ev3dev2/stopwatch.py +++ b/ev3dev2/stopwatch.py @@ -2,16 +2,16 @@ A StopWatch class for tracking the amount of time between events """ -try: - import datetime as dt - micropython = False -except ImportError: +from ev3dev2 import is_micropython + +if is_micropython(): import utime - micropython = True +else: + import datetime as dt def get_ticks_ms(): - if micropython: + if is_micropython(): return utime.ticks_ms() else: return int(dt.datetime.timestamp(dt.datetime.now()) * 1000) From bae544f9f3b5de5a2e4682c59d2af58579d919f3 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 19 May 2019 13:29:33 -0400 Subject: [PATCH 131/172] micropython support for Sound (#632) Resolves #628 --- debian/changelog | 1 + ev3dev2/sound.py | 159 +++++++++++++++++++++++++++-------------------- 2 files changed, 92 insertions(+), 68 deletions(-) diff --git a/debian/changelog b/debian/changelog index 7d6c431..0f9c7d8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * micropython Sound support * micropython support for LED animations * StopWatch class * Avoid race condition due to poll(None) diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index c2597cb..565bc00 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -28,10 +28,13 @@ if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') +from ev3dev2 import is_micropython import os import re -import shlex -from subprocess import check_output, Popen, PIPE + +if not is_micropython(): + import shlex + from subprocess import Popen, PIPE def _make_scales(notes): @@ -44,15 +47,34 @@ def _make_scales(notes): return res + +def get_command_processes(command): + """ + :param string command: a string of command(s) to run that may include pipes + :return: a list of Popen objects + """ + + # We must split command into sub-commands to support pipes + if "|" in command: + command_parts = command.split("|") + else: + command_parts = [command] + + processes = [] + + for command_part in command_parts: + if processes: + processes.append(Popen(shlex.split(command_part), stdin=processes[-1].stdout, stdout=PIPE, stderr=PIPE)) + else: + processes.append(Popen(shlex.split(command_part), stdin=None, stdout=PIPE, stderr=PIPE)) + + return processes + + class Sound(object): """ Support beep, play wav files, or convert text to speech. - Note that all methods of the class spawn system processes and return - subprocess.Popen objects. The methods are asynchronous (they return - immediately after child process was spawned, without waiting for its - completion), but you can call wait() on the returned result. - Examples:: # Play 'bark.wav': @@ -92,6 +114,46 @@ def _validate_play_type(self, play_type): assert play_type in self.PLAY_TYPES, \ "Invalid play_type %s, must be one of %s" % (play_type, ','.join(str(t) for t in self.PLAY_TYPES)) + def _audio_command(self, command, play_type): + if is_micropython(): + + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: + os.system(command) + + elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: + os.system('{} &'.format(command)) + + elif play_type == Sound.PLAY_LOOP: + while True: + os.system(command) + + else: + raise Exception("invalid play_type " % play_type) + + return None + + else: + with open(os.devnull, 'w') as n: + + if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: + processes = get_command_processes(command) + processes[-1].communicate() + processes[-1].wait() + return None + + elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: + processes = get_command_processes(command) + return processes[-1] + + elif play_type == Sound.PLAY_LOOP: + while True: + processes = get_command_processes(command) + processes[-1].communicate() + processes[-1].wait() + + else: + raise Exception("invalid play_type " % play_type) + def beep(self, args='', play_type=PLAY_WAIT_FOR_COMPLETE): """ Call beep command with the provided arguments (if any). @@ -102,19 +164,12 @@ def beep(self, args='', play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``beep`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` - :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise .. _`beep man page`: https://linux.die.net/man/1/beep .. _`linux beep music`: https://www.google.com/search?q=linux+beep+music """ - with open(os.devnull, 'w') as n: - subprocess = Popen(shlex.split('/usr/bin/beep %s' % args), stdout=n) - if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: - subprocess.wait() - return None - else: - return subprocess - + return self._audio_command("/usr/bin/beep %s" % args, play_type) def tone(self, *args, play_type=PLAY_WAIT_FOR_COMPLETE): """ @@ -154,7 +209,7 @@ def tone(self, *args, play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``tone`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` - :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise .. rubric:: tone(frequency, duration) @@ -166,7 +221,7 @@ def tone(self, *args, play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``tone`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` - :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ def play_tone_sequence(tone_sequence): def beep_args(frequency=None, duration=None, delay=None): @@ -201,7 +256,7 @@ def play_tone(self, frequency, duration, delay=0.0, volume=100, :param play_type: The behavior of ``play_tone`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise :raises ValueError: if invalid parameter """ @@ -231,7 +286,7 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE :param play_type: The behavior of ``play_note`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :return: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise :raises ValueError: is invalid parameter (note, duration,...) """ @@ -257,7 +312,7 @@ def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``play_file`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ if not 0 < volume <= 100: raise ValueError('invalid volume (%s)' % volume) @@ -265,26 +320,12 @@ def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): if not wav_file.endswith(".wav"): raise ValueError('invalid sound file (%s), only .wav files are supported' % wav_file) - if not os.path.isfile(wav_file): + if not os.path.exists(wav_file): raise ValueError("%s does not exist" % wav_file) - self.set_volume(volume) self._validate_play_type(play_type) - - with open(os.devnull, 'w') as n: - - if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: - pid = Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) - pid.wait() - - # Do not wait, run in the background - elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: - return Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) - - elif play_type == Sound.PLAY_LOOP: - while True: - pid = Popen(shlex.split('/usr/bin/aplay -q "%s"' % wav_file), stdout=n) - pid.wait() + self.set_volume(volume) + return self._audio_command('/usr/bin/aplay -q "%s"' % wav_file, play_type) def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Speak the given text aloud. @@ -298,33 +339,16 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA :param play_type: The behavior of ``speak`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :returns: When ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ self._validate_play_type(play_type) self.set_volume(volume) - - with open(os.devnull, 'w') as n: - cmd_line = ['/usr/bin/espeak', '--stdout'] + shlex.split(espeak_opts) + [shlex.quote(text)] - aplay_cmd_line = shlex.split('/usr/bin/aplay -q') - - if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: - espeak = Popen(cmd_line, stdout=PIPE) - play = Popen(aplay_cmd_line, stdin=espeak.stdout, stdout=n) - play.wait() - - elif play_type == Sound.PLAY_NO_WAIT_FOR_COMPLETE: - espeak = Popen(cmd_line, stdout=PIPE) - return Popen(aplay_cmd_line, stdin=espeak.stdout, stdout=n) - - elif play_type == Sound.PLAY_LOOP: - while True: - espeak = Popen(cmd_line, stdout=PIPE) - play = Popen(aplay_cmd_line, stdin=espeak.stdout, stdout=n) - play.wait() + cmd = "/usr/bin/espeak --stdout %s '%s' | /usr/bin/aplay -q" % (espeak_opts, text) + return self._audio_command(cmd, play_type) def _get_channel(self): """ - :returns: The detected sound channel + :return: The detected sound channel :rtype: string """ if self.channel is None: @@ -334,10 +358,10 @@ def _get_channel(self): # # Simple mixer control 'Master',0 # Simple mixer control 'Capture',0 - out = check_output(['amixer', 'scontrols']).decode() - m = re.search(r"'(?P[^']+)'", out) + out = os.popen('/usr/bin/amixer scontrols').read() + m = re.search(r"'([^']+)'", out) if m: - self.channel = m.group('channel') + self.channel = m.group(1) else: self.channel = 'Playback' @@ -355,8 +379,7 @@ def set_volume(self, pct, channel=None): if channel is None: channel = self._get_channel() - cmd_line = '/usr/bin/amixer -q set {0} {1:d}%'.format(channel, pct) - Popen(shlex.split(cmd_line)).wait() + os.system('/usr/bin/amixer -q set {0} {1:d}%'.format(channel, pct)) def get_volume(self, channel=None): """ @@ -370,10 +393,10 @@ def get_volume(self, channel=None): if channel is None: channel = self._get_channel() - out = check_output(['amixer', 'get', channel]).decode() - m = re.search(r'\[(?P\d+)%\]', out) + out = os.popen(['/usr/bin/amixer', 'get', channel]).read() + m = re.search(r'\[(\d+)%\]', out) if m: - return int(m.group('volume')) + return int(m.group(1)) else: raise Exception('Failed to parse output of `amixer get {}`'.format(channel)) @@ -436,7 +459,7 @@ def play_song(self, song, tempo=120, delay=0.05): :param int tempo: the song tempo, given in quarters per minute :param float delay: delay between notes (in seconds) - :return: the spawn subprocess from ``subprocess.Popen`` + :return: When python3 is used the spawn subprocess from ``subprocess.Popen`` is returned; ``None`` otherwise :raises ValueError: if invalid note in song or invalid play parameters """ From 830a6ffb7b37a8d25dfc7e130fbdcbbcdad79231 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 30 May 2019 07:35:44 -0400 Subject: [PATCH 132/172] Micropython buttons support (#639) --- .travis/install-micropython.sh | 4 +- debian/changelog | 1 + ev3dev2/button.py | 467 +++++++++++++++++++++------------ tests/fake-sys | 2 +- 4 files changed, 300 insertions(+), 174 deletions(-) diff --git a/.travis/install-micropython.sh b/.travis/install-micropython.sh index 28e5a82..add6e2d 100755 --- a/.travis/install-micropython.sh +++ b/.travis/install-micropython.sh @@ -19,7 +19,7 @@ make ~/micropython/tools/bootstrap_upip.sh # Install micropython library modules -~/micropython/ports/unix/micropython -m upip install micropython-unittest micropython-os micropython-os.path micropython-shutil micropython-io micropython-fnmatch micropython-numbers micropython-struct micropython-time micropython-logging micropython-select +~/micropython/ports/unix/micropython -m upip install micropython-unittest micropython-os micropython-os.path micropython-shutil micropython-io micropython-fnmatch micropython-numbers micropython-struct micropython-time micropython-logging micropython-select micropython-fcntl # Make unittest module show error output; will run until failure then print first error # See https://github.com/micropython/micropython-lib/blob/f20d89c6aad9443a696561ca2a01f7ef0c8fb302/unittest/unittest.py#L203 -sed -i 's/#raise/raise/g' ~/.micropython/lib/unittest.py \ No newline at end of file +sed -i 's/#raise/raise/g' ~/.micropython/lib/unittest.py diff --git a/debian/changelog b/debian/changelog index 0f9c7d8..e492754 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * micropython Button support * micropython Sound support * micropython support for LED animations * StopWatch class diff --git a/ev3dev2/button.py b/ev3dev2/button.py index 01ada00..5b742e8 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -28,29 +28,11 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') -import array -import time -import logging -from . import get_current_platform, library_load_warning_message - -log = logging.getLogger(__name__) - -try: - # This is a linux-specific module. - # It is required by the Button class, but failure to import it may be - # safely ignored if one just needs to run API tests on Windows. - import fcntl -except ImportError: - log.warning(library_load_warning_message("fcntl", "Button")) - -try: - # This is a linux-specific module. - # It is required by the Button class, but failure to import it may be - # safely ignored if one just needs to run API tests on Windows. - import evdev -except ImportError: - log.warning(library_load_warning_message("evdev", "Button")) +from ev3dev2.stopwatch import StopWatch +from ev3dev2 import get_current_platform, is_micropython, library_load_warning_message +from logging import getLogger +log = getLogger(__name__) # Import the button filenames, this is platform specific platform = get_current_platform() @@ -81,11 +63,7 @@ class MissingButton(Exception): pass -class ButtonBase(object): - """ - Abstract button interface. - """ - _state = set([]) +class ButtonCommon(object): def __str__(self): return self.__class__.__name__ @@ -99,6 +77,10 @@ def on_change(changed_buttons): """ pass + @property + def buttons_pressed(self): + raise NotImplementedError() + def any(self): """ Checks if any button is pressed. @@ -111,81 +93,8 @@ def check_buttons(self, buttons=[]): """ return set(self.buttons_pressed) == set(buttons) - @property - def evdev_device(self): - """ - Return our corresponding evdev device object - """ - devices = [evdev.InputDevice(fn) for fn in evdev.list_devices()] - - for device in devices: - if device.name == self.evdev_device_name: - return device - - raise Exception("%s: could not find evdev device '%s'" % (self, self.evdev_device_name)) - - def process(self, new_state=None): - """ - Check for currenly pressed buttons. If the new state differs from the - old state, call the appropriate button event handlers. - """ - if new_state is None: - new_state = set(self.buttons_pressed) - old_state = self._state - self._state = new_state - - state_diff = new_state.symmetric_difference(old_state) - for button in state_diff: - handler = getattr(self, 'on_' + button) - - if handler is not None: - handler(button in new_state) - - if self.on_change is not None and state_diff: - self.on_change([(button, button in new_state) for button in state_diff]) - - def process_forever(self): - for event in self.evdev_device.read_loop(): - if event.type == evdev.ecodes.EV_KEY: - self.process() - - @property - def buttons_pressed(self): - raise NotImplementedError() - def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): - tic = time.time() - - # wait_for_button_press/release can be a list of buttons or a string - # with the name of a single button. If it is a string of a single - # button convert that to a list. - if isinstance(wait_for_button_press, str): - wait_for_button_press = [wait_for_button_press, ] - - if isinstance(wait_for_button_release, str): - wait_for_button_release = [wait_for_button_release, ] - - for event in self.evdev_device.read_loop(): - if event.type == evdev.ecodes.EV_KEY: - all_pressed = True - all_released = True - pressed = self.buttons_pressed - - for button in wait_for_button_press: - if button not in pressed: - all_pressed = False - break - - for button in wait_for_button_release: - if button in pressed: - all_released = False - break - - if all_pressed and all_released: - return True - - if timeout_ms is not None and time.time() >= tic + timeout_ms / 1000: - return False + raise NotImplementedError() def wait_for_pressed(self, buttons, timeout_ms=None): """ @@ -204,91 +113,42 @@ def wait_for_bump(self, buttons, timeout_ms=None): Wait for the button to be pressed down and then released. Both actions must happen within timeout_ms. """ - start_time = time.time() + stopwatch = StopWatch() + stopwatch.start() if self.wait_for_pressed(buttons, timeout_ms): if timeout_ms is not None: - timeout_ms -= int((time.time() - start_time) * 1000) + timeout_ms -= stopwatch.value_ms return self.wait_for_released(buttons, timeout_ms) return False - -class ButtonEVIO(ButtonBase): - """ - Provides a generic button reading mechanism that works with event interface - and may be adapted to platform specific implementations. - - This implementation depends on the availability of the EVIOCGKEY ioctl - to be able to read the button state buffer. See Linux kernel source - in /include/uapi/linux/input.h for details. - """ - - KEY_MAX = 0x2FF - KEY_BUF_LEN = int((KEY_MAX + 7) / 8) - EVIOCGKEY = (2 << (14 + 8 + 8) | KEY_BUF_LEN << (8 + 8) | ord('E') << 8 | 0x18) - - _buttons = {} - - def __init__(self): - ButtonBase.__init__(self) - self._file_cache = {} - self._buffer_cache = {} - - for b in self._buttons: - name = self._buttons[b]['name'] - - if name is None: - raise MissingButton("Button '%s' is not available on this platform" % b) - - if name not in self._file_cache: - self._file_cache[name] = open(name, 'rb', 0) - self._buffer_cache[name] = array.array('B', [0] * self.KEY_BUF_LEN) - - def _button_file(self, name): - return self._file_cache[name] - - def _button_buffer(self, name): - return self._buffer_cache[name] - - @property - def buttons_pressed(self): + def process(self, new_state=None): """ - Returns list of names of pressed buttons. + Check for currenly pressed buttons. If the new state differs from the + old state, call the appropriate button event handlers (on_up, on_down, etc). """ - for b in self._buffer_cache: - fcntl.ioctl(self._button_file(b), self.EVIOCGKEY, self._buffer_cache[b]) - - pressed = [] - for k, v in self._buttons.items(): - buf = self._buffer_cache[v['name']] - bit = v['value'] + if new_state is None: + new_state = set(self.buttons_pressed) + old_state = self._state + self._state = new_state - if bool(buf[int(bit / 8)] & 1 << bit % 8): - pressed.append(k) + state_diff = new_state.symmetric_difference(old_state) + for button in state_diff: + handler = getattr(self, 'on_' + button) - return pressed + if handler is not None: + handler(button in new_state) + if self.on_change is not None and state_diff: + self.on_change([(button, button in new_state) for button in state_diff]) -class Button(ButtonEVIO): - """ - EVB Buttons - """ - _buttons = { - 'up': {'name': BUTTONS_FILENAME, 'value': 103}, - 'down': {'name': BUTTONS_FILENAME, 'value': 108}, - 'left': {'name': BUTTONS_FILENAME, 'value': 105}, - 'right': {'name': BUTTONS_FILENAME, 'value': 106}, - 'enter': {'name': BUTTONS_FILENAME, 'value': 28}, - 'backspace': {'name': BUTTONS_FILENAME, 'value': 14}, - } - evdev_device_name = EVDEV_DEVICE_NAME +class EV3ButtonCommon(object): - ''' - These handlers are called by `process()` whenever state of 'up', 'down', - etc buttons have changed since last `process()` call - ''' + # These handlers are called by `ButtonCommon.process()` whenever the + # state of 'up', 'down', etc buttons have changed since last + # `ButtonCommon.process()` call on_up = None on_down = None on_left = None @@ -337,3 +197,268 @@ def backspace(self): Check if 'backspace' button is pressed. """ return 'backspace' in self.buttons_pressed + + +# micropython implementation +if is_micropython(): + + try: + # This is a linux-specific module. + # It is required by the Button class, but failure to import it may be + # safely ignored if one just needs to run API tests on Windows. + import fcntl + except ImportError: + log.warning(library_load_warning_message("fcntl", "Button")) + + if platform not in ("ev3", "fake"): + raise Exception("micropython button support has not been implemented for '%s'" % platform) + + + def _test_bit(buf, index): + byte = buf[int(index >> 3)] + bit = byte & (1 << (index % 8)) + return bool(bit) + + + class ButtonBase(ButtonCommon): + pass + + class Button(ButtonCommon, EV3ButtonCommon): + """ + EV3 Buttons + """ + + # Button key codes + UP = 103 + DOWN = 108 + LEFT = 105 + RIGHT = 106 + ENTER = 28 + BACK = 14 + + # Note, this order is intentional and comes from the EV3-G software + _BUTTONS = (UP, DOWN, LEFT, RIGHT, ENTER, BACK) + _BUTTON_DEV = '/dev/input/by-path/platform-gpio_keys-event' + + _BUTTON_TO_STRING = { + UP : "up", + DOWN : "down", + LEFT : "left", + RIGHT : "right", + ENTER : "enter", + BACK : "backspace", + } + + # stuff from linux/input.h and linux/input-event-codes.h + _KEY_MAX = 0x2FF + _KEY_BUF_LEN = (_KEY_MAX + 7) // 8 + _EVIOCGKEY = 2 << (14 + 8 + 8) | _KEY_BUF_LEN << (8 + 8) | ord('E') << 8 | 0x18 + + def __init__(self): + super(Button, self).__init__() + self._devnode = open(Button._BUTTON_DEV, 'b') + self._fd = self._devnode.fileno() + self._buffer = bytearray(Button._KEY_BUF_LEN) + + @property + def buttons_pressed(self): + """ + Returns list of pressed buttons + """ + fcntl.ioctl(self._fd, Button._EVIOCGKEY, self._buffer, mut=True) + + pressed = [] + for b in Button._BUTTONS: + if _test_bit(self._buffer, b): + pressed.append(Button._BUTTON_TO_STRING[b]) + return pressed + + def process_forever(self): + while True: + self.process() + + def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): + stopwatch = StopWatch() + stopwatch.start() + + # wait_for_button_press/release can be a list of buttons or a string + # with the name of a single button. If it is a string of a single + # button convert that to a list. + if isinstance(wait_for_button_press, str): + wait_for_button_press = [wait_for_button_press, ] + + if isinstance(wait_for_button_release, str): + wait_for_button_release = [wait_for_button_release, ] + + while True: + all_pressed = True + all_released = True + pressed = self.buttons_pressed + + for button in wait_for_button_press: + if button not in pressed: + all_pressed = False + break + + for button in wait_for_button_release: + if button in pressed: + all_released = False + break + + if all_pressed and all_released: + return True + + if timeout_ms is not None and stopwatch.value_ms >= timeout_ms: + return False + +# python3 implementation +else: + import array + + try: + # This is a linux-specific module. + # It is required by the Button class, but failure to import it may be + # safely ignored if one just needs to run API tests on Windows. + import fcntl + except ImportError: + log.warning(library_load_warning_message("fcntl", "Button")) + + try: + # This is a linux-specific module. + # It is required by the Button class, but failure to import it may be + # safely ignored if one just needs to run API tests on Windows. + import evdev + except ImportError: + log.warning(library_load_warning_message("evdev", "Button")) + + + class ButtonBase(ButtonCommon): + """ + Abstract button interface. + """ + _state = set([]) + + @property + def evdev_device(self): + """ + Return our corresponding evdev device object + """ + devices = [evdev.InputDevice(fn) for fn in evdev.list_devices()] + + for device in devices: + if device.name == self.evdev_device_name: + return device + + raise Exception("%s: could not find evdev device '%s'" % (self, self.evdev_device_name)) + + def process_forever(self): + for event in self.evdev_device.read_loop(): + if event.type == evdev.ecodes.EV_KEY: + self.process() + + + class ButtonEVIO(ButtonBase): + """ + Provides a generic button reading mechanism that works with event interface + and may be adapted to platform specific implementations. + + This implementation depends on the availability of the EVIOCGKEY ioctl + to be able to read the button state buffer. See Linux kernel source + in /include/uapi/linux/input.h for details. + """ + + KEY_MAX = 0x2FF + KEY_BUF_LEN = int((KEY_MAX + 7) / 8) + EVIOCGKEY = (2 << (14 + 8 + 8) | KEY_BUF_LEN << (8 + 8) | ord('E') << 8 | 0x18) + + _buttons = {} + + def __init__(self): + super(ButtonEVIO, self).__init__() + self._file_cache = {} + self._buffer_cache = {} + + for b in self._buttons: + name = self._buttons[b]['name'] + + if name is None: + raise MissingButton("Button '%s' is not available on this platform" % b) + + if name not in self._file_cache: + self._file_cache[name] = open(name, 'rb', 0) + self._buffer_cache[name] = array.array('B', [0] * self.KEY_BUF_LEN) + + def _button_file(self, name): + return self._file_cache[name] + + def _button_buffer(self, name): + return self._buffer_cache[name] + + @property + def buttons_pressed(self): + """ + Returns list of names of pressed buttons. + """ + for b in self._buffer_cache: + fcntl.ioctl(self._button_file(b), self.EVIOCGKEY, self._buffer_cache[b]) + + pressed = [] + for k, v in self._buttons.items(): + buf = self._buffer_cache[v['name']] + bit = v['value'] + + if bool(buf[int(bit / 8)] & 1 << bit % 8): + pressed.append(k) + + return pressed + + def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): + stopwatch = StopWatch() + stopwatch.start() + + # wait_for_button_press/release can be a list of buttons or a string + # with the name of a single button. If it is a string of a single + # button convert that to a list. + if isinstance(wait_for_button_press, str): + wait_for_button_press = [wait_for_button_press, ] + + if isinstance(wait_for_button_release, str): + wait_for_button_release = [wait_for_button_release, ] + + for event in self.evdev_device.read_loop(): + if event.type == evdev.ecodes.EV_KEY: + all_pressed = True + all_released = True + pressed = self.buttons_pressed + + for button in wait_for_button_press: + if button not in pressed: + all_pressed = False + break + + for button in wait_for_button_release: + if button in pressed: + all_released = False + break + + if all_pressed and all_released: + return True + + if timeout_ms is not None and stopwatch.value_ms >= timeout_ms: + return False + + + class Button(ButtonEVIO, EV3ButtonCommon): + """ + EV3 Buttons + """ + + _buttons = { + 'up': {'name': BUTTONS_FILENAME, 'value': 103}, + 'down': {'name': BUTTONS_FILENAME, 'value': 108}, + 'left': {'name': BUTTONS_FILENAME, 'value': 105}, + 'right': {'name': BUTTONS_FILENAME, 'value': 106}, + 'enter': {'name': BUTTONS_FILENAME, 'value': 28}, + 'backspace': {'name': BUTTONS_FILENAME, 'value': 14}, + } + evdev_device_name = EVDEV_DEVICE_NAME diff --git a/tests/fake-sys b/tests/fake-sys index 2629188..64c4af7 160000 --- a/tests/fake-sys +++ b/tests/fake-sys @@ -1 +1 @@ -Subproject commit 2629188b90e83a086f55b067d16099c5783a2c03 +Subproject commit 64c4af7a029aca4da07f3de7d85e9f7e8d407219 From 0582aee80919f1677085beff05bcbeaf37a099a0 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Thu, 30 May 2019 04:46:30 -0700 Subject: [PATCH 133/172] Initial draft of MicroPython docs (#611) --- README.rst | 12 ++++++++---- docs/index.rst | 1 + docs/micropython.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 docs/micropython.md diff --git a/README.rst b/README.rst index b0dbc37..5978eb2 100644 --- a/README.rst +++ b/README.rst @@ -163,6 +163,12 @@ If you want to make your robot speak, you can use the ``Sound.speak`` method: Make sure to check out the `User Resources`_ section for more detailed information on these features and many others. +Using Micropython +----------------- + +Normal Python too slow? Try `Micropython`_ if it supports the features your +project needs. + User Resources -------------- @@ -236,12 +242,9 @@ but this library is compatible only with Python 3. .. _ev3python.com: http://ev3python.com/ .. _FAQ: http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html .. _our FAQ page: FAQ_ -.. _ev3dev-lang-python: https://github.com/rhempel/ev3dev-lang-python -.. _our Issues tracker: https://github.com/rhempel/ev3dev-lang-python/issues +.. _our Issues tracker: https://github.com/ev3dev/ev3dev-lang-python/issues .. _EXPLOR3R: demo-robot_ .. _demo-robot: http://robotsquare.com/2015/10/06/explor3r-building-instructions/ -.. _demo programs: demo-code_ -.. _demo-code: https://github.com/rhempel/ev3dev-lang-python/tree/master/demo .. _robot-square: http://robotsquare.com/ .. _Python 2.x: python2_ .. _python2: https://docs.python.org/2/ @@ -254,3 +257,4 @@ but this library is compatible only with Python 3. .. _ev3dev Visual Studio Code extension: https://github.com/ev3dev/vscode-ev3dev-browser .. _Python + VSCode introduction tutorial: https://github.com/ev3dev/vscode-hello-python .. _nano: http://www.ev3dev.org/docs/tutorials/nano-cheat-sheet/ +.. _Micropython: http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/micropython.html diff --git a/docs/index.rst b/docs/index.rst index 5cc8837..1d13ed6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,6 +5,7 @@ .. toctree:: :maxdepth: 3 + micropython upgrading-to-stretch spec rpyc diff --git a/docs/micropython.md b/docs/micropython.md new file mode 100644 index 0000000..75b61dd --- /dev/null +++ b/docs/micropython.md @@ -0,0 +1,45 @@ +# Using python-ev3dev with MicroPython + +The core modules of this library are shipped as a module for [MicroPython](https://micropython.org/), +which is faster to load and run on the EV3. If your app only requires functionality supported on +MicroPython, we recommend you run your code with it for improved performance. + +## Module support + +```eval_rst +============================== ================= +Module Support status +============================== ================= +`ev3dev2.button` ️️✔️ +`ev3dev2.control` [1]_ ⚠️ +`ev3dev2.display` ❌ +`ev3dev2.fonts` [2]_ ⚠️ +`ev3dev2.led` ✔️ +`ev3dev2.motor` ✔️ +`ev3dev2.port` ✔️ +`ev3dev2.power` ✔️ +`ev3dev2.sensor.*` ✔️ +`ev3dev2.sound` ✔️ +`ev3dev2.unit` ✔️ +`ev3dev2.wheel` ✔️ +============================== ================= + +.. [1] Untested/low-priority, but some of it might work. +.. [2] It might work, but isn't useful without ``ev3dev2.display``. +``` + +## Differences from standard Python (CPython) + +See [the MicroPython differences page](http://docs.micropython.org/en/latest/genrst/index.html) for language information. + +### Shebang + +You should modify the first line of your scripts to replace "python3" with "micropython": + +``` +#!/usr/bin/env micropython +``` + +### Running from the command line + +If you previously would have typed `python3 foo.py`, you should now type `micropython foo.py`. \ No newline at end of file From 33b1134dd35328698b62381bf64880173420de47 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Wed, 24 Jul 2019 20:58:56 -0400 Subject: [PATCH 134/172] PID Line follower (#630) --- debian/changelog | 1 + ev3dev2/__init__.py | 13 +- ev3dev2/motor.py | 192 ++++++++++++++++++++++++++- utils/line-follower-find-kp-ki-kd.py | 130 ++++++++++++++++++ utils/stop_all_motors.py | 2 +- 5 files changed, 324 insertions(+), 14 deletions(-) create mode 100755 utils/line-follower-find-kp-ki-kd.py diff --git a/debian/changelog b/debian/changelog index e492754..c83eb9a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [Daniel Walton] + * PID line follower * micropython Button support * micropython Sound support * micropython support for LED animations diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 84cddec..2d2895e 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -246,7 +246,7 @@ def _get_attribute(self, attribute, name): attribute.seek(0) return attribute, attribute.read().strip().decode() except Exception as ex: - self._raise_friendly_access_error(ex, name) + self._raise_friendly_access_error(ex, name, None) def _set_attribute(self, attribute, name, value): """Device attribute setter""" @@ -261,10 +261,10 @@ def _set_attribute(self, attribute, name, value): attribute.write(value) attribute.flush() except Exception as ex: - self._raise_friendly_access_error(ex, name) + self._raise_friendly_access_error(ex, name, value) return attribute - def _raise_friendly_access_error(self, driver_error, attribute): + def _raise_friendly_access_error(self, driver_error, attribute, value): if not isinstance(driver_error, OSError): raise driver_error @@ -275,10 +275,11 @@ def _raise_friendly_access_error(self, driver_error, attribute): try: max_speed = self.max_speed except (AttributeError, Exception): - chain_exception(ValueError("The given speed value was out of range"), driver_error) + chain_exception(ValueError("The given speed value {} was out of range".format(value)), + driver_error) else: - chain_exception(ValueError("The given speed value was out of range. Max speed: +/-" + str(max_speed)), driver_error) - chain_exception(ValueError("One or more arguments were out of range or invalid"), driver_error) + chain_exception(ValueError("The given speed value {} was out of range. Max speed: +/-{}".format(value, max_speed)), driver_error) + chain_exception(ValueError("One or more arguments were out of range or invalid, value {}".format(value)), driver_error) elif driver_errorno == errno.ENODEV or driver_errorno == errno.ENOENT: # We will assume that a file-not-found error is the result of a disconnected device # rather than a library error. If that isn't the case, at a minimum the underlying diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index bc612be..4a8e430 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -40,6 +40,7 @@ from logging import getLogger from os.path import abspath from ev3dev2 import get_current_platform, Device, list_device_names +from ev3dev2.stopwatch import StopWatch log = getLogger(__name__) @@ -83,9 +84,24 @@ class SpeedValue(object): :class:`SpeedDPS`, and :class:`SpeedDPM`. """ + def __eq__(self, other): + return self.to_native_units() == other.to_native_units() + + def __ne__(self, other): + return not self.__eq__(other) + def __lt__(self, other): return self.to_native_units() < other.to_native_units() + def __le__(self, other): + return self.to_native_units() <= other.to_native_units() + + def __gt__(self, other): + return self.to_native_units() > other.to_native_units() + + def __ge__(self, other): + return self.to_native_units() >= other.to_native_units() + def __rmul__(self, other): return self.__mul__(other) @@ -124,13 +140,13 @@ def __init__(self, native_counts): self.native_counts = native_counts def __str__(self): - return str(self.native_counts) + " counts/sec" + return "{:.2f}".format(self.native_counts) + " counts/sec" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) return SpeedNativeUnits(self.native_counts * other) - def to_native_units(self, motor): + def to_native_units(self, motor=None): """ Return this SpeedNativeUnits as a number """ @@ -1774,6 +1790,46 @@ def _block(self): self.wait_until_not_moving() +# line follower classes +class LineFollowErrorLostLine(Exception): + """ + Raised when a line following robot has lost the line + """ + pass + + +class LineFollowErrorTooFast(Exception): + """ + Raised when a line following robot has been asked to follow + a line at an unrealistic speed + """ + pass + + +# line follower functions +def follow_for_forever(tank): + """ + ``tank``: the MoveTank object that is following a line + """ + return True + + +def follow_for_ms(tank, ms): + """ + ``tank``: the MoveTank object that is following a line + ``ms`` : the number of milliseconds to follow the line + """ + if not hasattr(tank, 'stopwatch') or tank.stopwatch is None: + tank.stopwatch = StopWatch() + tank.stopwatch.start() + + if tank.stopwatch.value_ms >= ms: + tank.stopwatch = None + return False + else: + return True + + class MoveTank(MotorSet): """ Controls a pair of motors simultaneously, via individual speed setpoints for each motor. @@ -1798,6 +1854,9 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar self.right_motor = self.motors[right_motor_port] self.max_speed = self.left_motor.max_speed + # color sensor used by follow_line() + self.cs = None + def _unpack_speeds_to_native_units(self, left_speed, right_speed): left_speed = self.left_motor._speed_native_units(left_speed, "left_speed") right_speed = self.right_motor._speed_native_units(right_speed, "right_speed") @@ -1921,6 +1980,125 @@ def on(self, left_speed, right_speed): self.left_motor.run_forever() self.right_motor.run_forever() + def follow_line(self, + kp, ki, kd, + speed, + target_light_intensity=None, + follow_left_edge=True, + white=60, + off_line_count_max=20, + sleep_time=0.01, + follow_for=follow_for_forever, + **kwargs + ): + """ + PID line follower + + ``kp``, ``ki``, and ``kd`` are the PID constants. + + ``speed`` is the desired speed of the midpoint of the robot + + ``target_light_intensity`` is the reflected light intensity when the color sensor + is on the edge of the line. If this is None we assume that the color sensor + is on the edge of the line and will take a reading to set this variable. + + ``follow_left_edge`` determines if we follow the left or right edge of the line + + ``white`` is the reflected_light_intensity that is used to determine if we have + lost the line + + ``off_line_count_max`` is how many consecutive times through the loop the + reflected_light_intensity must be greater than ``white`` before we + declare the line lost and raise an exception + + ``sleep_time`` is how many seconds we sleep on each pass through + the loop. This is to give the robot a chance to react + to the new motor settings. This should be something small such + as 0.01 (10ms). + + ``follow_for`` is called to determine if we should keep following the + line or stop. This function will be passed ``self`` (the current + ``MoveTank`` object). Current supported options are: + - ``follow_for_forever`` + - ``follow_for_ms`` + + ``**kwargs`` will be passed to the ``follow_for`` function + + Example: + + .. code:: python + + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent, follow_for_ms + from ev3dev2.sensor.lego import ColorSensor + + tank = MoveTank(OUTPUT_A, OUTPUT_B) + tank.cs = ColorSensor() + + try: + # Follow the line for 4500ms + tank.follow_line( + kp=11.3, ki=0.05, kd=3.2, + speed=SpeedPercent(30), + follow_for=follow_for_ms, + ms=4500 + ) + except Exception: + tank.stop() + raise + """ + assert self.cs, "ColorSensor must be defined" + + if target_light_intensity is None: + target_light_intensity = self.cs.reflected_light_intensity + + integral = 0.0 + last_error = 0.0 + derivative = 0.0 + off_line_count = 0 + speed_native_units = speed.to_native_units(self.left_motor) + MAX_SPEED = SpeedNativeUnits(self.max_speed) + + while follow_for(self, **kwargs): + reflected_light_intensity = self.cs.reflected_light_intensity + error = target_light_intensity - reflected_light_intensity + integral = integral + error + derivative = error - last_error + last_error = error + turn_native_units = (kp * error) + (ki * integral) + (kd * derivative) + + if not follow_left_edge: + turn_native_units *= -1 + + left_speed = SpeedNativeUnits(speed_native_units - turn_native_units) + right_speed = SpeedNativeUnits(speed_native_units + turn_native_units) + + if left_speed > MAX_SPEED: + log.info("%s: left_speed %s is greater than MAX_SPEED %s" % (self, left_speed, MAX_SPEED)) + self.stop() + raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") + + if right_speed > MAX_SPEED: + log.info("%s: right_speed %s is greater than MAX_SPEED %s" % (self, right_speed, MAX_SPEED)) + self.stop() + raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") + + # Have we lost the line? + if reflected_light_intensity >= white: + off_line_count += 1 + + if off_line_count >= off_line_count_max: + self.stop() + raise LineFollowErrorLostLine("we lost the line") + else: + off_line_count = 0 + + if sleep_time: + time.sleep(sleep_time) + + self.on(left_speed, right_speed) + + self.stop() + class MoveSteering(MoveTank): """ @@ -2224,7 +2402,7 @@ def odometry_coordinates_log(self): def odometry_start(self, theta_degrees_start=90.0, x_pos_start=0.0, y_pos_start=0.0, - SLEEP_TIME=0.005): # 5ms + sleep_time=0.005): # 5ms """ Ported from: http://seattlerobotics.org/encoder/200610/Article3/IMU%20Odometry,%20by%20David%20Anderson.htm @@ -2254,8 +2432,8 @@ def _odometry_monitor(): # Have we moved? if not left_ticks and not right_ticks: - if SLEEP_TIME: - time.sleep(SLEEP_TIME) + if sleep_time: + time.sleep(sleep_time) continue # log.debug("%s: left_ticks %s (from %s to %s)" % @@ -2288,8 +2466,8 @@ def _odometry_monitor(): self.x_pos_mm += mm * math.cos(self.theta) self.y_pos_mm += mm * math.sin(self.theta) - if SLEEP_TIME: - time.sleep(SLEEP_TIME) + if sleep_time: + time.sleep(sleep_time) self.odometry_thread_id = None diff --git a/utils/line-follower-find-kp-ki-kd.py b/utils/line-follower-find-kp-ki-kd.py new file mode 100755 index 0000000..ed3bbfc --- /dev/null +++ b/utils/line-follower-find-kp-ki-kd.py @@ -0,0 +1,130 @@ + +""" +This program is used to find the kp, ki, kd PID values for +`MoveTank.follow_line()`. These values vary from robot to robot, the best way +to find them for your robot is to have it follow a line, tweak the values a +little, repeat. + +The default speed for this program is SpeedPercent(30). You can use whatever +speed you want, just search for "speed = SpeedPercent(30)" in this file and +modigy that line. + +You can use the pdf from this site to print a line that makes an oval, just +have your robot follow that oval when running this program. +http://robotsquare.com/2012/11/28/line-following/ +""" + +from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent, LineFollowError, follow_for_ms +from ev3dev2.sensor.lego import ColorSensor +from time import sleep +import logging +import sys + + +def frange(start, end, increment): + """ + range() does not support floats, this frange() does + """ + result = [] + x = start + + while x < end: + result.append(x) + x += increment + + return result + + +def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): + """ + Return the optimal `kx_to_tweak` value where `kx_to_tweak` must be "kp", "ki" or "kd" + This will test values from `start` to `end` in steps of `increment`. The value + that results in the robot moving the least total distance is the optimal value + that is returned by this function. + """ + min_delta = None + min_delta_kx = None + + for kx in frange(start, end, increment): + log.info("%s %s: place robot on line, then press " % (kx_to_tweak, kx)) + input("") + init_left_motor_pos = tank.left_motor.position + + try: + if kx_to_tweak == "kp": + tank.follow_line( + kp=kx, ki=ki, kd=kd, + speed=speed, + follow_for=follow_for_ms, + ms=10000, + ) + + elif kx_to_tweak == "ki": + tank.follow_line( + kp=kp, ki=kx, kd=kd, + speed=speed, + follow_for=follow_for_ms, + ms=10000, + ) + + elif kx_to_tweak == "kd": + tank.follow_line( + kp=kp, ki=ki, kd=kx, + speed=speed, + follow_for=follow_for_ms, + ms=10000, + ) + + else: + raise Exception("Invalid kx_to_tweak %s" % kx_to_tweak) + + except LineFollowError: + continue + + except: + tank.stop() + raise + + final_left_motor_pos = tank.left_motor.position + delta_left_motor_pos = abs(final_left_motor_pos - init_left_motor_pos) + + if min_delta is None or delta_left_motor_pos < min_delta: + min_delta = delta_left_motor_pos + min_delta_kx = kx + log.info("%s: %s %s, left motor moved %s (NEW MIN)" % (tank, kx_to_tweak, kx, delta_left_motor_pos)) + else: + log.info("%s: %s %s, left motor moved %s" % (tank, kx_to_tweak, kx, delta_left_motor_pos)) + + tank.stop() + return min_delta_kx + + +if __name__ == "__main__": + + # logging + logging.basicConfig(level=logging.DEBUG, + format="%(asctime)s %(levelname)5s: %(message)s") + log = logging.getLogger(__name__) + + tank = MoveTank(OUTPUT_A, OUTPUT_B) + tank.cs = ColorSensor() + + speed = SpeedPercent(30) + + # Find the best integer for kp (in increments of 1) then fine tune by + # finding the best float (in increments of 0.1) + kp = find_kp_ki_kd(tank, 1, 20, 1, speed, 'kp', 0, 0, 0) + kp = find_kp_ki_kd(tank, kp - 1, kp + 1, 0.1, speed, 'kp', kp, 0, 0) + print("\n\n\n%s\nkp %s\n%s\n\n\n" % ("" * 10, kp, "*" * 10)) + + # Find the best float ki (in increments of 0.1) + ki = find_kp_ki_kd(tank, 0, 1, 0.1, speed, 'ki', kp, 0, 0) + print("\n\n\n%s\nki %s\n%s\n\n\n" % ("" * 10, ki, "*" * 10)) + + # Find the best integer for kd (in increments of 1) then fine tune by + # finding the best float (in increments of 0.1) + kd = find_kp_ki_kd(tank, 0, 10, 1, speed, 'kd', kp, ki, 0) + kd = find_kp_ki_kd(tank, kd - 1, kd + 1, 0.1, speed, 'kd', kp, ki, 0) + print("\n\n\n%s\nkd %s\n%s\n\n\n" % ("" * 10, kd, "*" * 10)) + + print("Final results: kp %s, ki %s, kd %s" % (kp, ki, kd)) diff --git a/utils/stop_all_motors.py b/utils/stop_all_motors.py index e95a012..1f39a27 100755 --- a/utils/stop_all_motors.py +++ b/utils/stop_all_motors.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env micropython """ Stop all motors From 60309879b52050072d2a3113e24ae18dd714333d Mon Sep 17 00:00:00 2001 From: "Brady P. Merkel" Date: Sun, 28 Jul 2019 10:37:14 -0400 Subject: [PATCH 135/172] Updated README, FAQ, and CONTRIBUTING documentation (#653) * Updated README, FAQ, and CONTRIBUTING documentation * changed to link; per code review; added MoveDifferential * Move `Demo Code` section from FAQ to README * Relocated Demo Code section --- CONTRIBUTING.rst | 9 ++-- README.rst | 126 +++++++++++++++++++---------------------------- docs/faq.rst | 87 +++++++++++++++++++++++++++----- docs/motors.rst | 22 ++++++++- 4 files changed, 148 insertions(+), 96 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 539fc8b..ea7d100 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -10,7 +10,7 @@ take a moment to read our suggestions for happy maintainers and even happier users. The ``ev3dev-stretch`` branch ---------------------- +----------------------------- This is where the latest version of our library lives. It targets `ev3dev-stretch`, which is currently considered a beta. Nonetheless, @@ -39,13 +39,12 @@ notes. If your change addresses an Issue --------------------------------- -Bug fixes are always welcome, especially if they are against known +Bug fixes are always welcome, especially if they are against known issues! -When you send a pull request that addresses an issue, please add a +When you send a pull request that addresses an issue, please add a note of the format ``Fixes #24`` in the PR so that the PR links back to its relevant issue and will automatically close the issue when the PR is merged. -.. _ev3dev: http://ev3dev.org - +.. _ev3dev: http://ev3dev.org \ No newline at end of file diff --git a/README.rst b/README.rst index 5978eb2..3cfeac7 100644 --- a/README.rst +++ b/README.rst @@ -14,17 +14,19 @@ A Python3 library implementing an interface for ev3dev_ devices, letting you control motors, sensors, hardware buttons, LCD displays and more from Python code. -If you haven't written code in Python before, you'll need to learn the language -before you can use this library. +If you haven't written code in Python before, you can certainly use this +library to help you learn the language! Getting Started --------------- This library runs on ev3dev_. Before continuing, make sure that you have set up -your EV3 or other ev3dev device as explained in the `ev3dev Getting Started guide`_. -Make sure you have an ev3dev-stretch version greater than ``2.2.0``. You can check -the kernel version by selecting "About" in Brickman and scrolling down to the -"kernel version". If you don't have a compatible version, `upgrade the kernel before continuing`_. +your EV3 or other ev3dev device as explained in the +`ev3dev Getting Started guide`_. Make sure you have an ev3dev-stretch version +greater than ``2.2.0``. You can check the kernel version by selecting +"About" in Brickman and scrolling down to the "kernel version". +If you don't have a compatible version, +`upgrade the kernel before continuing`_. Usage ----- @@ -36,8 +38,8 @@ once you have that set up. Otherwise, you can can work with files `via an SSH connection`_ with an editor such as `nano`_, use the Python interactive REPL (type ``python3``), or roll -your own solution. If you don't know how to do that, you are probably better off -choosing the recommended option above. +your own solution. If you don't know how to do that, you are probably +better off choosing the recommended option above. The template for a Python script ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -64,16 +66,19 @@ or additional utilities. You should use the ``.py`` extension for your file, e.g. ``my-file.py``. -If you encounter an error such as ``/usr/bin/env: 'python3\r': No such file or directory``, -you must switch your editor's "line endings" setting for the file from "CRLF" to just "LF". -This is usually in the status bar at the bottom. For help, see `our FAQ page`_. +If you encounter an error such as +``/usr/bin/env: 'python3\r': No such file or directory``, +you must switch your editor's "line endings" setting for the file from +"CRLF" to just "LF". This is usually in the status bar at the bottom. +For help, see `our FAQ page`_. Important: Make your script executable (non-Visual Studio Code only) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To be able to run your Python file, **your program must be executable**. If -you are using the `ev3dev Visual Studio Code extension`_, you can skip this step, -as it will be automatically performed when you download your code to the brick. +you are using the `ev3dev Visual Studio Code extension`_, you can skip this +step, as it will be automatically performed when you download your code to the +brick. **To mark a program as executable from the command line (often an SSH session), run** ``chmod +x my-file.py``. @@ -85,8 +90,8 @@ Controlling the LEDs with a touch sensor ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This code will turn the LEDs red whenever the touch sensor is pressed, and -back to green when it's released. Plug a touch sensor into any sensor port before -trying this out. +back to green when it's released. Plug a touch sensor into any sensor port +before trying this out. .. code-block:: python @@ -120,8 +125,8 @@ This will run a LEGO Large Motor at 75% of maximum speed for 5 rotations. m.on_for_rotations(SpeedPercent(75), 5) You can also run a motor for a number of degrees, an amount of time, or simply -start it and let it run until you tell it to stop. Additionally, other units are -also available. See the following pages for more information: +start it and let it run until you tell it to stop. Additionally, other units +are also available. See the following pages for more information: - http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#ev3dev.motor.Motor.on_for_degrees - http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#units @@ -138,12 +143,12 @@ The simplest drive control style is with the `MoveTank` class: # drive in a turn for 5 rotations of the outer motor # the first two parameters can be unit classes or percentages. tank_drive.on_for_rotations(SpeedPercent(50), SpeedPercent(75), 10) - + # drive in a different turn for 3 seconds tank_drive.on_for_seconds(SpeedPercent(60), SpeedPercent(30), 3) -There are also `MoveSteering` and `MoveJoystick` classes which provide different -styles of control. See the following pages for more information: +There are also `MoveSteering` and `MoveJoystick` classes which provide +different styles of control. See the following pages for more information: - http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#multiple-motor-groups - http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/motors.html#units @@ -160,74 +165,43 @@ If you want to make your robot speak, you can use the ``Sound.speak`` method: sound = Sound() sound.speak('Welcome to the E V 3 dev project!') -Make sure to check out the `User Resources`_ section for more detailed -information on these features and many others. +More Demo Code +~~~~~~~~~~~~~~ + +There are several demo programs that you can run to get acquainted with +this language binding. The programs are available +`at this GitHub site `_. + +You can also copy and run the programs in the `utils` directory to +understand some of the code constructs to use the EV3 motors, sensors, +LCD console, buttons, sound, and LEDs. + +We also highly recommend `ev3python.com`_ where one of our community +members, @ndward, has put together a great website with detailed guides +on using this library which are targeted at beginners. If you are just +getting started with programming, we highly recommend that you check +it out at `ev3python.com`_! Using Micropython ----------------- -Normal Python too slow? Try `Micropython`_ if it supports the features your -project needs. - -User Resources --------------- +Normal Python too slow? Review `Micropython`_ to see if it supports the +features your project needs. Library Documentation - **Class documentation for this library can be found on** `our Read the Docs page`_ **.** - You can always go there to get information on how you can use this - library's functionality. +--------------------- -Demo Code - There are several demo programs that you can run to get acquainted with - this language binding. The programs are available at - https://github.com/ev3dev/ev3dev-lang-python-demo +**Class documentation for this library can be found on +** `our Read the Docs page`_ **.** You can always go there to get +information on how you can use this library's functionality. -ev3python.com - One of our community members, @ndward, has put together a great website - with detailed guides on using this library which are targeted at beginners. - If you are just getting started with programming, we highly recommend - that you check it out at `ev3python.com`_! Frequently-Asked Questions - Experiencing an odd error or unsure of how to do something that seems - simple? Check our our `FAQ`_ to see if there's an existing answer. - -ev3dev.org - `ev3dev.org`_ is a great resource for finding guides and tutorials on - using ev3dev, straight from the maintainers. - -Support - If you are having trouble using this library, please open an issue - at `our Issues tracker`_ so that we can help you. When opening an - issue, make sure to include as much information as possible about - what you are trying to do and what you have tried. The issue template - is in place to guide you through this process. - -Upgrading this Library ----------------------- - -You can upgrade this library from the command line as follows. Make sure -to type the password (the default is ``maker``) when prompted. - -.. code-block:: bash - - sudo apt-get update - sudo apt-get install --only-upgrade python3-ev3dev2 - - -Developer Resources -------------------- - -Python Package Index - The Python language has a `package repository`_ where you can find - libraries that others have written, including the `latest version of - this package`_. +-------------------------- -Python 2.x and Python 3.x Compatibility ---------------------------------------- +Experiencing an odd error or unsure of how to do something that seems +simple? Check our our `FAQ`_ to see if there's an existing answer. -Some versions of the ev3dev_ distribution come with both `Python 2.x`_ and `Python 3.x`_ installed -but this library is compatible only with Python 3. .. _ev3dev: http://ev3dev.org .. _ev3dev.org: ev3dev_ diff --git a/docs/faq.rst b/docs/faq.rst index 8d2d630..d01aea9 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1,20 +1,81 @@ Frequently-Asked Questions ========================== -My script works when launched as ``python3 script.py`` but exits immediately or throws an error when launched from Brickman or as ``./script.py`` -------------------------------------------------------------------------------------------------------------------------------------------------- +Q: Why does my Python program exit quickly or immediately throw an error? + A: This may occur if your file includes Windows-style line endings + (CRLF--carriage-return line-feed), which are often inserted by editors on + Windows. To resolve this issue, open an SSH session and run the following + command, replacing ```` with the name of the Python file you're + using: -This may occur if your file includes Windows-style line endings, which are often -inserted by editors on Windows. To resolve this issue, open an SSH session and -run the following command, replacing ```` with the name of the Python file -you're using: + .. code:: shell -.. code:: shell + sed -i 's/\r//g' - sed -i 's/\r//g' + This will fix it for the copy of the file on the brick, but if you plan to edit + it again from Windows, you should configure your editor to use Unix-style + line endings (LF--line-feed). For PyCharm, you can find a guide on doing this + `here `_. + Most other editors have similar options; there may be an option for it in the + status bar at the bottom of the window or in the menu bar at the top. -This will fix it for the copy of the file on the brick, but if you plan to edit -it again from Windows you should configure your editor to use Unix-style endings. -For PyCharm, you can find a guide on doing this `here `_. -Most other editors have similar options; there may be an option for it in the -status bar at the bottom of the window or in the menu bar at the top. +Q: Where can I learn more about the ev3dev operating system? + A: `ev3dev.org`_ is a great resource for finding guides and tutorials on + using ev3dev, straight from the maintainers. + +Q: How can I request support on the ev3dev2 Python library? + A: If you are having trouble using this library, please open an issue + at `our Issues tracker`_ so that we can help you. When opening an + issue, make sure to include as much information as possible about + what you are trying to do and what you have tried. The issue template + is in place to guide you through this process. + +Q: How can I upgrade the library on my EV3? + A: You can upgrade this library from an Internet-connected EV3 with an + SSH shell as follows. Make sure to type the password + (the default is ``maker``) when prompted. + + .. code-block:: bash + + sudo apt-get update + sudo apt-get install --only-upgrade python3-ev3dev2 + +Q: Are there other useful Python modules to use on the EV3? + A: The Python language has a `package repository`_ where you can find + libraries that others have written, including the `latest version of + this package`_. + +Q: What compatibility issues are there with the different versions of Python? + A: Some versions of the ev3dev_ distribution come with + `Python 2.x`_, `Python 3.x`_, and `micropython`_ installed, + but this library is compatible only with Python 3 and micropython. + +.. _ev3dev: http://ev3dev.org +.. _ev3dev.org: ev3dev_ +.. _Getting Started: ev3dev-getting-started_ +.. _ev3dev Getting Started guide: ev3dev-getting-started_ +.. _ev3dev-getting-started: http://www.ev3dev.org/docs/getting-started/ +.. _upgrade the kernel before continuing: http://www.ev3dev.org/docs/tutorials/upgrading-ev3dev/ +.. _detailed instructions for USB connections: ev3dev-usb-internet_ +.. _via an SSH connection: http://www.ev3dev.org/docs/tutorials/connecting-to-ev3dev-with-ssh/ +.. _ev3dev-usb-internet: http://www.ev3dev.org/docs/tutorials/connecting-to-the-internet-via-usb/ +.. _our Read the Docs page: http://python-ev3dev.readthedocs.org/en/ev3dev-stretch/ +.. _ev3python.com: http://ev3python.com/ +.. _FAQ: http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html +.. _our FAQ page: FAQ_ +.. _our Issues tracker: https://github.com/ev3dev/ev3dev-lang-python/issues +.. _EXPLOR3R: demo-robot_ +.. _demo-robot: http://robotsquare.com/2015/10/06/explor3r-building-instructions/ +.. _robot-square: http://robotsquare.com/ +.. _Python 2.x: python2_ +.. _python2: https://docs.python.org/2/ +.. _Python 3.x: python3_ +.. _python3: https://docs.python.org/3/ +.. _package repository: pypi_ +.. _pypi: https://pypi.python.org/pypi +.. _latest version of this package: pypi-python-ev3dev_ +.. _pypi-python-ev3dev: https://pypi.python.org/pypi/python-ev3dev2 +.. _ev3dev Visual Studio Code extension: https://github.com/ev3dev/vscode-ev3dev-browser +.. _Python + VSCode introduction tutorial: https://github.com/ev3dev/vscode-hello-python +.. _nano: http://www.ev3dev.org/docs/tutorials/nano-cheat-sheet/ +.. _Micropython: http://python-ev3dev.readthedocs.io/en/ev3dev-stretch/micropython.html diff --git a/docs/motors.rst b/docs/motors.rst index 6973d73..2476f00 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -10,7 +10,10 @@ Motor classes Units ----- -Most methods which run motors will accept a ``speed`` argument. While this can be provided as an integer which will be interpreted as a percentage of max speed, you can also specify an instance of any of the following classes, each of which represents a different unit system: +Most methods which run motors will accept a ``speed`` argument. While this can +be provided as an integer which will be interpreted as a percentage of max +speed, you can also specify an instance of any of the following classes, each +of which represents a different unit system: .. autoclass:: SpeedValue .. autoclass:: SpeedPercent @@ -25,7 +28,7 @@ Example: .. code:: python from ev3dev2.motor import SpeedRPM - + # later... # rotates the motor at 200 RPM (rotations-per-minute) for five seconds. @@ -41,6 +44,7 @@ Tacho Motor (``Motor``) .. autoclass:: Motor :members: + :show-inheritance: Large EV3 Motor ~~~~~~~~~~~~~~~ @@ -64,24 +68,28 @@ DC Motor .. autoclass:: DcMotor :members: + :show-inheritance: Servo Motor ~~~~~~~~~~~ .. autoclass:: ServoMotor :members: + :show-inheritance: Actuonix L12 50 Linear Servo Motor ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: ActuonixL1250Motor :members: + :show-inheritance: Actuonix L12 100 Linear Servo Motor ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: ActuonixL12100Motor :members: + :show-inheritance: Multiple-motor groups --------------------- @@ -97,15 +105,25 @@ Move Tank .. autoclass:: MoveTank :members: + :show-inheritance: Move Steering ~~~~~~~~~~~~~ .. autoclass:: MoveSteering :members: + :show-inheritance: Move Joystick ~~~~~~~~~~~~~ .. autoclass:: MoveJoystick :members: + :show-inheritance: + +Move MoveDifferential +~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: MoveDifferential + :members: + :show-inheritance: From be85d8c67f18ff138aff3a7f99ae6e2625af12fe Mon Sep 17 00:00:00 2001 From: "Brady P. Merkel" Date: Sun, 28 Jul 2019 10:47:18 -0400 Subject: [PATCH 136/172] Console() implementation (#649) Related to #635. Micropython support for Display This is a text-only implementation using the EV3 LCD console ANSI code support to position the cursor, clear the screen, show text with different fonts, and use reverse (white text on black background, versus the default black text on white background). See the file utils/console_fonts.py to see the fonts supported with sample calls to the ev3dev2.Console() class. I also included a Makefile so folks can git clone, build and install the latest ev3dev2 modules to the /usr/lib/micropython directory. --- CONTRIBUTING.rst | 53 ++++++++++++++- Makefile | 18 +++++ debian/changelog | 14 +++- docs/console.rst | 149 +++++++++++++++++++++++++++++++++++++++++ docs/micropython.md | 19 ++++-- docs/spec.rst | 3 +- ev3dev2/auto.py | 1 + ev3dev2/console.py | 143 +++++++++++++++++++++++++++++++++++++++ utils/console_fonts.py | 114 +++++++++++++++++++++++++++++++ 9 files changed, 503 insertions(+), 11 deletions(-) create mode 100644 Makefile create mode 100644 docs/console.rst create mode 100644 ev3dev2/console.py create mode 100644 utils/console_fonts.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ea7d100..9793397 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -13,7 +13,7 @@ The ``ev3dev-stretch`` branch ----------------------------- This is where the latest version of our library lives. It targets -`ev3dev-stretch`, which is currently considered a beta. Nonetheless, +``ev3dev-stretch``, which is currently considered a beta. Nonetheless, it is very stable and isn't expected to have significant breaking changes. We publish releases from this branch. @@ -23,13 +23,16 @@ Before you issue a Pull Request Sometimes, it isn't easy for us to pull your suggested change and run rigorous testing on it. So please help us out by validating your changes and mentioning what kinds of testing you did when you open your PR. -Please also consider adding relevant tests to `api_tests.py`. +Please also consider adding relevant tests to ``api_tests.py`` and documentation +changes within the ``docs`` directory. If your change breaks or changes an API --------------------------------------- Breaking changes are discouraged, but sometimes they are necessary. A more common change is to add a new function or property to a class. +If you add a new parameter to an existing function, give it a default value +so as not to break existing code that calls the function. Either way, if it's more than a bug fix, please add enough text to the comments in the pull request message so that we know what was updated @@ -47,4 +50,48 @@ note of the format ``Fixes #24`` in the PR so that the PR links back to its relevant issue and will automatically close the issue when the PR is merged. -.. _ev3dev: http://ev3dev.org \ No newline at end of file +Building and testing changes on the EV3 +--------------------------------------- + +In an SSH terminal window with an EV3 with Internet access, +run the following commands: +(recall that the default ``sudo`` password is ``maker``) + +```shell +git clone https://github.com/ev3dev/ev3dev-lang-python.git +cd ev3dev-lang-python +sudo make install +``` + +To update the module, use the following commands: + +```shell +cd ev3dev-lang-python +git pull +sudo make install +``` + +If you are developing micropython support, you can take a shortcut +and use the following command to build and deploy the micropython +files only: + +```shell +cd ev3dev-lang-python +sudo make micropython-install +``` + +To re-install the latest release, use the following command: + +```shell +sudo apt-get --reinstall install python3-ev3dev2 +``` + +Or, to update your current ev3dev2 to the latest release, use the +following commands: + +```shell +sudo apt update +sudo apt install --only-upgrade micropython-ev3dev2 +``` + +.. _ev3dev: http://ev3dev.org diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..697b250 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# Makefile to assist developers while modifying and testing changes before release +# Note: to re-install a release of EV3DEV2, use `sudo apt-get --reinstall install python3-ev3dev2` +OUT := build +MPYCROSS := /usr/bin/mpy-cross +MPYC_FLAGS := -v -v -mcache-lookup-bc +PYS := $(shell find ./ev3dev2 -type f \( -iname "*.py" ! -iname "setup.py" \)) +MPYS := $(PYS:./%.py=${OUT}/%.mpy) +vpath %.py . + +${OUT}/%.mpy: %.py + @mkdir -p $(dir $@) + ${MPYCROSS} ${MPYC_FLAGS} -o $@ $< + +install: + python3 setup.py install + +micropython-install: ${MPYS} + cp -R $(OUT)/* /usr/lib/micropython/ diff --git a/debian/changelog b/debian/changelog index c83eb9a..22aa77a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -8,7 +8,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium * StopWatch class * Avoid race condition due to poll(None) * MoveDifferential odometry support, tracks robot's (x,y) position - * Display support via raspberry pi HDMI + * Display support via raspberry pi HDMI * Update tests/README to include sphinx-build instructions * LED animation support, brickpi3 LED corrected from BLUE to AMBER * Sound: fallback to regular case of 'note' if uppercase is not found @@ -17,6 +17,16 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium [David Lechner] * Fix and document ev3dev2.sensors.lego.GyroSensor.reset() + [Brady Merkel] + * Added the Console() class for positional text display and font support + on the EV3 LCD console + * Added a utility program to demonstrate the Console() functionality and + show the various system fonts + * Added documentation for the Console() class + * Updated the micropython documentation to reflect the Console() class + * Updated the CONTRIBUTING documentation with guidance using the makefile + to build and install the module while developing enancements + -- Kaelin Laundry Sun, 24 Mar 2019 00:25:00 -0800 python-ev3dev2 (2.0.0~beta3) stable; urgency=medium @@ -36,7 +46,7 @@ python-ev3dev2 (2.0.0~beta3) stable; urgency=medium * brickpi(3) raise exception if LargeMotor not used for a motor * MoveJoyStick should use SpeedPercent * renamed GyroSensor rate_and_angle to angle_and_rate - + [Kaelin Laundry] * Added new binary package for Micropython diff --git a/docs/console.rst b/docs/console.rst new file mode 100644 index 0000000..babf424 --- /dev/null +++ b/docs/console.rst @@ -0,0 +1,149 @@ +Console +======= + +.. autoclass:: ev3dev2.console.Console + :members: + +Examples: + +.. code-block:: py + + #!/usr/bin/env micropython + from ev3dev2.console import Console + + # create a Console instance, which uses the default font + console = Console() + + # reset the console to clear it, home the cursor at 1,1, and then turn off the cursor + console.reset_console() + + # display 'Hello World!' at row 5, column 1 in inverse, but reset the EV3 LCD console first + console.text_at('Hello World!', column=1, row=5, reset_console=True, inverse=True) + +.. code-block:: py + + #!/usr/bin/env micropython + from time import sleep + from ev3dev2.sensor import INPUT_1, INPUT_2, INPUT_3 + from ev3dev2.console import Console + from ev3dev2.sensor.lego import GyroSensor, ColorSensor + + console = Console() + gyro = GyroSensor(INPUT_1) + gyro.mode = GyroSensor.MODE_GYRO_ANG + color_sensor_left = ColorSensor(INPUT_2) + color_sensor_right = ColorSensor(INPUT_3) + + # show the gyro angle and reflected light intensity for both of our color sensors + while True: + angle = gyro.angle + left = color_sensor_left.reflected_light_intensity + right = color_sensor_right.reflected_light_intensity + + # show angle; in inverse color when pointing at 0 + console.text_at("G: %03d" % (angle), column=5, row=1, reset_console=True, inverse=(angle == 0)) + + # show light intensity values; in inverse when 'dark' + console.text_at("L: %02d" % (left), column=0, row=3, reset_console=False, inverse=(left < 10)) + console.text_at("R: %02d" % (right), column=10, row=3, reset_console=False, inverse=(right < 10)) + + sleep(0.5) + +Console fonts +------------- + +The :py:class:`ev3dev2.console.Console` class displays text on the LCD console +using ANSI codes in various system console fonts. The system console fonts are +located in `/usr/share/consolefonts`. + +Font filenames consist of the codeset, font face and font size. The codeset +specifies the characters supported. The font face determines the look of the +font. Each font face is available in multiple sizes. + +For Codeset information, see +``. + +Note: `Terminus` fonts are "thinner"; `TerminusBold` and `VGA` offer more +contrast on the LCD console and are thus more readable; the `TomThumb` font is +too small to read! + +Depending on the font used, the EV3 LCD console will support various maximum +rows and columns, as follows for the `Lat15` fonts. See +`utils/console_fonts.py` to discover fonts and their resulting rows/columns. +These fonts are listed in larger-to-smaller size order: + ++----------+------------+--------------------------------+ +| LCD Rows | LCD Columns| Font | ++==========+============+================================+ +| 4 | 11 | Lat15-Terminus32x16.psf.gz | ++----------+------------+--------------------------------+ +| 4 | 11 | Lat15-TerminusBold32x16.psf.gz | ++----------+------------+--------------------------------+ +| 4 | 11 | Lat15-VGA28x16.psf.gz | ++----------+------------+--------------------------------+ +| 4 | 11 | Lat15-VGA32x16.psf.gz | ++----------+------------+--------------------------------+ +| 4 | 12 | Lat15-Terminus28x14.psf.gz | ++----------+------------+--------------------------------+ +| 4 | 12 | Lat15-TerminusBold28x14.psf.gz | ++----------+------------+--------------------------------+ +| 5 | 14 | Lat15-Terminus24x12.psf.gz | ++----------+------------+--------------------------------+ +| 5 | 14 | Lat15-TerminusBold24x12.psf.gz | ++----------+------------+--------------------------------+ +| 5 | 16 | Lat15-Terminus22x11.psf.gz | ++----------+------------+--------------------------------+ +| 5 | 16 | Lat15-TerminusBold22x11.psf.gz | ++----------+------------+--------------------------------+ +| 6 | 17 | Lat15-Terminus20x10.psf.gz | ++----------+------------+--------------------------------+ +| 6 | 17 | Lat15-TerminusBold20x10.psf.gz | ++----------+------------+--------------------------------+ +| 7 | 22 | Lat15-Fixed18.psf.gz | ++----------+------------+--------------------------------+ +| 8 | 22 | Lat15-Fixed15.psf.gz | ++----------+------------+--------------------------------+ +| 8 | 22 | Lat15-Fixed16.psf.gz | ++----------+------------+--------------------------------+ +| 8 | 22 | Lat15-Terminus16.psf.gz | ++----------+------------+--------------------------------+ +| 8 | 22 | Lat15-TerminusBold16.psf.gz | ++----------+------------+--------------------------------+ +| 8 | 22 | Lat15-TerminusBoldVGA16.psf.gz | ++----------+------------+--------------------------------+ +| 8 | 22 | Lat15-VGA16.psf.gz | ++----------+------------+--------------------------------+ +| 9 | 22 | Lat15-Fixed13.psf.gz | ++----------+------------+--------------------------------+ +| 9 | 22 | Lat15-Fixed14.psf.gz | ++----------+------------+--------------------------------+ +| 9 | 22 | Lat15-Terminus14.psf.gz | ++----------+------------+--------------------------------+ +| 9 | 22 | Lat15-TerminusBold14.psf.gz | ++----------+------------+--------------------------------+ +| 9 | 22 | Lat15-TerminusBoldVGA14.psf.gz | ++----------+------------+--------------------------------+ +| 9 | 22 | Lat15-VGA14.psf.gz | ++----------+------------+--------------------------------+ +| 10 | 29 | Lat15-Terminus12x6.psf.gz | ++----------+------------+--------------------------------+ +| 16 | 22 | Lat15-VGA8.psf.gz | ++----------+------------+--------------------------------+ +| 21 | 44 | Lat15-TomThumb4x6.psf.gz | ++----------+------------+--------------------------------+ + +Example: + +.. code-block:: py + + #!/usr/bin/env micropython + from ev3dev2.console import Console + + # create a Console instance, which uses the default font + console = Console() + + # change the console font and reset the console to clear it and turn off the cursor + console.set_font('Lat15-TerminusBold16.psf.gz', True) + + # display 'Hello World!' at column 1, row 5 + console.text_at('Hello World!', column=1, row=5) diff --git a/docs/micropython.md b/docs/micropython.md index 75b61dd..a3d8d60 100644 --- a/docs/micropython.md +++ b/docs/micropython.md @@ -11,9 +11,10 @@ MicroPython, we recommend you run your code with it for improved performance. Module Support status ============================== ================= `ev3dev2.button` ️️✔️ +`ev3dev2.console` ✔️️ `ev3dev2.control` [1]_ ⚠️ -`ev3dev2.display` ❌ -`ev3dev2.fonts` [2]_ ⚠️ +`ev3dev2.display` [2]_ ❌ +`ev3dev2.fonts` [3]_ ❌ `ev3dev2.led` ✔️ `ev3dev2.motor` ✔️ `ev3dev2.port` ✔️ @@ -25,7 +26,8 @@ Module Support status ============================== ================= .. [1] Untested/low-priority, but some of it might work. -.. [2] It might work, but isn't useful without ``ev3dev2.display``. +.. [2] ``ev3dev2.display`` isn't implemented. Use ``ev3dev2.console`` for text-only, using ANSI codes to the EV3 LCD console. +.. [3] ``ev3dev2.console`` supports the system fonts, but the fonts for ``ev3dev2.display`` do not work. ``` ## Differences from standard Python (CPython) @@ -36,10 +38,17 @@ See [the MicroPython differences page](http://docs.micropython.org/en/latest/gen You should modify the first line of your scripts to replace "python3" with "micropython": -``` +```python #!/usr/bin/env micropython ``` ### Running from the command line -If you previously would have typed `python3 foo.py`, you should now type `micropython foo.py`. \ No newline at end of file +If you previously would have typed `python3 foo.py`, you should now type `micropython foo.py`. + +If you are running programs via an SSH shell to your EV3, use the following command line to +prevent Brickman from interfering: + +```shell +brickrun -- ./program.py +``` diff --git a/docs/spec.rst b/docs/spec.rst index 1247c80..9738ac8 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -16,6 +16,7 @@ Device interfaces power-supply sound display + console ports port-names wheels @@ -34,4 +35,4 @@ Each class in ev3dev module inherits from the base :py:class:`ev3dev2.Device` cl .. autofunction:: ev3dev2.motor.list_motors -.. autofunction:: ev3dev2.sensor.list_sensors \ No newline at end of file +.. autofunction:: ev3dev2.sensor.list_sensors diff --git a/ev3dev2/auto.py b/ev3dev2/auto.py index b13a4df..7f7c8bb 100644 --- a/ev3dev2/auto.py +++ b/ev3dev2/auto.py @@ -36,6 +36,7 @@ raise Exception("Unsupported platform '%s'" % platform) from ev3dev2.button import * +from ev3dev2.console import * from ev3dev2.display import * from ev3dev2.fonts import * from ev3dev2.led import * diff --git a/ev3dev2/console.py b/ev3dev2/console.py new file mode 100644 index 0000000..e397cbe --- /dev/null +++ b/ev3dev2/console.py @@ -0,0 +1,143 @@ +# ----------------------------------------------------------------------------- +# 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 os + + +class Console(): + """ + A class that represents the EV3 LCD console, which implements ANSI codes + for cursor positioning, text color, and resetting the screen. Supports changing + the console font using standard system fonts. + """ + + def __init__(self, font="Lat15-TerminusBold24x12"): + """ + Construct the Console instance, optionally with a font name specified. + + Parameter: + + - `font` (string): Font name, as found in `/usr/share/consolefonts/` + + """ + self._font = None + self.set_font(font, False) # don't reset the screen during construction + + def text_at(self, text, column=1, row=1, reset_console=False, inverse=False, alignment="L"): + """ + Display `text` (string) at grid position (`column`, `row`). + Note that the grid locations are 1-based (not 0-based). + + Depending on the font, the number of columns and rows supported by the EV3 LCD console + can vary. Large fonts support as few as 11 columns and 4 rows, while small fonts support + 44 columns and 21 rows. The default font for the Console() class results in a grid that + is 14 columns and 5 rows. + + Using the `inverse=True` parameter will display the `text` with more emphasis and contrast, + as the background of the text will be black, and the foreground is white. Using inverse + can help in certain situations, such as to indicate when a color sensor senses + black, or the gyro sensor is pointing to zero. + + Use the `alignment` parameter to enable the function to align the `text` differently to the + column/row values passed-in. Use `L` for left-alignment (default), where the first character + in the `text` will show at the column/row position. Use `R` for right-alignment, where the + last character will show at the column/row position. Use `C` for center-alignment, where the + text string will centered at the column/row position (as close as possible using integer + division--odd-length text string will center better than even-length). + + Parameters: + + - `text` (string): Text to display + - `column` (int): LCD column position to start the text (1 = left column); + text will wrap when it reaches the right edge + - `row` (int): LCD row position to start the text (1 = top row) + - `reset_console` (bool): ``True`` to reset the EV3 LCD console before showing + the text; default is ``False`` + - `inverse` (bool): ``True`` for white on black, otherwise black on white; + default is ``False`` + - `alignment` (string): Align the `text` horizontally. Use `L` for left-alignment (default), + `R` for right-alignment, or `C` for center-alignment + + """ + + if reset_console: + self.reset_console() + + if alignment == "R": + column = column - len(text) + 1 + elif alignment == "C": + column -= len(text) // 2 + + if inverse: + text = "\x1b[7m{}\x1b[m".format(text) + + print("\x1b[{};{}H{}".format(row, column, text), end='') + + def set_font(self, font, reset_console=True): + """ + Set the EV3 LCD console font and optionally reset the EV3 LCD console + to clear it and turn off the cursor. + + Parameters: + + - `font` (string): Font name, as found in `/usr/share/consolefonts/` + - `reset_console` (bool): ``True`` to reset the EV3 LCD console + after the font change; default is ``True`` + + """ + if font is not None and font != self._font: + self._font = font + os.system("setfont {}".format(font)) + + if reset_console: + self.reset_console() + + def show_cursor(self, on=False): + """ + Use ANSI codes to turn the EV3 LCD console cursor on or off. + + Parameter: + + - `on` (bool): ``True`` to turn on the cursor; default is ``False`` + + """ + print("\x1b[?25{}".format('h' if on else 'l'), end='') + + def clear_to_eol(self, column=None, row=None): + """ + Clear to the end of line from the `column` and `row` position + on the EV3 LCD console. Default to current cursor position. + + Parameters: + + - `column` (int): LCD column position to move to before clearing + - `row` (int): LCD row position to move to before clearing + + """ + if column is not None and row is not None: + print("\x1b[{};{}H".format(row, column), end='') + print("\x1b[K", end='') + + def reset_console(self): + """ + Use ANSI codes to clear the EV3 LCD console, move the cursor to 1,1, then turn off the cursor. + """ + print("\x1b[2J\x1b[H", end='') + self.show_cursor(False) diff --git a/utils/console_fonts.py b/utils/console_fonts.py new file mode 100644 index 0000000..2a3d072 --- /dev/null +++ b/utils/console_fonts.py @@ -0,0 +1,114 @@ +#!/usr/bin/env micropython +from time import sleep +from sys import stderr, stdin +from os import listdir +from ev3dev2.console import Console + +""" +Used to iterate over the system console fonts (in /usr/share/consolefonts) and calculate the max row/col +position by moving the cursor to 50, 50 and asking the LCD for the actual cursor position +where it ends up (the EV3 LCD console driver prevents the cursor from positioning off-screen). + +The font specification consists of three parameters - codeset, font face and font size. The codeset specifies +what characters will be supported by the font. The font face determines the general look of the font. Each +font face is available in certain possible sizes. + +For Codeset clarity, see https://www.systutorials.com/docs/linux/man/5-console-setup/#lbAP + +""" + + +def calc_fonts(): + """ + Iterate through all the Latin "1 & 5" fonts, and use ANSI escape sequences to see how many rows/columns + the EV3 LCD console can accommodate for each font + """ + console = Console() + + files = [f for f in listdir("/usr/share/consolefonts/") if f.startswith("Lat15") and f.endswith(".psf.gz")] + files.sort() + for font in files: + console.set_font(font, True) + + # position cursor at 50, 50, and ask the console to report its actual cursor position + console.text_at("\x1b[6n", 50, 50, False) + console.text_at(font, 1, 1, False, True) + console.clear_to_eol() + + # now, read the console response of the actual cursor position, in the form of esc[rr;ccR + # requires pressing the center button on the EV3 for each read + dims = '' + while True: + ch = stdin.read(1) + if ch == '\x1b' or ch == '[' or ch == '\r' or ch == '\n': + continue + if ch == 'R': + break + dims += str(ch) + (rows, cols) = dims.split(";") + print("({}, {}, \"{}\"),".format(rows, cols, font), file=stderr) + sleep(.5) + + +def show_fonts(): + """ + Iterate over the known Latin "1 & 5" fonts and display each on the EV3 LCD console. + Note: `Terminus` fonts are "thinner"; `TerminusBold` and `VGA` offer more contrast on the LCD console + and are thus more readable; the `TomThumb` font is waaaaay too small to read! + """ + # Create a list of tuples with calulated rows, columns, font filename + fonts = [ + (4, 11, "Lat15-Terminus32x16.psf.gz"), + (4, 11, "Lat15-TerminusBold32x16.psf.gz"), + (4, 11, "Lat15-VGA28x16.psf.gz"), + (4, 11, "Lat15-VGA32x16.psf.gz"), + (4, 12, "Lat15-Terminus28x14.psf.gz"), + (4, 12, "Lat15-TerminusBold28x14.psf.gz"), + (5, 14, "Lat15-Terminus24x12.psf.gz"), + (5, 14, "Lat15-TerminusBold24x12.psf.gz"), + (5, 16, "Lat15-Terminus22x11.psf.gz"), + (5, 16, "Lat15-TerminusBold22x11.psf.gz"), + (6, 17, "Lat15-Terminus20x10.psf.gz"), + (6, 17, "Lat15-TerminusBold20x10.psf.gz"), + (7, 22, "Lat15-Fixed18.psf.gz"), + (8, 22, "Lat15-Fixed15.psf.gz"), + (8, 22, "Lat15-Fixed16.psf.gz"), + (8, 22, "Lat15-Terminus16.psf.gz"), + (8, 22, "Lat15-TerminusBold16.psf.gz"), + (8, 22, "Lat15-TerminusBoldVGA16.psf.gz"), + (8, 22, "Lat15-VGA16.psf.gz"), + (9, 22, "Lat15-Fixed13.psf.gz"), + (9, 22, "Lat15-Fixed14.psf.gz"), + (9, 22, "Lat15-Terminus14.psf.gz"), + (9, 22, "Lat15-TerminusBold14.psf.gz"), + (9, 22, "Lat15-TerminusBoldVGA14.psf.gz"), + (9, 22, "Lat15-VGA14.psf.gz"), + (10, 29, "Lat15-Terminus12x6.psf.gz"), + (16, 22, "Lat15-VGA8.psf.gz"), + (21, 44, "Lat15-TomThumb4x6.psf.gz") + ] + + # Paint the screen full of numbers that represent the column number, reversing the even rows + console = Console() + for rows, cols, font in fonts: + print(rows, cols, font, file=stderr) + console.set_font(font, True) + for row in range(1, rows+1): + for col in range(1, cols+1): + console.text_at("{}".format(col % 10), col, row, False, (row % 2 == 0)) + console.text_at(font.split(".")[0], 1, 1, False, True) + console.clear_to_eol() + sleep(.5) + + +# Uncomment the calc_fonts() call to iterate through each system font +# and use ANSI codes to find the max row/column the screen will accommodate for +# each font. Remember to press the center EV3 button for each font. +# Also, you may want to adjust the `startswith` filter to show other codesets. +# calc_fonts() + + +# show the fonts +show_fonts() + +sleep(5) From ac82fbe908d41cad109e3be3fb35b20219571b62 Mon Sep 17 00:00:00 2001 From: "Brady P. Merkel" Date: Sat, 17 Aug 2019 23:48:00 -0400 Subject: [PATCH 137/172] Console() rows/columns, echo, and cursor properties; doc corrections (#654) * implement rows/columns, echo, and cursor properties * improved console_fonts utility to use rows/columns from the console class; improved documentation for console and motors * disable cursor and turn off echo only at construction, not in every reset_console * corrected README link; added console_menu utility sample * corrected setter attributes; simplified echo logic * correct end-paren typo * simplified and aligned inline docs * corrected type reference in docs for sphinx rendering * Delete menu demo (to be re-added later in modified form) --- README.rst | 4 +- docs/console.rst | 8 +++- docs/motors.rst | 4 +- ev3dev2/console.py | 73 +++++++++++++++++++++++++------- ev3dev2/sound.py | 2 +- utils/console_fonts.py | 94 ++++++++---------------------------------- 6 files changed, 86 insertions(+), 99 deletions(-) diff --git a/README.rst b/README.rst index 3cfeac7..23af819 100644 --- a/README.rst +++ b/README.rst @@ -191,8 +191,8 @@ features your project needs. Library Documentation --------------------- -**Class documentation for this library can be found on -** `our Read the Docs page`_ **.** You can always go there to get +Class documentation for this library can be found on +`our Read the Docs page`_. You can always go there to get information on how you can use this library's functionality. diff --git a/docs/console.rst b/docs/console.rst index babf424..643921b 100644 --- a/docs/console.rst +++ b/docs/console.rst @@ -145,5 +145,9 @@ Example: # change the console font and reset the console to clear it and turn off the cursor console.set_font('Lat15-TerminusBold16.psf.gz', True) - # display 'Hello World!' at column 1, row 5 - console.text_at('Hello World!', column=1, row=5) + # compute the middle of the console + mid_col = console.columns // 2 + mid_row = console.rows // 2 + + # display 'Hello World!' in the center of the LCD console + console.text_at('Hello World!', column=mid_col, row=mid_row, alignment="C") diff --git a/docs/motors.rst b/docs/motors.rst index 2476f00..beb399f 100644 --- a/docs/motors.rst +++ b/docs/motors.rst @@ -121,8 +121,8 @@ Move Joystick :members: :show-inheritance: -Move MoveDifferential -~~~~~~~~~~~~~~~~~~~~~ +Move Differential +~~~~~~~~~~~~~~~~~ .. autoclass:: MoveDifferential :members: diff --git a/ev3dev2/console.py b/ev3dev2/console.py index e397cbe..d7c0324 100644 --- a/ev3dev2/console.py +++ b/ev3dev2/console.py @@ -38,7 +38,59 @@ def __init__(self, font="Lat15-TerminusBold24x12"): """ self._font = None - self.set_font(font, False) # don't reset the screen during construction + self._columns = 0 + self._rows = 0 + self._echo = False + self._cursor = False + self.set_font(font, reset_console=False) # don't reset the screen during construction + self.cursor = False + self.echo = False + + @property + def columns(self): + """ + Return (int) number of columns on the EV3 LCD console supported by the current font. + """ + return self._columns + + @property + def rows(self): + """ + Return (int) number of rows on the EV3 LCD console supported by the current font. + """ + return self._rows + + @property + def echo(self): + """ + Return (bool) whether the console echo mode is enabled. + """ + return self._echo + + @echo.setter + def echo(self, value): + """ + Enable/disable console echo (so that EV3 button presses do not show the escape characters on + the LCD console). Set to True to show the button codes, or False to hide them. + """ + self._echo = value + os.system("stty {}".format("echo" if value else "-echo")) + + @property + def cursor(self): + """ + Return (bool) whether the console cursor is visible. + """ + return self._cursor + + @cursor.setter + def cursor(self, value): + """ + Enable/disable console cursor (to hide the cursor on the LCD). + Set to True to show the cursor, or False to hide it. + """ + self._cursor = value + print("\x1b[?25{}".format('h' if value else 'l'), end='') def text_at(self, text, column=1, row=1, reset_console=False, inverse=False, alignment="L"): """ @@ -90,7 +142,7 @@ def text_at(self, text, column=1, row=1, reset_console=False, inverse=False, ali print("\x1b[{};{}H{}".format(row, column, text), end='') - def set_font(self, font, reset_console=True): + def set_font(self, font="Lat15-TerminusBold24x12", reset_console=True): """ Set the EV3 LCD console font and optionally reset the EV3 LCD console to clear it and turn off the cursor. @@ -105,21 +157,13 @@ def set_font(self, font, reset_console=True): if font is not None and font != self._font: self._font = font os.system("setfont {}".format(font)) + rows, columns = os.popen('stty size').read().strip().split(" ") + self._rows = int(rows) + self._columns = int(columns) if reset_console: self.reset_console() - def show_cursor(self, on=False): - """ - Use ANSI codes to turn the EV3 LCD console cursor on or off. - - Parameter: - - - `on` (bool): ``True`` to turn on the cursor; default is ``False`` - - """ - print("\x1b[?25{}".format('h' if on else 'l'), end='') - def clear_to_eol(self, column=None, row=None): """ Clear to the end of line from the `column` and `row` position @@ -137,7 +181,6 @@ def clear_to_eol(self, column=None, row=None): def reset_console(self): """ - Use ANSI codes to clear the EV3 LCD console, move the cursor to 1,1, then turn off the cursor. + Clear the EV3 LCD console using ANSI codes, and move the cursor to 1,1 """ print("\x1b[2J\x1b[H", end='') - self.show_cursor(False) diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 565bc00..cd2f4e4 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -455,7 +455,7 @@ def play_song(self, song, tempo=120, delay=0.05): Only 4/4 signature songs are supported with respect to note durations. - :param iterable[tuple(string, string)] song: the song + :param iterable[tuple(string,string)] song: the song :param int tempo: the song tempo, given in quarters per minute :param float delay: delay between notes (in seconds) diff --git a/utils/console_fonts.py b/utils/console_fonts.py index 2a3d072..f2d79a2 100644 --- a/utils/console_fonts.py +++ b/utils/console_fonts.py @@ -1,15 +1,13 @@ #!/usr/bin/env micropython from time import sleep -from sys import stderr, stdin +from sys import stderr from os import listdir from ev3dev2.console import Console """ -Used to iterate over the system console fonts (in /usr/share/consolefonts) and calculate the max row/col -position by moving the cursor to 50, 50 and asking the LCD for the actual cursor position -where it ends up (the EV3 LCD console driver prevents the cursor from positioning off-screen). +Used to iterate over the system console fonts (in /usr/share/consolefonts) and show the max row/col. -The font specification consists of three parameters - codeset, font face and font size. The codeset specifies +Font names consist of three parameters - codeset, font face and font size. The codeset specifies what characters will be supported by the font. The font face determines the general look of the font. Each font face is available in certain possible sizes. @@ -18,97 +16,39 @@ """ -def calc_fonts(): +def show_fonts(): """ - Iterate through all the Latin "1 & 5" fonts, and use ANSI escape sequences to see how many rows/columns - the EV3 LCD console can accommodate for each font + Iterate through all the Latin "1 & 5" fonts, and see how many rows/columns + the EV3 LCD console can accommodate for each font. + Note: `Terminus` fonts are "thinner"; `TerminusBold` and `VGA` offer more contrast on the LCD console + and are thus more readable; the `TomThumb` font is waaaaay too small to read! """ console = Console() - files = [f for f in listdir("/usr/share/consolefonts/") if f.startswith("Lat15") and f.endswith(".psf.gz")] files.sort() + fonts = [] for font in files: console.set_font(font, True) - - # position cursor at 50, 50, and ask the console to report its actual cursor position - console.text_at("\x1b[6n", 50, 50, False) console.text_at(font, 1, 1, False, True) console.clear_to_eol() + console.text_at("{}, {}".format(console.columns, console.rows), + column=2, row=4, reset_console=False, inverse=False) + print("{}, {}, \"{}\"".format(console.columns, console.rows, font), file=stderr) + fonts.append((console.columns, console.rows, font)) - # now, read the console response of the actual cursor position, in the form of esc[rr;ccR - # requires pressing the center button on the EV3 for each read - dims = '' - while True: - ch = stdin.read(1) - if ch == '\x1b' or ch == '[' or ch == '\r' or ch == '\n': - continue - if ch == 'R': - break - dims += str(ch) - (rows, cols) = dims.split(";") - print("({}, {}, \"{}\"),".format(rows, cols, font), file=stderr) - sleep(.5) - - -def show_fonts(): - """ - Iterate over the known Latin "1 & 5" fonts and display each on the EV3 LCD console. - Note: `Terminus` fonts are "thinner"; `TerminusBold` and `VGA` offer more contrast on the LCD console - and are thus more readable; the `TomThumb` font is waaaaay too small to read! - """ - # Create a list of tuples with calulated rows, columns, font filename - fonts = [ - (4, 11, "Lat15-Terminus32x16.psf.gz"), - (4, 11, "Lat15-TerminusBold32x16.psf.gz"), - (4, 11, "Lat15-VGA28x16.psf.gz"), - (4, 11, "Lat15-VGA32x16.psf.gz"), - (4, 12, "Lat15-Terminus28x14.psf.gz"), - (4, 12, "Lat15-TerminusBold28x14.psf.gz"), - (5, 14, "Lat15-Terminus24x12.psf.gz"), - (5, 14, "Lat15-TerminusBold24x12.psf.gz"), - (5, 16, "Lat15-Terminus22x11.psf.gz"), - (5, 16, "Lat15-TerminusBold22x11.psf.gz"), - (6, 17, "Lat15-Terminus20x10.psf.gz"), - (6, 17, "Lat15-TerminusBold20x10.psf.gz"), - (7, 22, "Lat15-Fixed18.psf.gz"), - (8, 22, "Lat15-Fixed15.psf.gz"), - (8, 22, "Lat15-Fixed16.psf.gz"), - (8, 22, "Lat15-Terminus16.psf.gz"), - (8, 22, "Lat15-TerminusBold16.psf.gz"), - (8, 22, "Lat15-TerminusBoldVGA16.psf.gz"), - (8, 22, "Lat15-VGA16.psf.gz"), - (9, 22, "Lat15-Fixed13.psf.gz"), - (9, 22, "Lat15-Fixed14.psf.gz"), - (9, 22, "Lat15-Terminus14.psf.gz"), - (9, 22, "Lat15-TerminusBold14.psf.gz"), - (9, 22, "Lat15-TerminusBoldVGA14.psf.gz"), - (9, 22, "Lat15-VGA14.psf.gz"), - (10, 29, "Lat15-Terminus12x6.psf.gz"), - (16, 22, "Lat15-VGA8.psf.gz"), - (21, 44, "Lat15-TomThumb4x6.psf.gz") - ] + fonts.sort(key=lambda f: (f[0], f[1], f[2])) # Paint the screen full of numbers that represent the column number, reversing the even rows - console = Console() - for rows, cols, font in fonts: - print(rows, cols, font, file=stderr) + for cols, rows, font in fonts: + print(cols, rows, font, file=stderr) console.set_font(font, True) for row in range(1, rows+1): for col in range(1, cols+1): console.text_at("{}".format(col % 10), col, row, False, (row % 2 == 0)) console.text_at(font.split(".")[0], 1, 1, False, True) console.clear_to_eol() - sleep(.5) - - -# Uncomment the calc_fonts() call to iterate through each system font -# and use ANSI codes to find the max row/column the screen will accommodate for -# each font. Remember to press the center EV3 button for each font. -# Also, you may want to adjust the `startswith` filter to show other codesets. -# calc_fonts() - -# show the fonts +# Show the fonts; you may want to adjust the `startswith` filter to show other codesets. show_fonts() sleep(5) From e121fe40f512dcf7fccde301f1a526e2d5b573d9 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 17 Aug 2019 23:31:37 -0700 Subject: [PATCH 138/172] Distribution in changelog is now the codename --- debian/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/release.sh b/debian/release.sh index 79d44e1..f225106 100755 --- a/debian/release.sh +++ b/debian/release.sh @@ -7,7 +7,7 @@ set -e source=$(dpkg-parsechangelog -S Source) version=$(dpkg-parsechangelog -S Version) distribution=$(dpkg-parsechangelog -S Distribution) -codename=$(debian-distro-info --codename --${distribution}) +codename=$distribution # as of Aug 13 2019, the distribution is the codename OS=debian DIST=${codename} ARCH=amd64 pbuilder-ev3dev build OS=raspbian DIST=${codename} ARCH=armhf pbuilder-ev3dev build From 4686c42ff2cb6c38b5a1c8bf9f2b61f76321f793 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 17 Aug 2019 23:06:04 -0700 Subject: [PATCH 139/172] Update changelog for 2.0.0-beta4 release --- debian/changelog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 22aa77a..ca5d68f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium +python-ev3dev2 (2.0.0~beta4) stretch; urgency=medium [Daniel Walton] * PID line follower @@ -27,7 +27,7 @@ python-ev3dev2 (2.0.0~beta4) UNRELEASED; urgency=medium * Updated the CONTRIBUTING documentation with guidance using the makefile to build and install the module while developing enancements - -- Kaelin Laundry Sun, 24 Mar 2019 00:25:00 -0800 + -- Kaelin Laundry Sat, 17 Aug 2019 23:05:00 -0700 python-ev3dev2 (2.0.0~beta3) stable; urgency=medium From 4394507f59f448115a012f04ee212e68fe27df26 Mon Sep 17 00:00:00 2001 From: "Brady P. Merkel" Date: Sun, 18 Aug 2019 13:50:52 -0400 Subject: [PATCH 140/172] add console and stopwatch to Debian rules file (#658) --- debian/rules | 2 ++ 1 file changed, 2 insertions(+) diff --git a/debian/rules b/debian/rules index e0f2efe..edc3d3b 100755 --- a/debian/rules +++ b/debian/rules @@ -16,6 +16,7 @@ mpy_files = \ ev3dev2/_platform/pistorms.mpy \ ev3dev2/auto.mpy \ ev3dev2/button.mpy \ + ev3dev2/console.mpy \ ev3dev2/control/__init__.mpy \ ev3dev2/control/GyroBalancer.mpy \ ev3dev2/control/rc_tank.mpy \ @@ -29,6 +30,7 @@ mpy_files = \ ev3dev2/sensor/__init__.mpy \ ev3dev2/sensor/lego.mpy \ ev3dev2/sound.mpy \ + ev3dev2/stopwatch.mpy \ ev3dev2/unit.mpy \ ev3dev2/version.mpy \ ev3dev2/wheel.mpy From 73d1c06f7dd5a7de97189789fc6ae5ffd86b1474 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 18 Aug 2019 11:41:06 -0700 Subject: [PATCH 141/172] Update changelog for 2.0.0-beta5 release --- debian/changelog | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/debian/changelog b/debian/changelog index ca5d68f..5848b81 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +python-ev3dev2 (2.0.0~beta5) stretch; urgency=medium + + [Brady Merkel] + * Add console and stopwatch to Debian rules file so they are + included in MicroPython package + + -- Kaelin Laundry Sun, 18 Aug 2019 11:37:17 -0700 + python-ev3dev2 (2.0.0~beta4) stretch; urgency=medium [Daniel Walton] From 48fbd29fd06c69f57e88efcf59955ea49bcc7ace Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Wed, 21 Aug 2019 23:20:16 -0700 Subject: [PATCH 142/172] Improve contributing guide for issues and add micropython to FAQ (#662) --- CONTRIBUTING.rst | 40 +++++++++++++++++++++++----------------- docs/faq.rst | 2 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9793397..7c650b1 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,24 +1,22 @@ Contributing to the Python language bindings for ev3dev ======================================================= -This repository holds the pure Python bindings for peripheral -devices that use the drivers available in the ev3dev_ distribution. -for embedded systems. +This repository holds the Python bindings for ev3dev_, ev3dev-lang-python. -Contributions are welcome in the form of pull requests - but please -take a moment to read our suggestions for happy maintainers and -even happier users. +Opening issues +-------------- -The ``ev3dev-stretch`` branch ------------------------------ +Please make sure you have read the FAQ_. If you are still encountering your +problem, open an issue, and ensure that the questions asked by the issue +template are completely answered with correct info. This will make it much +easier for us to help you! -This is where the latest version of our library lives. It targets -``ev3dev-stretch``, which is currently considered a beta. Nonetheless, -it is very stable and isn't expected to have significant breaking -changes. We publish releases from this branch. +Submitting Pull Requests +------------------------ -Before you issue a Pull Request -------------------------------- +Contributions are welcome in the form of pull requests - but please +take a moment to read our suggestions for happy maintainers and +even happier users. Sometimes, it isn't easy for us to pull your suggested change and run rigorous testing on it. So please help us out by validating your changes @@ -26,8 +24,15 @@ and mentioning what kinds of testing you did when you open your PR. Please also consider adding relevant tests to ``api_tests.py`` and documentation changes within the ``docs`` directory. +The ``ev3dev-stretch`` branch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is where the latest version of our library lives. It targets +``ev3dev-stretch``, which is the current stable version of ev3dev. +We publish releases from this branch. + If your change breaks or changes an API ---------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Breaking changes are discouraged, but sometimes they are necessary. A more common change is to add a new function or property to a class. @@ -40,7 +45,7 @@ and can easily discuss the breaking change and add it to the release notes. If your change addresses an Issue ---------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Bug fixes are always welcome, especially if they are against known issues! @@ -51,7 +56,7 @@ to its relevant issue and will automatically close the issue when the PR is merged. Building and testing changes on the EV3 ---------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In an SSH terminal window with an EV3 with Internet access, run the following commands: @@ -95,3 +100,4 @@ sudo apt install --only-upgrade micropython-ev3dev2 ``` .. _ev3dev: http://ev3dev.org +.. _FAQ: https://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html \ No newline at end of file diff --git a/docs/faq.rst b/docs/faq.rst index d01aea9..2033729 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -38,7 +38,7 @@ Q: How can I upgrade the library on my EV3? .. code-block:: bash sudo apt-get update - sudo apt-get install --only-upgrade python3-ev3dev2 + sudo apt-get install --only-upgrade python3-ev3dev2 micropython-ev3dev2 Q: Are there other useful Python modules to use on the EV3? A: The Python language has a `package repository`_ where you can find From 8fd12cf07b3b8585a6203b3d4a6eaad1af1c908f Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 25 Aug 2019 14:28:48 -0700 Subject: [PATCH 143/172] Ask for micropython-ev3dev2 version in issue template --- ISSUE_TEMPLATE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index 8e1d7f4..5b4ca0e 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -1,10 +1,10 @@ - **ev3dev version:** PASTE THE OUTPUT OF `uname -r` HERE -- **ev3dev-lang-python version:** INSERT ALL VERSIONS GIVEN BY `dpkg-query -l python3-ev3dev*` HERE +- **ev3dev-lang-python version:** INSERT ALL VERSIONS GIVEN BY `dpkg-query -l {python3,micropython}-ev3dev*` HERE From 3e6ce644f5e67069408b5c09ca920cb585869177 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 23 Nov 2019 20:13:33 -0600 Subject: [PATCH 144/172] Fix gyro sensor reset (#692) Gyro sensor reset was failing because we were writing 17 bytes of 0 to the gyro sensor instead of the byte 17. Also change it to a bytes literal for max efficiency. --- ev3dev2/sensor/lego.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 1bd1efe..b5c7301 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -631,7 +631,7 @@ def reset(self): PiStorms, or with any sensor multiplexors. """ # 17 comes from inspecting the .vix file of the Gyro sensor block in EV3-G - self._direct = self.set_attr_raw(self._direct, 'direct', bytes(17,)) + self._direct = self.set_attr_raw(self._direct, 'direct', b'\x11') def wait_until_angle_changed_by(self, delta, direction_sensitive=False): """ From b32fca48f8bfd5835ae383a5bfab4be5bdb78a90 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 23 Nov 2019 18:23:49 -0800 Subject: [PATCH 145/172] Add gyro sensor reset fix to changelog --- debian/changelog | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/debian/changelog b/debian/changelog index 5848b81..057571e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +python-ev3dev2 (2.0.0) UNRELEASED; urgency=medium + + [David Lechner] + * Fix gyro sensor reset + + -- Kaelin Laundry UNRELEASED + python-ev3dev2 (2.0.0~beta5) stretch; urgency=medium [Brady Merkel] From d7ae4384b214bf77c116080e6b2339a9f1a19918 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 24 Nov 2019 20:44:21 -0800 Subject: [PATCH 146/172] Update changelog for 2.0.0 release --- debian/changelog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 057571e..f2490a4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,9 +1,9 @@ -python-ev3dev2 (2.0.0) UNRELEASED; urgency=medium +python-ev3dev2 (2.0.0) stretch; urgency=medium [David Lechner] * Fix gyro sensor reset - -- Kaelin Laundry UNRELEASED + -- Kaelin Laundry Sun, 24 Nov 2019 20:41:18 -0800 python-ev3dev2 (2.0.0~beta5) stretch; urgency=medium From 3cf0281752be26b30433698105b147fe31aa2771 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 24 Nov 2019 21:51:30 -0800 Subject: [PATCH 147/172] Ignore Debian package tags for PyPi versioning --- git_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git_version.py b/git_version.py index 63703c0..4ca6b7d 100644 --- a/git_version.py +++ b/git_version.py @@ -14,7 +14,7 @@ #---------------------------------------------------------------------------- def call_git_describe(abbrev=4): try: - p = Popen(['git', 'describe', '--abbrev=%d' % abbrev], + p = Popen(['git', 'describe', '--exclude', 'ev3dev-*', '--abbrev=%d' % abbrev], stdout=PIPE, stderr=PIPE) p.stderr.close() line = p.stdout.readlines()[0] From 9486f2d9958ca97e6940d2975fecd2d5b30bdfb9 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 21 Dec 2019 06:59:56 +0000 Subject: [PATCH 148/172] Fix Button process() on Micropython Micropython doesn't handle deep superclass constructors properly, so the _state attribute wasn't initialized. --- ev3dev2/button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ev3dev2/button.py b/ev3dev2/button.py index 5b742e8..b169697 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -130,7 +130,7 @@ def process(self, new_state=None): """ if new_state is None: new_state = set(self.buttons_pressed) - old_state = self._state + old_state = self._state if hasattr(self, '_state') else set() self._state = new_state state_diff = new_state.symmetric_difference(old_state) From 06604a6c155b054f213dcbe47ae968a86f8df804 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 28 Dec 2019 12:37:54 -1000 Subject: [PATCH 149/172] Add seconds and elapsed checks to stopwatch (#667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add seconds and elapsed checks to stopwatch Also did general clean-up. * Update ev3dev2/stopwatch.py Co-Authored-By: Matěj Volf * Update ev3dev2/stopwatch.py Co-Authored-By: Matěj Volf * Improve behavior or restart and add new reset Co-authored-by: Matěj Volf --- debian/changelog | 8 +++ ev3dev2/stopwatch.py | 129 +++++++++++++++++++++++++++++------------ tests/api_tests.py | 133 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 37 deletions(-) diff --git a/debian/changelog b/debian/changelog index f2490a4..220f22b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium + + [Kaelin Laundry] + * Add "value_secs" and "is_elapsed_*" methods to StopWatch + * Rename "value_hms" to "hms_str". New "value_hms" returns original tuple. + + -- UNRELEASED + python-ev3dev2 (2.0.0) stretch; urgency=medium [David Lechner] diff --git a/ev3dev2/stopwatch.py b/ev3dev2/stopwatch.py index 3029b84..35b804b 100644 --- a/ev3dev2/stopwatch.py +++ b/ev3dev2/stopwatch.py @@ -9,76 +9,131 @@ else: import datetime as dt - def get_ticks_ms(): if is_micropython(): return utime.ticks_ms() else: return int(dt.datetime.timestamp(dt.datetime.now()) * 1000) +class StopWatchAlreadyStartedException(Exception): + """ + Exception raised when start() is called on a StopWatch which was already start()ed and not yet + stopped. + """ + pass class StopWatch(object): - + """ + A timer class which lets you start timing and then check the amount of time + elapsed. + """ def __init__(self, desc=None): + """ + Initializes the StopWatch but does not start it. + + desc: + A string description to print when stringifying. + """ self.desc = desc - self._value = 0 - self.start_time = None - self.prev_update_time = None + self._start_time = None + self._stopped_total_time = None def __str__(self): - if self.desc is not None: - return self.desc - else: - return self.__class__.__name__ + name = self.desc if self.desc is not None else self.__class__.__name__ + return "{}: {}".format(name, self.hms_str) def start(self): - assert self.start_time is None, "%s is already running" % self - self.start_time = get_ticks_ms() - - def update(self): - - if self.start_time is None: - return - - current_time = get_ticks_ms() + """ + Starts the timer. If the timer is already running, resets it. - if self.prev_update_time is None: - delta = current_time - self.start_time - else: - delta = current_time - self.prev_update_time + Raises a :py:class:`ev3dev2.stopwatch.StopWatchAlreadyStartedException` if already started. + """ + if self.is_started: + raise StopWatchAlreadyStartedException() - self._value += delta - self.prev_update_time = current_time + self._stopped_total_time = None + self._start_time = get_ticks_ms() def stop(self): - - if self.start_time is None: + """ + Stops the timer. The time value of this Stopwatch is paused and will not continue increasing. + """ + if self._start_time is None: return - self.update() - self.start_time = None - self.prev_update_time = None + self._stopped_total_time = get_ticks_ms() - self._start_time + self._start_time = None def reset(self): - self.stop() - self._value = 0 + """ + Resets the timer and leaves it stopped. + """ + self._start_time = None + self._stopped_total_time = None + + def restart(self): + """ + Resets and then starts the timer. + """ + self.reset() + self.start() + + @property + def is_started(self): + """ + True if the StopWatch has been started but not stoped (i.e., it's currently running), false otherwise. + """ + return self._start_time is not None @property def value_ms(self): """ Returns the value of the stopwatch in milliseconds """ - self.update() - return self._value + if self._stopped_total_time is not None: + return self._stopped_total_time + + return get_ticks_ms() - self._start_time if self._start_time is not None else 0 + + @property + def value_secs(self): + """ + Returns the value of the stopwatch in seconds + """ + return self.value_ms / 1000 @property def value_hms(self): """ - Returns the value of the stopwatch in HH:MM:SS.msec format + Returns this StopWatch's elapsed time as a tuple + ``(hours, minutes, seconds, milliseconds)``. """ - self.update() - (hours, x) = divmod(int(self._value), 3600000) + (hours, x) = divmod(int(self.value_ms), 3600000) (mins, x) = divmod(x, 60000) (secs, x) = divmod(x, 1000) + return hours, mins, secs, x + + @property + def hms_str(self): + """ + Returns the stringified value of the stopwatch in HH:MM:SS.msec format + """ + return '%02d:%02d:%02d.%03d' % self.value_hms + + def is_elapsed_ms(self, duration_ms): + """ + Returns True if this timer has measured at least ``duration_ms`` + milliseconds. + Otherwise, returns False. If ``duration_ms`` is None, returns False. + """ + + return duration_ms is not None and self.value_ms >= duration_ms + + def is_elapsed_secs(self, duration_secs): + """ + Returns True if this timer has measured at least ``duration_secs`` seconds. + Otherwise, returns False. If ``duration_secs`` is None, returns False. + """ + + return duration_secs is not None and self.value_secs >= duration_secs - return '%02d:%02d:%02d.%03d' % (hours, mins, secs, x) diff --git a/tests/api_tests.py b/tests/api_tests.py index e979742..527b226 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -30,6 +30,9 @@ DistanceStuds ) +import ev3dev2.stopwatch +from ev3dev2.stopwatch import StopWatch, StopWatchAlreadyStartedException + ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') _internal_set_attribute = ev3dev2.Device._set_attribute @@ -55,6 +58,16 @@ def dummy_wait(self, cond, timeout=None): Motor.wait = dummy_wait +# for StopWatch +mock_ticks_ms = 0 +def _mock_get_ticks_ms(): + return mock_ticks_ms +ev3dev2.stopwatch.get_ticks_ms = _mock_get_ticks_ms + +def set_mock_ticks_ms(value): + global mock_ticks_ms + mock_ticks_ms = value + class TestAPI(unittest.TestCase): def setUp(self): @@ -64,6 +77,9 @@ def setUp(self): except AttributeError: pass + # ensure tests don't depend on order based on StopWatch tick state + set_mock_ticks_ms(0) + def test_device(self): clean_arena() populate_arena([('medium_motor', 0, 'outA'), ('infrared_sensor', 0, 'in1')]) @@ -359,6 +375,123 @@ def test_units(self): self.assertEqual(DistanceStuds(42).mm, 336) + def test_stopwatch(self): + sw = StopWatch() + self.assertEqual(str(sw), "StopWatch: 00:00:00.000") + + sw = StopWatch(desc="test sw") + self.assertEqual(str(sw), "test sw: 00:00:00.000") + self.assertEqual(sw.is_started, False) + + sw.start() + self.assertEqual(sw.is_started, True) + self.assertEqual(sw.value_ms, 0) + self.assertEqual(sw.value_secs, 0) + self.assertEqual(sw.value_hms, (0,0,0,0)) + self.assertEqual(sw.hms_str, "00:00:00.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), False) + self.assertEqual(sw.is_elapsed_secs(3), False) + + set_mock_ticks_ms(1500) + self.assertEqual(sw.is_started, True) + self.assertEqual(sw.value_ms, 1500) + self.assertEqual(sw.value_secs, 1.5) + self.assertEqual(sw.value_hms, (0,0,1,500)) + self.assertEqual(sw.hms_str, "00:00:01.500") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), False) + self.assertEqual(sw.is_elapsed_secs(3), False) + + set_mock_ticks_ms(3000) + self.assertEqual(sw.is_started, True) + self.assertEqual(sw.value_ms, 3000) + self.assertEqual(sw.value_secs, 3) + self.assertEqual(sw.value_hms, (0,0,3,0)) + self.assertEqual(sw.hms_str, "00:00:03.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), True) + self.assertEqual(sw.is_elapsed_secs(3), True) + + set_mock_ticks_ms(1000 * 60 * 75.5) #75.5 minutes + self.assertEqual(sw.is_started, True) + self.assertEqual(sw.value_ms, 1000 * 60 * 75.5) + self.assertEqual(sw.value_secs, 60 * 75.5) + self.assertEqual(sw.value_hms, (1,15,30,0)) + self.assertEqual(sw.hms_str, "01:15:30.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), True) + self.assertEqual(sw.is_elapsed_secs(3), True) + + try: + # StopWatch can't be started if already running + sw.start() + self.fail() + except StopWatchAlreadyStartedException: + pass + + # test reset behavior + sw.restart() + self.assertEqual(sw.is_started, True) + self.assertEqual(sw.value_ms, 0) + self.assertEqual(sw.value_secs, 0) + self.assertEqual(sw.value_hms, (0,0,0,0)) + self.assertEqual(sw.hms_str, "00:00:00.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), False) + self.assertEqual(sw.is_elapsed_secs(3), False) + + set_mock_ticks_ms(1000 * 60 * 75.5 + 3000) + self.assertEqual(sw.is_started, True) + self.assertEqual(sw.value_ms, 3000) + self.assertEqual(sw.value_secs, 3) + self.assertEqual(sw.value_hms, (0,0,3,0)) + self.assertEqual(sw.hms_str, "00:00:03.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), True) + self.assertEqual(sw.is_elapsed_secs(3), True) + + # test stop + sw.stop() + set_mock_ticks_ms(1000 * 60 * 75.5 + 10000) + self.assertEqual(sw.is_started, False) + self.assertEqual(sw.value_ms, 3000) + self.assertEqual(sw.value_secs, 3) + self.assertEqual(sw.value_hms, (0,0,3,0)) + self.assertEqual(sw.hms_str, "00:00:03.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), True) + self.assertEqual(sw.is_elapsed_secs(3), True) + + # test reset + sw.reset() + self.assertEqual(sw.is_started, False) + self.assertEqual(sw.value_ms, 0) + self.assertEqual(sw.value_secs, 0) + self.assertEqual(sw.value_hms, (0,0,0,0)) + self.assertEqual(sw.hms_str, "00:00:00.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), False) + self.assertEqual(sw.is_elapsed_secs(3), False) + + set_mock_ticks_ms(1000 * 60 * 75.5 + 6000) + self.assertEqual(sw.is_started, False) + self.assertEqual(sw.value_ms, 0) + self.assertEqual(sw.value_secs, 0) + self.assertEqual(sw.value_hms, (0,0,0,0)) + self.assertEqual(sw.hms_str, "00:00:00.000") + self.assertEqual(sw.is_elapsed_ms(None), False) + self.assertEqual(sw.is_elapsed_secs(None), False) + self.assertEqual(sw.is_elapsed_ms(3000), False) + self.assertEqual(sw.is_elapsed_secs(3), False) if __name__ == "__main__": unittest.main() From a227613396520b203d50a1ce6e16d0ab7736d31a Mon Sep 17 00:00:00 2001 From: Nithin Shenoy Date: Sat, 28 Dec 2019 20:18:29 -0500 Subject: [PATCH 150/172] Adds Gyro Support to MoveTank (#697) * Adds Gyro Support to MoveTank Adds the following gyro supported functions to MoveTank: - follow_gyro_angle: angle following function that uses the same PID algorithm as the line follower to have the robot drive straight at a specific angle. - pivot_gyro: pivots the robot to a specific angle - calibrate_gyro: flips the mode of the sensor to zero out the gyro to reduce chance of drift * Fixes whitespace * Fixes some PR feedbac * Refactors Color and Gyro sensors to be properties Added explicit color and gyro setters/getters * Modifies private vars to use single underscore Addresses code review feedback including: - making private members use single instead of double underscore - `wiggle_room` added to `turn_to_angle_gyro` for user to control accuracy --- ev3dev2/motor.py | 211 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 206 insertions(+), 5 deletions(-) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 4a8e430..2e2b853 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -1790,6 +1790,15 @@ def _block(self): self.wait_until_not_moving() +# follow gyro angle classes +class FollowGyroAngleErrorTooFast(Exception): + """ + Raised when a gyro following robot has been asked to follow + an angle at an unrealistic speed + """ + pass + + # line follower classes class LineFollowErrorLostLine(Exception): """ @@ -1854,8 +1863,23 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar self.right_motor = self.motors[right_motor_port] self.max_speed = self.left_motor.max_speed - # color sensor used by follow_line() - self.cs = None + # color sensor used by follow_line() + @property + def cs(self): + return self._cs + + @cs.setter + def cs(self, cs): + self._cs = cs + + # gyro sensor used by follow_gyro_angle() + @property + def gyro(self): + return self._gyro + + @gyro.setter + def gyro(self, gyro): + self._gyro = gyro def _unpack_speeds_to_native_units(self, left_speed, right_speed): left_speed = self.left_motor._speed_native_units(left_speed, "left_speed") @@ -2046,10 +2070,10 @@ def follow_line(self, tank.stop() raise """ - assert self.cs, "ColorSensor must be defined" + assert self._cs, "ColorSensor must be defined" if target_light_intensity is None: - target_light_intensity = self.cs.reflected_light_intensity + target_light_intensity = self._cs.reflected_light_intensity integral = 0.0 last_error = 0.0 @@ -2059,7 +2083,7 @@ def follow_line(self, MAX_SPEED = SpeedNativeUnits(self.max_speed) while follow_for(self, **kwargs): - reflected_light_intensity = self.cs.reflected_light_intensity + reflected_light_intensity = self._cs.reflected_light_intensity error = target_light_intensity - reflected_light_intensity integral = integral + error derivative = error - last_error @@ -2099,6 +2123,183 @@ def follow_line(self, self.stop() + def calibrate_gyro(self): + """ + Calibrates the gyro sensor. + + NOTE: This takes 1sec to run + """ + assert self._gyro, "GyroSensor must be defined" + + for x in range(2): + self._gyro.mode = 'GYRO-RATE' + self._gyro.mode = 'GYRO-ANG' + time.sleep(0.5) + + def follow_gyro_angle(self, + kp, ki, kd, + speed, + target_angle=0, + sleep_time=0.01, + follow_for=follow_for_forever, + **kwargs + ): + """ + PID gyro angle follower + + ``kp``, ``ki``, and ``kd`` are the PID constants. + + ``speed`` is the desired speed of the midpoint of the robot + + ``target_angle`` is the angle we want to maintain + + ``sleep_time`` is how many seconds we sleep on each pass through + the loop. This is to give the robot a chance to react + to the new motor settings. This should be something small such + as 0.01 (10ms). + + ``follow_for`` is called to determine if we should keep following the + desired angle or stop. This function will be passed ``self`` (the current + ``MoveTank`` object). Current supported options are: + - ``follow_for_forever`` + - ``follow_for_ms`` + + ``**kwargs`` will be passed to the ``follow_for`` function + + Example: + + .. code:: python + + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent, follow_for_ms + from ev3dev2.sensor.lego import GyroSensor + + # Instantiate the MoveTank object + tank = MoveTank(OUTPUT_A, OUTPUT_B) + + # Initialize the tank's gyro sensor + tank.gyro = GyroSensor() + + try: + # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 + tank.calibrate_gyro() + + # Follow the line for 4500ms + tank.follow_gyro_angle( + kp=11.3, ki=0.05, kd=3.2, + speed=SpeedPercent(30), + target_angle=0 + follow_for=follow_for_ms, + ms=4500 + ) + except FollowGyroAngleErrorTooFast: + tank.stop() + raise + """ + assert self._gyro, "GyroSensor must be defined" + + integral = 0.0 + last_error = 0.0 + derivative = 0.0 + speed_native_units = speed.to_native_units(self.left_motor) + MAX_SPEED = SpeedNativeUnits(self.max_speed) + + assert speed_native_units <= MAX_SPEED, "Speed exceeds the max speed of the motors" + + while follow_for(self, **kwargs): + current_angle = self._gyro.angle + error = current_angle - target_angle + integral = integral + error + derivative = error - last_error + last_error = error + turn_native_units = (kp * error) + (ki * integral) + (kd * derivative) + + left_speed = SpeedNativeUnits(speed_native_units - turn_native_units) + right_speed = SpeedNativeUnits(speed_native_units + turn_native_units) + + if abs(left_speed) > MAX_SPEED: + log.info("%s: left_speed %s is greater than MAX_SPEED %s" % + (self, left_speed, MAX_SPEED)) + self.stop() + raise FollowGyroAngleErrorTooFast( + "The robot is moving too fast to follow the angle") + + if abs(right_speed) > MAX_SPEED: + log.info("%s: right_speed %s is greater than MAX_SPEED %s" % + (self, right_speed, MAX_SPEED)) + self.stop() + raise FollowGyroAngleErrorTooFast( + "The robot is moving too fast to follow the angle") + + if sleep_time: + time.sleep(sleep_time) + + self.on(left_speed, right_speed) + + self.stop() + + def turn_to_angle_gyro(self, + speed, + target_angle=0, + wiggle_room=2, + sleep_time=0.01 + ): + """ + Pivot Turn + + ``speed`` is the desired speed of the midpoint of the robot + + ``target_angle`` is the target angle we want to pivot to + + ``wiggle_room`` is the +/- angle threshold to control how accurate the turn should be + + ``sleep_time`` is how many seconds we sleep on each pass through + the loop. This is to give the robot a chance to react + to the new motor settings. This should be something small such + as 0.01 (10ms). + + Example: + + .. code:: python + + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent + from ev3dev2.sensor.lego import GyroSensor + + # Instantiate the MoveTank object + tank = MoveTank(OUTPUT_A, OUTPUT_B) + + # Initialize the tank's gyro sensor + tank.gyro = GyroSensor() + + # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 + tank.calibrate_gyro() + + # Pivot 30 degrees + tank.turn_to_angle_gyro( + speed=SpeedPercent(5), + target_angle(30) + ) + """ + assert self._gyro, "GyroSensor must be defined" + + speed_native_units = speed.to_native_units(self.left_motor) + target_reached = False + + while not target_reached: + current_angle = self._gyro.angle + if abs(current_angle - target_angle) <= wiggle_room: + target_reached = True + self.stop() + elif (current_angle > target_angle): + left_speed = SpeedNativeUnits(-1 * speed_native_units) + right_speed = SpeedNativeUnits(speed_native_units) + else: + left_speed = SpeedNativeUnits(speed_native_units) + right_speed = SpeedNativeUnits(-1 * speed_native_units) + + if sleep_time: + time.sleep(sleep_time) + + self.on(left_speed, right_speed) class MoveSteering(MoveTank): """ From 77cbf559924ce66c278f970971f844b7e33b3c4d Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 28 Dec 2019 17:42:37 -0800 Subject: [PATCH 151/172] Update changelog --- debian/changelog | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/debian/changelog b/debian/changelog index 220f22b..6418b67 100644 --- a/debian/changelog +++ b/debian/changelog @@ -3,6 +3,10 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Kaelin Laundry] * Add "value_secs" and "is_elapsed_*" methods to StopWatch * Rename "value_hms" to "hms_str". New "value_hms" returns original tuple. + * Fix bug in Button.process() on Micropython + + [Nithin Shenoy] + * Add Gyro-based driving support to MoveTank -- UNRELEASED From 8b43d6ff0641c0403e2bf6aacdad8bc26b8781fe Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Mon, 30 Dec 2019 09:09:04 -0500 Subject: [PATCH 152/172] Add rest notes to sound.Sound.play_song() (#701) --- debian/changelog | 3 +++ ev3dev2/sound.py | 62 ++++++++++++++++++++++++++---------------------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/debian/changelog b/debian/changelog index 6418b67..e305fe1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,8 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium + [Matěj Volf] + * Add rest notes to sound.Sound.play_song() + [Kaelin Laundry] * Add "value_secs" and "is_elapsed_*" methods to StopWatch * Rename "value_hms" to "hms_str". New "value_hms" returns original tuple. diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index cd2f4e4..a1cdbad 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -31,6 +31,7 @@ from ev3dev2 import is_micropython import os import re +from time import sleep if not is_micropython(): import shlex @@ -43,7 +44,7 @@ def _make_scales(notes): for note, freq in notes: freq = round(freq) for n in note.split('/'): - res[n] = freq + res[n.upper()] = freq return res @@ -77,14 +78,18 @@ class Sound(object): Examples:: + from ev3dev2.sound import Sound + + spkr = Sound() + # Play 'bark.wav': - Sound.play_file('bark.wav') + spkr.play_file('bark.wav') # Introduce yourself: - Sound.speak('Hello, I am Robot') + spkr.speak('Hello, I am Robot') # Play a small song - Sound.play_song(( + spkr.play_song(( ('D4', 'e3'), ('D4', 'e3'), ('D4', 'e3'), @@ -406,6 +411,7 @@ def play_song(self, song, tempo=120, delay=0.05): and duration. It supports symbolic notes (e.g. ``A4``, ``D#3``, ``Gb5``) and durations (e.g. ``q``, ``h``). + You can also specify rests by using ``R`` instead of note pitch. For an exhaustive list of accepted note symbols and values, have a look at the ``_NOTE_FREQUENCIES`` and ``_NOTE_VALUES`` private dictionaries in the source code. @@ -429,7 +435,9 @@ def play_song(self, song, tempo=120, delay=0.05): >>> # A long time ago in a galaxy far, >>> # far away... - >>> Sound.play_song(( + >>> from ev3dev2.sound import Sound + >>> spkr = Sound() + >>> spkr.play_song(( >>> ('D4', 'e3'), # intro anacrouse >>> ('D4', 'e3'), >>> ('D4', 'e3'), @@ -471,41 +479,39 @@ def play_song(self, song, tempo=120, delay=0.05): delay_ms = int(delay * 1000) meas_duration_ms = 60000 / tempo * 4 # we only support 4/4 bars, hence "* 4" - def beep_args(note, value): - """ Builds the arguments string for producing a beep matching - the requested note and value. - - Args: - note (str): the note note and octave - value (str): the note value expression - Returns: - str: the arguments to be passed to the beep command - """ - freq = self._NOTE_FREQUENCIES.get(note.upper(), self._NOTE_FREQUENCIES[note]) + for (note, value) in song: + value = value.lower() if '/' in value: base, factor = value.split('/') - duration_ms = meas_duration_ms * self._NOTE_VALUES[base] / float(factor) + factor = float(factor) + elif '*' in value: base, factor = value.split('*') - duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * float(factor) + factor = float(factor) + elif value.endswith('.'): base = value[:-1] - duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * 1.5 + factor = 1.5 + elif value.endswith('3'): base = value[:-1] - duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * 2 / 3 + factor = float(2/3) + else: - duration_ms = meas_duration_ms * self._NOTE_VALUES[value] + base = value + factor = 1.0 - return '-f %d -l %d -D %d' % (freq, duration_ms, delay_ms) + try: + duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * factor + except KeyError: + raise ValueError('invalid note (%s)' % base) - try: - return self.beep(' -n '.join( - [beep_args(note, value) for (note, value) in song] - )) - except KeyError as e: - raise ValueError('invalid note (%s)' % e) + if note == "R": + sleep(duration_ms / 1000 + delay) + else: + freq = self._NOTE_FREQUENCIES[note.upper()] + self.beep('-f %d -l %d -D %d' % (freq, duration_ms, delay_ms)) #: Note frequencies. #: From 64b7beddc534fe014b3c3e05807f4cd97a3611a9 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Mon, 30 Dec 2019 09:11:34 -0500 Subject: [PATCH 153/172] LED animation fix duration None (#703) --- debian/changelog | 1 + ev3dev2/led.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/debian/changelog b/debian/changelog index e305fe1..c11797a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Matěj Volf] + * LED animation fix duration None * Add rest notes to sound.Sound.play_song() [Kaelin Laundry] diff --git a/ev3dev2/led.py b/ev3dev2/led.py index d6669cc..50a545b 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -354,6 +354,8 @@ def all_off(self): if not self.leds: return + self.animate_stop() + for led in self.leds.values(): led.brightness = 0 @@ -401,7 +403,7 @@ def animate_police_lights(self, color1, color2, group1='LEFT', group2='RIGHT', s def _animate_police_lights(): self.all_off() even = True - duration_ms = duration * 1000 + duration_ms = duration * 1000 if duration is not None else None stopwatch = StopWatch() stopwatch.start() @@ -413,7 +415,7 @@ def _animate_police_lights(): self.set_color(group1, color2) self.set_color(group2, color1) - if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: + if self.animate_thread_stop or stopwatch.is_elapsed_ms(duration_ms): break even = not even @@ -446,7 +448,7 @@ def animate_flash(self, color, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duration def _animate_flash(): even = True - duration_ms = duration * 1000 + duration_ms = duration * 1000 if duration is not None else None stopwatch = StopWatch() stopwatch.start() @@ -457,7 +459,7 @@ def _animate_flash(): else: self.all_off() - if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: + if self.animate_thread_stop or stopwatch.is_elapsed_ms(duration_ms): break even = not even @@ -491,7 +493,7 @@ def animate_cycle(self, colors, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duratio def _animate_cycle(): index = 0 max_index = len(colors) - duration_ms = duration * 1000 + duration_ms = duration * 1000 if duration is not None else None stopwatch = StopWatch() stopwatch.start() @@ -504,7 +506,7 @@ def _animate_cycle(): if index == max_index: index = 0 - if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: + if self.animate_thread_stop or stopwatch.is_elapsed_ms(duration_ms): break sleep(sleeptime) @@ -545,7 +547,7 @@ def _animate_rainbow(): MIN_VALUE = 0 MAX_VALUE = 1 self.all_off() - duration_ms = duration * 1000 + duration_ms = duration * 1000 if duration is not None else None stopwatch = StopWatch() stopwatch.start() @@ -580,7 +582,7 @@ def _animate_rainbow(): elif state == 3 and right_value == MIN_VALUE: state = 0 - if self.animate_thread_stop or stopwatch.value_ms >= duration_ms: + if self.animate_thread_stop or stopwatch.is_elapsed_ms(duration_ms): break sleep(sleeptime) From 72c929208ae20e90a487c93f61195fb3fdeb6421 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sat, 11 Jan 2020 11:41:40 -0500 Subject: [PATCH 154/172] RPyC update docs and make it easier to use (#700) --- debian/changelog | 3 + docs/rpyc.rst | 156 ++++++++++++++++++++++++++++------------------- 2 files changed, 95 insertions(+), 64 deletions(-) diff --git a/debian/changelog b/debian/changelog index c11797a..e221d7b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,8 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium + [Daniel Walton] + * RPyC update docs and make it easier to use + [Matěj Volf] * LED animation fix duration None * Add rest notes to sound.Sound.play_song() diff --git a/docs/rpyc.rst b/docs/rpyc.rst index 58ff0c2..46a41d6 100644 --- a/docs/rpyc.rst +++ b/docs/rpyc.rst @@ -1,87 +1,115 @@ -Working with ev3dev remotely using RPyC -======================================= +************** +RPyC on ev3dev +************** -RPyC_ (pronounced as are-pie-see), or Remote Python Call, is a transparent -python library for symmetrical remote procedure calls, clustering and -distributed-computing. RPyC makes use of object-proxying, a technique that -employs python’s dynamic nature, to overcome the physical boundaries between -processes and computers, so that remote objects can be manipulated as if they -were local. Here are simple steps you need to follow in order to install and -use RPyC with ev3dev: +`RPyC_ `_ (pronounced as are-pie-see) can be used to: +* run a python program on an ev3dev device that controls another ev3dev device. +This is more commonly known as daisy chaining. +* run a python program on your laptop that controls an ev3dev device. This can be +useful if your robot requires CPU intensive code that would be slow to run on the +EV3. A good example of this is a Rubik's cube solver, calculating the solution to +solve a Rubik's cube can be slow on an EV3. -1. Install RPyC both on the EV3 and on your desktop PC. For the EV3, enter the - following command at the command prompt (after you `connect with SSH`_): +For both of these scenarios you can use RPyC to control multiple remote ev3dev devices. - .. code-block:: shell - - sudo easy_install3 rpyc - On the desktop PC, it really depends on your operating system. In case it is - some flavor of linux, you should be able to do +Networking +========== +You will need IP connectivity between the device where your python code runs +(laptop, an ev3dev device, etc) and the remote ev3dev devices. Some common scenarios +might be: +* Multiple EV3s on the same WiFi network +* A laptop and an EV3 on the same WiFi network +* A bluetooth connection between two EV3s - .. code-block:: shell +The `ev3dev networking documentation `_ should get +you up and running in terms of networking connectivity. - sudo pip3 install rpyc - In case it is Windows, there is a win32 installer on the project's - `sourceforge page`_. Also, have a look at the `Download and Install`_ page - on their site. +Install +======= -2. Create file ``rpyc_server.sh`` with the following contents on the EV3: +1. RPyC is installed on ev3dev but we need to create a service that launches + ``rpyc_classic.py`` at bootup. `SSH `_ to your remote ev3dev devices and + cut-n-paste the following commands at the bash prompt. .. code-block:: shell - #!/bin/bash - python3 `which rpyc_classic.py` + echo "[Unit] + Description=RPyC Classic Service + After=multi-user.target - and make the file executable: + [Service] + Type=simple + ExecStart=/usr/bin/rpyc_classic.py - .. code-block:: shell + [Install] + WantedBy=multi-user.target" > rpyc-classic.service - chmod +x rpyc_server.sh + sudo cp rpyc-classic.service /lib/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable rpyc-classic.service + sudo systemctl start rpyc-classic.service - Launch the created file either from SSH session (with - ``./rpyc_server.sh`` command), or from brickman. It should output something - like - .. code-block:: none +2. If you will be using an ev3dev device to control another ev3dev device you + can skip this step. If you will be using your desktop PC to control an ev3dev + device you must install RPyC on your desktop PC. How you install RPyC depends + on your operating system. For Linux you should be able to do: - INFO:SLAVE/18812:server started on [0.0.0.0]:18812 + .. code-block:: shell + + sudo apt-get install python3-rpyc - and keep running. + For Windows there is a win32 installer on the project's `sourceforge page`_. + Also, have a look at the `Download and Install`_ page on their site. -3. Now you are ready to connect to the RPyC server from your desktop PC. The - following python script should make a large motor connected to output port - ``A`` spin for a second. +Example +======= +We will run code on our laptop to control the remote ev3dev device with IP +address X.X.X.X. The goal is to have the LargeMotor connected to ``OUTPUT_A`` +run when the TouchSensor on ``INPUT_1`` is pressed. .. code-block:: py import rpyc - conn = rpyc.classic.connect('ev3dev') # host name or IP address of the EV3 - ev3 = conn.modules['ev3dev2.ev3'] # import ev3dev2.ev3 remotely - m = ev3.LargeMotor('outA') - m.run_timed(time_sp=1000, speed_sp=600) - -You can run scripts like this from any interactive python environment, like -ipython shell/notebook, spyder, pycharm, etc. - -Some *advantages* of using RPyC with ev3dev are: - -* It uses much less resources than running ipython notebook on EV3; RPyC server - is lightweight, and only requires an IP connection to the EV3 once set up (no - ssh required). -* The scripts you are working with are actually stored and edited on your - desktop PC, with your favorite editor/IDE. -* Some robots may need much more computational power than what EV3 can give - you. A notable example is the Rubics cube solver: there is an algorithm that - provides almost optimal solution (in terms of number of cube rotations), but - it takes more RAM than is available on EV3. With RPYC, you could run the - heavy-duty computations on your desktop. - -The most obvious *disadvantage* is latency introduced by network connection. -This may be a show stopper for robots where reaction speed is essential. - -.. _RPyC: http://rpyc.readthedocs.io/ -.. _sourceforge page: http://sourceforge.net/projects/rpyc/files/main -.. _Download and Install: http://rpyc.readthedocs.io/en/latest/install.html -.. _connect with SSH: http://www.ev3dev.org/docs/tutorials/connecting-to-ev3dev-with-ssh/ + + # Create a RPyC connection to the remote ev3dev device. + # Use the hostname or IP address of the ev3dev device. + # If this fails, verify your IP connectivty via ``ping X.X.X.X`` + conn = rpyc.classic.connect('X.X.X.X') + + # import ev3dev2 on the remote ev3dev device + ev3dev2_motor = conn.modules['ev3dev2.motor'] + ev3dev2_sensor = conn.modules['ev3dev2.sensor'] + ev3dev2_sensor_lego = conn.modules['ev3dev2.sensor.lego'] + + # Use the LargeMotor and TouchSensor on the remote ev3dev device + motor = ev3dev2_motor.LargeMotor(ev3dev2_motor.OUTPUT_A) + ts = ev3dev2_sensor_lego.TouchSensor(ev3dev2_sensor.INPUT_1) + + # If the TouchSensor is pressed, run the motor + while True: + ts.wait_for_pressed() + motor.run_forever(speed_sp=200) + + ts.wait_for_released() + motor.stop() + +Pros +==== +* RPyC is lightweight and only requires an IP connection (no ssh required). +* Some robots may need much more computational power than an EV3 can give + you. A notable example is the Rubik's cube solver. + +Cons +==== +* Latency will be introduced by the network connection. This may be a show stopper for robots where reaction speed is essential. +* RPyC is only supported by python, it is *NOT* supported by micropython + +References +========== +* `RPyC `_ +* `sourceforge page `_ +* `Download and Install `_ +* `connect with SSH `_ From c4cbe75edd69310dc2a81c10e76f03274b3fc383 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 12 Jan 2020 08:54:20 -0500 Subject: [PATCH 155/172] use double backticks consistently in docstrings (#710) --- debian/changelog | 1 + ev3dev2/button.py | 32 ++-- ev3dev2/console.py | 38 ++--- ev3dev2/display.py | 17 +-- ev3dev2/led.py | 38 ++--- ev3dev2/motor.py | 218 +++++++++++++-------------- ev3dev2/port.py | 16 +- ev3dev2/power.py | 8 - ev3dev2/sensor/__init__.py | 32 ++-- ev3dev2/sound.py | 4 +- ev3dev2/unit.py | 2 +- utils/console_fonts.py | 6 +- utils/line-follower-find-kp-ki-kd.py | 6 +- 13 files changed, 206 insertions(+), 212 deletions(-) diff --git a/debian/changelog b/debian/changelog index e221d7b..29947f4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -2,6 +2,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Daniel Walton] * RPyC update docs and make it easier to use + * use double backticks consistently in docstrings [Matěj Volf] * LED animation fix duration None diff --git a/ev3dev2/button.py b/ev3dev2/button.py index b169697..f43d1e6 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -71,8 +71,8 @@ def __str__(self): @staticmethod def on_change(changed_buttons): """ - This handler is called by `process()` whenever state of any button has - changed since last `process()` call. `changed_buttons` is a list of + This handler is called by ``process()`` whenever state of any button has + changed since last ``process()`` call. ``changed_buttons`` is a list of tuples of changed button names and their states. """ pass @@ -89,7 +89,7 @@ def any(self): def check_buttons(self, buttons=[]): """ - Check if currently pressed buttons exactly match the given list. + Check if currently pressed buttons exactly match the given list ``buttons``. """ return set(self.buttons_pressed) == set(buttons) @@ -98,20 +98,20 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): def wait_for_pressed(self, buttons, timeout_ms=None): """ - Wait for the button to be pressed down. + Wait for ``buttons`` to be pressed down. """ return self._wait(buttons, [], timeout_ms) def wait_for_released(self, buttons, timeout_ms=None): """ - Wait for the button to be released. + Wait for ``buttons`` to be released. """ return self._wait([], buttons, timeout_ms) def wait_for_bump(self, buttons, timeout_ms=None): """ - Wait for the button to be pressed down and then released. - Both actions must happen within timeout_ms. + Wait for ``buttons`` to be pressed down and then released. + Both actions must happen within ``timeout_ms``. """ stopwatch = StopWatch() stopwatch.start() @@ -125,7 +125,7 @@ def wait_for_bump(self, buttons, timeout_ms=None): def process(self, new_state=None): """ - Check for currenly pressed buttons. If the new state differs from the + Check for currenly pressed buttons. If the ``new_state`` differs from the old state, call the appropriate button event handlers (on_up, on_down, etc). """ if new_state is None: @@ -146,9 +146,9 @@ def process(self, new_state=None): class EV3ButtonCommon(object): - # These handlers are called by `ButtonCommon.process()` whenever the + # These handlers are called by ButtonCommon.process() whenever the # state of 'up', 'down', etc buttons have changed since last - # `ButtonCommon.process()` call + # ButtonCommon.process() call on_up = None on_down = None on_left = None @@ -159,42 +159,42 @@ class EV3ButtonCommon(object): @property def up(self): """ - Check if 'up' button is pressed. + Check if ``up`` button is pressed. """ return 'up' in self.buttons_pressed @property def down(self): """ - Check if 'down' button is pressed. + Check if ``down`` button is pressed. """ return 'down' in self.buttons_pressed @property def left(self): """ - Check if 'left' button is pressed. + Check if ``left`` button is pressed. """ return 'left' in self.buttons_pressed @property def right(self): """ - Check if 'right' button is pressed. + Check if ``right`` button is pressed. """ return 'right' in self.buttons_pressed @property def enter(self): """ - Check if 'enter' button is pressed. + Check if ``enter`` button is pressed. """ return 'enter' in self.buttons_pressed @property def backspace(self): """ - Check if 'backspace' button is pressed. + Check if ``backspace`` button is pressed. """ return 'backspace' in self.buttons_pressed diff --git a/ev3dev2/console.py b/ev3dev2/console.py index d7c0324..a7d2dc0 100644 --- a/ev3dev2/console.py +++ b/ev3dev2/console.py @@ -34,7 +34,7 @@ def __init__(self, font="Lat15-TerminusBold24x12"): Parameter: - - `font` (string): Font name, as found in `/usr/share/consolefonts/` + - ``font`` (string): Font name, as found in ``/usr/share/consolefonts/`` """ self._font = None @@ -94,7 +94,7 @@ def cursor(self, value): def text_at(self, text, column=1, row=1, reset_console=False, inverse=False, alignment="L"): """ - Display `text` (string) at grid position (`column`, `row`). + Display ``text`` (string) at grid position (``column``, ``row``). Note that the grid locations are 1-based (not 0-based). Depending on the font, the number of columns and rows supported by the EV3 LCD console @@ -102,30 +102,30 @@ def text_at(self, text, column=1, row=1, reset_console=False, inverse=False, ali 44 columns and 21 rows. The default font for the Console() class results in a grid that is 14 columns and 5 rows. - Using the `inverse=True` parameter will display the `text` with more emphasis and contrast, + Using the ``inverse=True`` parameter will display the ``text`` with more emphasis and contrast, as the background of the text will be black, and the foreground is white. Using inverse can help in certain situations, such as to indicate when a color sensor senses black, or the gyro sensor is pointing to zero. - Use the `alignment` parameter to enable the function to align the `text` differently to the - column/row values passed-in. Use `L` for left-alignment (default), where the first character - in the `text` will show at the column/row position. Use `R` for right-alignment, where the - last character will show at the column/row position. Use `C` for center-alignment, where the + Use the ``alignment`` parameter to enable the function to align the ``text`` differently to the + column/row values passed-in. Use ``L`` for left-alignment (default), where the first character + in the ``text`` will show at the column/row position. Use ``R`` for right-alignment, where the + last character will show at the column/row position. Use ``C`` for center-alignment, where the text string will centered at the column/row position (as close as possible using integer division--odd-length text string will center better than even-length). Parameters: - - `text` (string): Text to display - - `column` (int): LCD column position to start the text (1 = left column); + - ``text`` (string): Text to display + - ``column`` (int): LCD column position to start the text (1 = left column); text will wrap when it reaches the right edge - - `row` (int): LCD row position to start the text (1 = top row) - - `reset_console` (bool): ``True`` to reset the EV3 LCD console before showing + - ``row`` (int): LCD row position to start the text (1 = top row) + - ``reset_console`` (bool): ``True`` to reset the EV3 LCD console before showing the text; default is ``False`` - - `inverse` (bool): ``True`` for white on black, otherwise black on white; + - ``inverse`` (bool): ``True`` for white on black, otherwise black on white; default is ``False`` - - `alignment` (string): Align the `text` horizontally. Use `L` for left-alignment (default), - `R` for right-alignment, or `C` for center-alignment + - ``alignment`` (string): Align the ``text`` horizontally. Use ``L`` for left-alignment (default), + ``R`` for right-alignment, or ``C`` for center-alignment """ @@ -149,8 +149,8 @@ def set_font(self, font="Lat15-TerminusBold24x12", reset_console=True): Parameters: - - `font` (string): Font name, as found in `/usr/share/consolefonts/` - - `reset_console` (bool): ``True`` to reset the EV3 LCD console + - ``font`` (string): Font name, as found in ``/usr/share/consolefonts/`` + - ``reset_console`` (bool): ``True`` to reset the EV3 LCD console after the font change; default is ``True`` """ @@ -166,13 +166,13 @@ def set_font(self, font="Lat15-TerminusBold24x12", reset_console=True): def clear_to_eol(self, column=None, row=None): """ - Clear to the end of line from the `column` and `row` position + Clear to the end of line from the ``column`` and ``row`` position on the EV3 LCD console. Default to current cursor position. Parameters: - - `column` (int): LCD column position to move to before clearing - - `row` (int): LCD row position to move to before clearing + - ``column`` (int): LCD column position to move to before clearing + - ``row`` (int): LCD row position to move to before clearing """ if column is not None and row is not None: diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 41d4ccf..2a46744 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -157,9 +157,8 @@ def __init__(self, fbdev=None): def _open_fbdev(fbdev=None): """Return the framebuffer file descriptor. - Try to use the FRAMEBUFFER - environment variable if fbdev is not given. Use '/dev/fb0' by - default. + Try to use the FRAMEBUFFER environment variable if fbdev is + not given. Use '/dev/fb0' by default. """ dev = fbdev or os.getenv('FRAMEBUFFER', '/dev/fb0') fbfid = os.open(dev, os.O_RDWR) @@ -370,19 +369,19 @@ def point(self, clear_screen=True, x=10, y=10, point_color='black'): def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', font=None): """ - Display `text` starting at pixel (x, y). + Display ``text`` starting at pixel (x, y). The EV3 display is 178x128 pixels - (0, 0) would be the top left corner of the display - (89, 64) would be right in the middle of the display - 'text_color' : PIL says it supports "common HTML color names". There + ``text_color`` : PIL says it supports "common HTML color names". There are 140 HTML color names listed here that are supported by all modern browsers. This is probably a good list to start with. https://www.w3schools.com/colors/colors_names.asp - 'font' : can be any font displayed here + ``font`` : can be any font displayed here http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/display.html#bitmap-fonts - If font is a string, it is the name of a font to be loaded. @@ -404,18 +403,18 @@ def text_pixels(self, text, clear_screen=True, x=0, y=0, text_color='black', fon def text_grid(self, text, clear_screen=True, x=0, y=0, text_color='black', font=None): """ - Display 'text' starting at grid (x, y) + Display ``text`` starting at grid (x, y) The EV3 display can be broken down in a grid that is 22 columns wide and 12 rows tall. Each column is 8 pixels wide and each row is 10 pixels tall. - 'text_color' : PIL says it supports "common HTML color names". There + ``text_color`` : PIL says it supports "common HTML color names". There are 140 HTML color names listed here that are supported by all modern browsers. This is probably a good list to start with. https://www.w3schools.com/colors/colors_names.asp - 'font' : can be any font displayed here + ``font`` : can be any font displayed here http://ev3dev-lang.readthedocs.io/projects/python-ev3dev/en/ev3dev-stretch/display.html#bitmap-fonts - If font is a string, it is the name of a font to be loaded. diff --git a/ev3dev2/led.py b/ev3dev2/led.py index 50a545b..de1f1fd 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -110,7 +110,7 @@ def max_brightness(self): @property def brightness(self): """ - Sets the brightness level. Possible values are from 0 to `max_brightness`. + Sets the brightness level. Possible values are from 0 to ``max_brightness``. """ self._brightness, value = self.get_attr_int(self._brightness, 'brightness') return value @@ -133,17 +133,17 @@ def trigger(self): Sets the LED trigger. A trigger is a kernel based source of LED events. Triggers can either be simple or complex. A simple trigger isn't configurable and is designed to slot into existing subsystems with - minimal additional code. Examples are the `ide-disk` and `nand-disk` + minimal additional code. Examples are the ``ide-disk`` and ``nand-disk`` triggers. Complex triggers whilst available to all LEDs have LED specific - parameters and work on a per LED basis. The `timer` trigger is an example. - The `timer` trigger will periodically change the LED brightness between - 0 and the current brightness setting. The `on` and `off` time can - be specified via `delay_{on,off}` attributes in milliseconds. + parameters and work on a per LED basis. The ``timer`` trigger is an example. + The ``timer`` trigger will periodically change the LED brightness between + 0 and the current brightness setting. The ``on`` and ``off`` time can + be specified via ``delay_{on,off}`` attributes in milliseconds. You can change the brightness value of a LED independently of the timer trigger. However, if you set the brightness value to 0 it will - also disable the `timer` trigger. + also disable the ``timer`` trigger. """ self._trigger, value = self.get_attr_from_set(self._trigger, 'trigger') return value @@ -179,9 +179,9 @@ def trigger(self, value): @property def delay_on(self): """ - The `timer` trigger will periodically change the LED brightness between - 0 and the current brightness setting. The `on` time can - be specified via `delay_on` attribute in milliseconds. + The ``timer`` trigger will periodically change the LED brightness between + 0 and the current brightness setting. The ``on`` time can + be specified via ``delay_on`` attribute in milliseconds. """ # Workaround for ev3dev/ev3dev#225. @@ -219,9 +219,9 @@ def delay_on(self, value): @property def delay_off(self): """ - The `timer` trigger will periodically change the LED brightness between - 0 and the current brightness setting. The `off` time can - be specified via `delay_off` attribute in milliseconds. + The ``timer`` trigger will periodically change the LED brightness between + 0 and the current brightness setting. The ``off`` time can + be specified via ``delay_off`` attribute in milliseconds. """ # Workaround for ev3dev/ev3dev#225. @@ -241,11 +241,13 @@ def delay_off(self): @delay_off.setter def delay_off(self, value): - # Workaround for ev3dev/ev3dev#225. - # 'delay_on' and 'delay_off' attributes are created when trigger is set - # to 'timer', and destroyed when it is set to anything else. - # This means the file cache may become outdated, and we may have to - # reopen the file. + """ + Workaround for ev3dev/ev3dev#225. + ``delay_on`` and ``delay_off`` attributes are created when trigger is set + to ``timer``, and destroyed when it is set to anything else. + This means the file cache may become outdated, and we may have to + reopen the file. + """ for retry in (True, False): try: self._delay_off = self.set_attr_int(self._delay_off, 'delay_off', value) diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 2e2b853..1c7e4b3 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -259,7 +259,7 @@ class Motor(Device): The motor class provides a uniform interface for using motors with positional and directional feedback such as the EV3 and NXT motors. This feedback allows for precise control of the motors. This is the - most common type of motor, so we just call it `motor`. + most common type of motor, so we just call it ``motor``. """ SYSTEM_CLASS_NAME = 'tacho-motor' @@ -303,27 +303,27 @@ class Motor(Device): #: Run the motor until another command is sent. COMMAND_RUN_FOREVER = 'run-forever' - #: Run to an absolute position specified by `position_sp` and then - #: stop using the action specified in `stop_action`. + #: Run to an absolute position specified by ``position_sp`` and then + #: stop using the action specified in ``stop_action``. COMMAND_RUN_TO_ABS_POS = 'run-to-abs-pos' - #: Run to a position relative to the current `position` value. - #: The new position will be current `position` + `position_sp`. + #: Run to a position relative to the current ``position`` value. + #: The new position will be current ``position`` + ``position_sp``. #: When the new position is reached, the motor will stop using - #: the action specified by `stop_action`. + #: the action specified by ``stop_action``. COMMAND_RUN_TO_REL_POS = 'run-to-rel-pos' - #: Run the motor for the amount of time specified in `time_sp` - #: and then stop the motor using the action specified by `stop_action`. + #: Run the motor for the amount of time specified in ``time_sp`` + #: and then stop the motor using the action specified by ``stop_action``. COMMAND_RUN_TIMED = 'run-timed' - #: Run the motor at the duty cycle specified by `duty_cycle_sp`. - #: Unlike other run commands, changing `duty_cycle_sp` while running *will* + #: Run the motor at the duty cycle specified by ``duty_cycle_sp``. + #: Unlike other run commands, changing ``duty_cycle_sp`` while running *will* #: take effect immediately. COMMAND_RUN_DIRECT = 'run-direct' #: Stop any of the run commands before they are complete using the - #: action specified by `stop_action`. + #: action specified by ``stop_action``. COMMAND_STOP = 'stop' #: Reset all of the motor parameter attributes to their default value. @@ -336,11 +336,11 @@ class Motor(Device): #: Sets the inversed polarity of the rotary encoder. ENCODER_POLARITY_INVERSED = 'inversed' - #: With `normal` polarity, a positive duty cycle will + #: With ``normal`` polarity, a positive duty cycle will #: cause the motor to rotate clockwise. POLARITY_NORMAL = 'normal' - #: With `inversed` polarity, a positive duty cycle will + #: With ``inversed`` polarity, a positive duty cycle will #: cause the motor to rotate counter-clockwise. POLARITY_INVERSED = 'inversed' @@ -353,7 +353,7 @@ class Motor(Device): #: The motor is not turning, but rather attempting to hold a fixed position. STATE_HOLDING = 'holding' - #: The motor is turning, but cannot reach its `speed_sp`. + #: The motor is turning, but cannot reach its ``speed_sp``. STATE_OVERLOADED = 'overloaded' #: The motor is not turning when it should be. @@ -370,7 +370,7 @@ class Motor(Device): #: Does not remove power from the motor. Instead it actively try to hold the motor #: at the current position. If an external force tries to turn the motor, the motor - #: will `push back` to maintain its position. + #: will ``push back`` to maintain its position. STOP_ACTION_HOLD = 'hold' def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -426,7 +426,7 @@ def address(self): @property def command(self): """ - Sends a command to the motor controller. See `commands` for a list of + Sends a command to the motor controller. See ``commands`` for a list of possible values. """ raise Exception("command is a write-only property!") @@ -439,23 +439,23 @@ def command(self, value): def commands(self): """ Returns a list of commands that are supported by the motor - controller. Possible values are `run-forever`, `run-to-abs-pos`, `run-to-rel-pos`, - `run-timed`, `run-direct`, `stop` and `reset`. Not all commands may be supported. - - - `run-forever` will cause the motor to run until another command is sent. - - `run-to-abs-pos` will run to an absolute position specified by `position_sp` - and then stop using the action specified in `stop_action`. - - `run-to-rel-pos` will run to a position relative to the current `position` value. - The new position will be current `position` + `position_sp`. When the new - position is reached, the motor will stop using the action specified by `stop_action`. - - `run-timed` will run the motor for the amount of time specified in `time_sp` - and then stop the motor using the action specified by `stop_action`. - - `run-direct` will run the motor at the duty cycle specified by `duty_cycle_sp`. - Unlike other run commands, changing `duty_cycle_sp` while running *will* + controller. Possible values are ``run-forever``, ``run-to-abs-pos``, ``run-to-rel-pos``, + ``run-timed``, ``run-direct``, ``stop`` and ``reset``. Not all commands may be supported. + + - ``run-forever`` will cause the motor to run until another command is sent. + - ``run-to-abs-pos`` will run to an absolute position specified by ``position_sp`` + and then stop using the action specified in ``stop_action``. + - ``run-to-rel-pos`` will run to a position relative to the current ``position`` value. + The new position will be current ``position`` + ``position_sp``. When the new + position is reached, the motor will stop using the action specified by ``stop_action``. + - ``run-timed`` will run the motor for the amount of time specified in ``time_sp`` + and then stop the motor using the action specified by ``stop_action``. + - ``run-direct`` will run the motor at the duty cycle specified by ``duty_cycle_sp``. + Unlike other run commands, changing ``duty_cycle_sp`` while running *will* take effect immediately. - - `stop` will stop any of the run commands before they are complete using the - action specified by `stop_action`. - - `reset` will reset all of the motor parameter attributes to their default value. + - ``stop`` will stop any of the run commands before they are complete using the + action specified by ``stop_action``. + - ``reset`` will reset all of the motor parameter attributes to their default value. This will also have the effect of stopping the motor. """ (self._commands, value) = self.get_cached_attr_set(self._commands, 'commands') @@ -516,7 +516,7 @@ def duty_cycle_sp(self, value): def full_travel_count(self): """ Returns the number of tacho counts in the full travel of the motor. When - combined with the `count_per_m` atribute, you can use this value to + combined with the ``count_per_m`` atribute, you can use this value to calculate the maximum travel distance of the motor. (linear motors only) """ (self._full_travel_count, value) = self.get_cached_attr_int(self._full_travel_count, 'full_travel_count') @@ -525,10 +525,10 @@ def full_travel_count(self): @property def polarity(self): """ - Sets the polarity of the motor. With `normal` polarity, a positive duty - cycle will cause the motor to rotate clockwise. With `inversed` polarity, + Sets the polarity of the motor. With ``normal`` polarity, a positive duty + cycle will cause the motor to rotate clockwise. With ``inversed`` polarity, a positive duty cycle will cause the motor to rotate counter-clockwise. - Valid values are `normal` and `inversed`. + Valid values are ``normal`` and ``inversed``. """ self._polarity, value = self.get_attr_string(self._polarity, 'polarity') return value @@ -591,9 +591,9 @@ def position_d(self, value): @property def position_sp(self): """ - Writing specifies the target position for the `run-to-abs-pos` and `run-to-rel-pos` + Writing specifies the target position for the ``run-to-abs-pos`` and ``run-to-rel-pos`` commands. Reading returns the current value. Units are in tacho counts. You - can use the value returned by `count_per_rot` to convert tacho counts to/from + can use the value returned by ``count_per_rot`` to convert tacho counts to/from rotations or degrees. """ self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') @@ -606,7 +606,7 @@ def position_sp(self, value): @property def max_speed(self): """ - Returns the maximum value that is accepted by the `speed_sp` attribute. This + Returns the maximum value that is accepted by the ``speed_sp`` attribute. This may be slightly different than the maximum speed that a particular motor can reach - it's the maximum theoretical speed. """ @@ -617,7 +617,7 @@ def max_speed(self): def speed(self): """ Returns the current motor speed in tacho counts per second. Note, this is - not necessarily degrees (although it is for LEGO motors). Use the `count_per_rot` + not necessarily degrees (although it is for LEGO motors). Use the ``count_per_rot`` attribute to convert this value to RPM or deg/sec. """ self._speed, value = self.get_attr_int(self._speed, 'speed') @@ -626,11 +626,11 @@ def speed(self): @property def speed_sp(self): """ - Writing sets the target speed in tacho counts per second used for all `run-*` - commands except `run-direct`. Reading returns the current value. A negative - value causes the motor to rotate in reverse with the exception of `run-to-*-pos` - commands where the sign is ignored. Use the `count_per_rot` attribute to convert - RPM or deg/sec to tacho counts per second. Use the `count_per_m` attribute to + Writing sets the target speed in tacho counts per second used for all ``run-*`` + commands except ``run-direct``. Reading returns the current value. A negative + value causes the motor to rotate in reverse with the exception of ``run-to-*-pos`` + commands where the sign is ignored. Use the ``count_per_rot`` attribute to convert + RPM or deg/sec to tacho counts per second. Use the ``count_per_m`` attribute to convert m/s to tacho counts per second. """ self._speed_sp, value = self.get_attr_int(self._speed_sp, 'speed_sp') @@ -645,9 +645,9 @@ def ramp_up_sp(self): """ Writing sets the ramp up setpoint. Reading returns the current value. Units are in milliseconds and must be positive. When set to a non-zero value, the - motor speed will increase from 0 to 100% of `max_speed` over the span of this + motor speed will increase from 0 to 100% of ``max_speed`` over the span of this setpoint. The actual ramp time is the ratio of the difference between the - `speed_sp` and the current `speed` and max_speed multiplied by `ramp_up_sp`. + ``speed_sp`` and the current ``speed`` and max_speed multiplied by ``ramp_up_sp``. """ self._ramp_up_sp, value = self.get_attr_int(self._ramp_up_sp, 'ramp_up_sp') return value @@ -661,9 +661,9 @@ def ramp_down_sp(self): """ Writing sets the ramp down setpoint. Reading returns the current value. Units are in milliseconds and must be positive. When set to a non-zero value, the - motor speed will decrease from 0 to 100% of `max_speed` over the span of this + motor speed will decrease from 0 to 100% of ``max_speed`` over the span of this setpoint. The actual ramp time is the ratio of the difference between the - `speed_sp` and the current `speed` and max_speed multiplied by `ramp_down_sp`. + ``speed_sp`` and the current ``speed`` and max_speed multiplied by ``ramp_down_sp``. """ self._ramp_down_sp, value = self.get_attr_int(self._ramp_down_sp, 'ramp_down_sp') return value @@ -712,7 +712,7 @@ def speed_d(self, value): def state(self): """ Reading returns a list of state flags. Possible flags are - `running`, `ramping`, `holding`, `overloaded` and `stalled`. + ``running``, ``ramping``, ``holding``, ``overloaded`` and ``stalled``. """ self._state, value = self.get_attr_set(self._state, 'state') return value @@ -721,9 +721,9 @@ def state(self): def stop_action(self): """ Reading returns the current stop action. Writing sets the stop action. - The value determines the motors behavior when `command` is set to `stop`. + The value determines the motors behavior when ``command`` is set to ``stop``. Also, it determines the motors behavior when a run command completes. See - `stop_actions` for a list of possible values. + ``stop_actions`` for a list of possible values. """ self._stop_action, value = self.get_attr_string(self._stop_action, 'stop_action') return value @@ -736,12 +736,12 @@ def stop_action(self, value): def stop_actions(self): """ Returns a list of stop actions supported by the motor controller. - Possible values are `coast`, `brake` and `hold`. `coast` means that power will - be removed from the motor and it will freely coast to a stop. `brake` means + Possible values are ``coast``, ``brake`` and ``hold``. ``coast`` means that power will + be removed from the motor and it will freely coast to a stop. ``brake`` means that power will be removed from the motor and a passive electrical load will be placed on the motor. This is usually done by shorting the motor terminals together. This load will absorb the energy from the rotation of the motors and - cause the motor to stop more quickly than coasting. `hold` does not remove + cause the motor to stop more quickly than coasting. ``hold`` does not remove power from the motor. Instead it actively tries to hold the motor at the current position. If an external force tries to turn the motor, the motor will 'push back' to maintain its position. @@ -753,7 +753,7 @@ def stop_actions(self): def time_sp(self): """ Writing specifies the amount of time the motor will run when using the - `run-timed` command. Reading returns the current value. Units are in + ``run-timed`` command. Reading returns the current value. Units are in milliseconds. """ self._time_sp, value = self.get_attr_int(self._time_sp, 'time_sp') @@ -773,8 +773,8 @@ def run_forever(self, **kwargs): def run_to_abs_pos(self, **kwargs): """ - Run to an absolute position specified by `position_sp` and then - stop using the action specified in `stop_action`. + Run to an absolute position specified by ``position_sp`` and then + stop using the action specified in ``stop_action``. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -782,10 +782,10 @@ def run_to_abs_pos(self, **kwargs): def run_to_rel_pos(self, **kwargs): """ - Run to a position relative to the current `position` value. - The new position will be current `position` + `position_sp`. + Run to a position relative to the current ``position`` value. + The new position will be current ``position`` + ``position_sp``. When the new position is reached, the motor will stop using - the action specified by `stop_action`. + the action specified by ``stop_action``. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -793,8 +793,8 @@ def run_to_rel_pos(self, **kwargs): def run_timed(self, **kwargs): """ - Run the motor for the amount of time specified in `time_sp` - and then stop the motor using the action specified by `stop_action`. + Run the motor for the amount of time specified in ``time_sp`` + and then stop the motor using the action specified by ``stop_action``. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -802,8 +802,8 @@ def run_timed(self, **kwargs): def run_direct(self, **kwargs): """ - Run the motor at the duty cycle specified by `duty_cycle_sp`. - Unlike other run commands, changing `duty_cycle_sp` while running *will* + Run the motor at the duty cycle specified by ``duty_cycle_sp``. + Unlike other run commands, changing ``duty_cycle_sp`` while running *will* take effect immediately. """ for key in kwargs: @@ -813,7 +813,7 @@ def run_direct(self, **kwargs): def stop(self, **kwargs): """ Stop any of the run commands before they are complete using the - action specified by `stop_action`. + action specified by ``stop_action``. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -852,7 +852,7 @@ def is_holding(self): @property def is_overloaded(self): """ - The motor is turning, but cannot reach its `speed_sp`. + The motor is turning, but cannot reach its ``speed_sp``. """ return self.STATE_OVERLOADED in self.state @@ -1048,8 +1048,8 @@ def on(self, speed, brake=True, block=False): ``speed`` can be a percentage or a :class:`ev3dev2.motor.SpeedValue` object, enabling use of other units. - Note that `block` is False by default, this is different from the - other `on_for_XYZ` methods. + Note that ``block`` is False by default, this is different from the + other ``on_for_XYZ`` methods. """ speed = self._speed_native_units(speed) self.speed_sp = int(round(speed)) @@ -1217,9 +1217,9 @@ def address(self): @property def command(self): """ - Sets the command for the motor. Possible values are `run-forever`, `run-timed` and - `stop`. Not all commands may be supported, so be sure to check the contents - of the `commands` attribute. + Sets the command for the motor. Possible values are ``run-forever``, ``run-timed`` and + ``stop``. Not all commands may be supported, so be sure to check the contents + of the ``commands`` attribute. """ raise Exception("command is a write-only property!") @@ -1271,7 +1271,7 @@ def duty_cycle_sp(self, value): @property def polarity(self): """ - Sets the polarity of the motor. Valid values are `normal` and `inversed`. + Sets the polarity of the motor. Valid values are ``normal`` and ``inversed``. """ self._polarity, value = self.get_attr_string(self._polarity, 'polarity') return value @@ -1310,9 +1310,9 @@ def ramp_up_sp(self, value): def state(self): """ Gets a list of flags indicating the motor status. Possible - flags are `running` and `ramping`. `running` indicates that the motor is - powered. `ramping` indicates that the motor has not yet reached the - `duty_cycle_sp`. + flags are ``running`` and ``ramping``. ``running`` indicates that the motor is + powered. ``ramping`` indicates that the motor has not yet reached the + ``duty_cycle_sp``. """ self._state, value = self.get_attr_set(self._state, 'state') return value @@ -1321,7 +1321,7 @@ def state(self): def stop_action(self): """ Sets the stop action that will be used when the motor stops. Read - `stop_actions` to get the list of valid values. + ``stop_actions`` to get the list of valid values. """ raise Exception("stop_action is a write-only property!") @@ -1332,8 +1332,8 @@ def stop_action(self, value): @property def stop_actions(self): """ - Gets a list of stop actions. Valid values are `coast` - and `brake`. + Gets a list of stop actions. Valid values are ``coast`` + and ``brake``. """ self._stop_actions, value = self.get_attr_set(self._stop_actions, 'stop_actions') return value @@ -1342,7 +1342,7 @@ def stop_actions(self): def time_sp(self): """ Writing specifies the amount of time the motor will run when using the - `run-timed` command. Reading returns the current value. Units are in + ``run-timed`` command. Reading returns the current value. Units are in milliseconds. """ self._time_sp, value = self.get_attr_int(self._time_sp, 'time_sp') @@ -1355,24 +1355,24 @@ def time_sp(self, value): #: Run the motor until another command is sent. COMMAND_RUN_FOREVER = 'run-forever' - #: Run the motor for the amount of time specified in `time_sp` - #: and then stop the motor using the action specified by `stop_action`. + #: Run the motor for the amount of time specified in ``time_sp`` + #: and then stop the motor using the action specified by ``stop_action``. COMMAND_RUN_TIMED = 'run-timed' - #: Run the motor at the duty cycle specified by `duty_cycle_sp`. - #: Unlike other run commands, changing `duty_cycle_sp` while running *will* + #: Run the motor at the duty cycle specified by ``duty_cycle_sp``. + #: Unlike other run commands, changing ``duty_cycle_sp`` while running *will* #: take effect immediately. COMMAND_RUN_DIRECT = 'run-direct' #: Stop any of the run commands before they are complete using the - #: action specified by `stop_action`. + #: action specified by ``stop_action``. COMMAND_STOP = 'stop' - #: With `normal` polarity, a positive duty cycle will + #: With ``normal`` polarity, a positive duty cycle will #: cause the motor to rotate clockwise. POLARITY_NORMAL = 'normal' - #: With `inversed` polarity, a positive duty cycle will + #: With ``inversed`` polarity, a positive duty cycle will #: cause the motor to rotate counter-clockwise. POLARITY_INVERSED = 'inversed' @@ -1395,8 +1395,8 @@ def run_forever(self, **kwargs): def run_timed(self, **kwargs): """ - Run the motor for the amount of time specified in `time_sp` - and then stop the motor using the action specified by `stop_action`. + Run the motor for the amount of time specified in ``time_sp`` + and then stop the motor using the action specified by ``stop_action``. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -1404,8 +1404,8 @@ def run_timed(self, **kwargs): def run_direct(self, **kwargs): """ - Run the motor at the duty cycle specified by `duty_cycle_sp`. - Unlike other run commands, changing `duty_cycle_sp` while running *will* + Run the motor at the duty cycle specified by ``duty_cycle_sp``. + Unlike other run commands, changing ``duty_cycle_sp`` while running *will* take effect immediately. """ for key in kwargs: @@ -1415,7 +1415,7 @@ def run_direct(self, **kwargs): def stop(self, **kwargs): """ Stop any of the run commands before they are complete using the - action specified by `stop_action`. + action specified by ``stop_action``. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -1472,9 +1472,9 @@ def address(self): @property def command(self): """ - Sets the command for the servo. Valid values are `run` and `float`. Setting - to `run` will cause the servo to be driven to the position_sp set in the - `position_sp` attribute. Setting to `float` will remove power from the motor. + Sets the command for the servo. Valid values are ``run`` and ``float``. Setting + to ``run`` will cause the servo to be driven to the position_sp set in the + ``position_sp`` attribute. Setting to ``float`` will remove power from the motor. """ raise Exception("command is a write-only property!") @@ -1541,10 +1541,10 @@ def min_pulse_sp(self, value): @property def polarity(self): """ - Sets the polarity of the servo. Valid values are `normal` and `inversed`. - Setting the value to `inversed` will cause the position_sp value to be - inversed. i.e `-100` will correspond to `max_pulse_sp`, and `100` will - correspond to `min_pulse_sp`. + Sets the polarity of the servo. Valid values are ``normal`` and ``inversed``. + Setting the value to ``inversed`` will cause the position_sp value to be + inversed. i.e ``-100`` will correspond to ``max_pulse_sp``, and ``100`` will + correspond to ``min_pulse_sp``. """ self._polarity, value = self.get_attr_string(self._polarity, 'polarity') return value @@ -1558,8 +1558,8 @@ def position_sp(self): """ Reading returns the current position_sp of the servo. Writing instructs the servo to move to the specified position_sp. Units are percent. Valid values - are -100 to 100 (-100% to 100%) where `-100` corresponds to `min_pulse_sp`, - `0` corresponds to `mid_pulse_sp` and `100` corresponds to `max_pulse_sp`. + are -100 to 100 (-100% to 100%) where ``-100`` corresponds to ``min_pulse_sp``, + ``0`` corresponds to ``mid_pulse_sp`` and ``100`` corresponds to ``max_pulse_sp``. """ self._position_sp, value = self.get_attr_int(self._position_sp, 'position_sp') return value @@ -1575,7 +1575,7 @@ def rate_sp(self): range of the servo). Units are in milliseconds. Example: Setting the rate_sp to 1000 means that it will take a 180 degree servo 2 second to move from 0 to 180 degrees. Note: Some servo controllers may not support this in which - case reading and writing will fail with `-EOPNOTSUPP`. In continuous rotation + case reading and writing will fail with ``-EOPNOTSUPP``. In continuous rotation servos, this value will affect the rate_sp at which the speed ramps up or down. """ self._rate_sp, value = self.get_attr_int(self._rate_sp, 'rate_sp') @@ -1590,28 +1590,28 @@ def state(self): """ Returns a list of flags indicating the state of the servo. Possible values are: - * `running`: Indicates that the motor is powered. + * ``running``: Indicates that the motor is powered. """ self._state, value = self.get_attr_set(self._state, 'state') return value - #: Drive servo to the position set in the `position_sp` attribute. + #: Drive servo to the position set in the ``position_sp`` attribute. COMMAND_RUN = 'run' #: Remove power from the motor. COMMAND_FLOAT = 'float' - #: With `normal` polarity, a positive duty cycle will + #: With ``normal`` polarity, a positive duty cycle will #: cause the motor to rotate clockwise. POLARITY_NORMAL = 'normal' - #: With `inversed` polarity, a positive duty cycle will + #: With ``inversed`` polarity, a positive duty cycle will #: cause the motor to rotate counter-clockwise. POLARITY_INVERSED = 'inversed' def run(self, **kwargs): """ - Drive servo to the position set in the `position_sp` attribute. + Drive servo to the position set in the ``position_sp`` attribute. """ for key in kwargs: setattr(self, key, kwargs[key]) @@ -2688,7 +2688,7 @@ def odometry_stop(self): def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): """ - Rotate in place to `angle_target_degrees` at `speed` + Rotate in place to ``angle_target_degrees`` at ``speed`` """ assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" @@ -2727,7 +2727,7 @@ def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): def on_to_coordinates(self, speed, x_target_mm, y_target_mm, brake=True, block=True): """ - Drive to (`x_target_mm`, `y_target_mm`) coordinates at `speed` + Drive to (``x_target_mm``, ``y_target_mm``) coordinates at ``speed`` """ assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" diff --git a/ev3dev2/port.py b/ev3dev2/port.py index faeccc7..4bb5f3a 100644 --- a/ev3dev2/port.py +++ b/ev3dev2/port.py @@ -32,7 +32,7 @@ class LegoPort(Device): """ - The `lego-port` class provides an interface for working with input and + The ``lego-port`` class provides an interface for working with input and output ports that are compatible with LEGO MINDSTORMS RCX/NXT/EV3, LEGO WeDo and LEGO Power Functions sensors and motors. Supported devices include the LEGO MINDSTORMS EV3 Intelligent Brick, the LEGO WeDo USB hub and @@ -45,17 +45,17 @@ class LegoPort(Device): In most cases, ports are able to automatically detect what type of sensor or motor is connected. In some cases though, this must be manually specified - using the `mode` and `set_device` attributes. The `mode` attribute affects + using the ``mode`` and ``set_device`` attributes. The ``mode`` attribute affects how the port communicates with the connected device. For example the input ports on the EV3 brick can communicate using UART, I2C or analog voltages, but not all at the same time, so the mode must be set to the one that is - appropriate for the connected sensor. The `set_device` attribute is used to + appropriate for the connected sensor. The ``set_device`` attribute is used to specify the exact type of sensor that is connected. Note: the mode must be correctly set before setting the sensor type. - Ports can be found at `/sys/class/lego-port/port` where `` is + Ports can be found at ``/sys/class/lego-port/port`` where ```` is incremented each time a new port is registered. Note: The number is not - related to the actual port at all - use the `address` attribute to find + related to the actual port at all - use the ``address`` attribute to find a specific port. """ @@ -142,9 +142,9 @@ def set_device(self, value): @property def status(self): """ - In most cases, reading status will return the same value as `mode`. In - cases where there is an `auto` mode additional values may be returned, - such as `no-device` or `error`. See individual port driver documentation + In most cases, reading status will return the same value as ``mode``. In + cases where there is an ``auto`` mode additional values may be returned, + such as ``no-device`` or ``error``. See individual port driver documentation for the full list of possible values. """ self._status, value = self.get_attr_string(self._status, 'status') diff --git a/ev3dev2/power.py b/ev3dev2/power.py index afac94d..6c3e3a8 100644 --- a/ev3dev2/power.py +++ b/ev3dev2/power.py @@ -79,29 +79,21 @@ def measured_voltage(self): @property def max_voltage(self): - """ - """ self._max_voltage, value = self.get_attr_int(self._max_voltage, 'voltage_max_design') return value @property def min_voltage(self): - """ - """ self._min_voltage, value = self.get_attr_int(self._min_voltage, 'voltage_min_design') return value @property def technology(self): - """ - """ self._technology, value = self.get_attr_string(self._technology, 'technology') return value @property def type(self): - """ - """ self._type, value = self.get_attr_string(self._type, 'type') return value diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index d082bc9..ffe2b92 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -124,8 +124,8 @@ def _scale(self, mode): @property def address(self): """ - Returns the name of the port that the sensor is connected to, e.g. `ev3:in1`. - I2C sensors also include the I2C address (decimal), e.g. `ev3:in1:i2c8`. + Returns the name of the port that the sensor is connected to, e.g. ``ev3:in1``. + I2C sensors also include the I2C address (decimal), e.g. ``ev3:in1:i2c8``. """ self._address, value = self.get_attr_string(self._address, 'address') return value @@ -153,7 +153,7 @@ def commands(self): @property def decimals(self): """ - Returns the number of decimal places for the values in the `value` + Returns the number of decimal places for the values in the ``value`` attributes of the current mode. """ self._decimals, value = self.get_attr_int(self._decimals, 'decimals') @@ -171,7 +171,7 @@ def driver_name(self): @property def mode(self): """ - Returns the current mode. Writing one of the values returned by `modes` + Returns the current mode. Writing one of the values returned by ``modes`` sets the sensor to that mode. """ self._mode, value = self.get_attr_string(self._mode, 'mode') @@ -192,7 +192,7 @@ def modes(self): @property def num_values(self): """ - Returns the number of `value` attributes that will return a valid value + Returns the number of ``value`` attributes that will return a valid value for the current mode. """ self._num_values, value = self.get_attr_int(self._num_values, 'num_values') @@ -222,27 +222,27 @@ def value(self, n=0): @property def bin_data_format(self): """ - Returns the format of the values in `bin_data` for the current mode. + Returns the format of the values in ``bin_data`` for the current mode. Possible values are: - - `u8`: Unsigned 8-bit integer (byte) - - `s8`: Signed 8-bit integer (sbyte) - - `u16`: Unsigned 16-bit integer (ushort) - - `s16`: Signed 16-bit integer (short) - - `s16_be`: Signed 16-bit integer, big endian - - `s32`: Signed 32-bit integer (int) - - `float`: IEEE 754 32-bit floating point (float) + - ``u8``: Unsigned 8-bit integer (byte) + - ``s8``: Signed 8-bit integer (sbyte) + - ``u16``: Unsigned 16-bit integer (ushort) + - ``s16``: Signed 16-bit integer (short) + - ``s16_be``: Signed 16-bit integer, big endian + - ``s32``: Signed 32-bit integer (int) + - ``float``: IEEE 754 32-bit floating point (float) """ self._bin_data_format, value = self.get_attr_string(self._bin_data_format, 'bin_data_format') return value def bin_data(self, fmt=None): """ - Returns the unscaled raw values in the `value` attributes as raw byte - array. Use `bin_data_format`, `num_values` and the individual sensor + Returns the unscaled raw values in the ``value`` attributes as raw byte + array. Use ``bin_data_format``, ``num_values`` and the individual sensor documentation to determine how to interpret the data. - Use `fmt` to unpack the raw bytes into a struct. + Use ``fmt`` to unpack the raw bytes into a struct. Example:: diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index a1cdbad..32e7780 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -403,7 +403,7 @@ def get_volume(self, channel=None): if m: return int(m.group(1)) else: - raise Exception('Failed to parse output of `amixer get {}`'.format(channel)) + raise Exception('Failed to parse output of ``amixer get {}``'.format(channel)) def play_song(self, song, tempo=120, delay=0.05): """ Plays a song provided as a list of tuples containing the note name and its @@ -424,7 +424,7 @@ def play_song(self, song, tempo=120, delay=0.05): Shortcuts exist for common modifiers: - - ``3`` produces a triplet member note. For instance `e3` gives a triplet of eight notes, + - ``3`` produces a triplet member note. For instance ``e3`` gives a triplet of eight notes, i.e. 3 eight notes in the duration of a single quarter. You must ensure that 3 triplets notes are defined in sequence to match the count, otherwise the result will not be the expected one. diff --git a/ev3dev2/unit.py b/ev3dev2/unit.py index 2a15abb..6d90849 100644 --- a/ev3dev2/unit.py +++ b/ev3dev2/unit.py @@ -38,7 +38,7 @@ class DistanceValue(object): """ A base class for other unit types. Don't use this directly; instead, see - :class:`DistanceMillimeters`, :class:`DistanceCentimeters`, :class:`DistanceDecimeters`, :class:`DistanceMeters`, + :class:`DistanceMillimeters`, :class:`DistanceCentimeters`, :class:`DistanceDecimeters``, :class:`DistanceMeters`, :class:`DistanceInches`, :class:`DistanceFeet`, :class:`DistanceYards` and :class:`DistanceStuds`. """ diff --git a/utils/console_fonts.py b/utils/console_fonts.py index f2d79a2..8bbaae6 100644 --- a/utils/console_fonts.py +++ b/utils/console_fonts.py @@ -20,8 +20,8 @@ def show_fonts(): """ Iterate through all the Latin "1 & 5" fonts, and see how many rows/columns the EV3 LCD console can accommodate for each font. - Note: `Terminus` fonts are "thinner"; `TerminusBold` and `VGA` offer more contrast on the LCD console - and are thus more readable; the `TomThumb` font is waaaaay too small to read! + Note: ``Terminus`` fonts are "thinner"; ``TerminusBold`` and ``VGA`` offer more contrast on the LCD console + and are thus more readable; the ``TomThumb`` font is waaaaay too small to read! """ console = Console() files = [f for f in listdir("/usr/share/consolefonts/") if f.startswith("Lat15") and f.endswith(".psf.gz")] @@ -48,7 +48,7 @@ def show_fonts(): console.text_at(font.split(".")[0], 1, 1, False, True) console.clear_to_eol() -# Show the fonts; you may want to adjust the `startswith` filter to show other codesets. +# Show the fonts; you may want to adjust the ``startswith`` filter to show other codesets. show_fonts() sleep(5) diff --git a/utils/line-follower-find-kp-ki-kd.py b/utils/line-follower-find-kp-ki-kd.py index ed3bbfc..9e705b5 100755 --- a/utils/line-follower-find-kp-ki-kd.py +++ b/utils/line-follower-find-kp-ki-kd.py @@ -1,7 +1,7 @@ """ This program is used to find the kp, ki, kd PID values for -`MoveTank.follow_line()`. These values vary from robot to robot, the best way +``MoveTank.follow_line()``. These values vary from robot to robot, the best way to find them for your robot is to have it follow a line, tweak the values a little, repeat. @@ -37,8 +37,8 @@ def frange(start, end, increment): def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): """ - Return the optimal `kx_to_tweak` value where `kx_to_tweak` must be "kp", "ki" or "kd" - This will test values from `start` to `end` in steps of `increment`. The value + Return the optimal ``kx_to_tweak`` value where ``kx_to_tweak`` must be "kp", "ki" or "kd" + This will test values from ``start`` to ``end`` in steps of ``increment``. The value that results in the robot moving the least total distance is the optimal value that is returned by this function. """ From 69e7bde46bd0faaea7e5c06fd7f4681442267fac Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Tue, 14 Jan 2020 19:43:13 -0500 Subject: [PATCH 156/172] MoveDifferential: use gyro for better accuracy (#705) --- debian/changelog | 4 +- ev3dev2/__init__.py | 8 +- ev3dev2/motor.py | 309 +++++++++++++---------- ev3dev2/sensor/__init__.py | 2 +- ev3dev2/sensor/lego.py | 75 +++++- ev3dev2/stopwatch.py | 4 +- tests/api_tests.py | 2 +- tests/motor/ev3dev_port_logger.py | 2 +- tests/motor/motor_run_direct_unittest.py | 2 +- tests/motor/motor_unittest.py | 8 +- 10 files changed, 272 insertions(+), 144 deletions(-) diff --git a/debian/changelog b/debian/changelog index 29947f4..efe3f8e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -3,6 +3,8 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Daniel Walton] * RPyC update docs and make it easier to use * use double backticks consistently in docstrings + * MoveDifferential use gyro for better accuracy. Make MoveTank and + MoveDifferential "turn" APIs more consistent. [Matěj Volf] * LED animation fix duration None @@ -12,7 +14,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium * Add "value_secs" and "is_elapsed_*" methods to StopWatch * Rename "value_hms" to "hms_str". New "value_hms" returns original tuple. * Fix bug in Button.process() on Micropython - + [Nithin Shenoy] * Add Gyro-based driving support to MoveTank diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 2d2895e..502c3e7 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -142,6 +142,12 @@ def library_load_warning_message(library_name, dependent_class): class DeviceNotFound(Exception): pass +class DeviceNotDefined(Exception): + pass + +class ThreadNotRunning(Exception): + pass + # ----------------------------------------------------------------------------- # Define the base class from which all other ev3dev classes are defined. @@ -214,7 +220,7 @@ def __str__(self): return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address')) else: return self.__class__.__name__ - + def __repr__(self): return self.__str__() diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 1c7e4b3..f7638af 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -39,7 +39,7 @@ from logging import getLogger from os.path import abspath -from ev3dev2 import get_current_platform, Device, list_device_names +from ev3dev2 import get_current_platform, Device, list_device_names, DeviceNotDefined, ThreadNotRunning from ev3dev2.stopwatch import StopWatch log = getLogger(__name__) @@ -1867,7 +1867,7 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar @property def cs(self): return self._cs - + @cs.setter def cs(self, cs): self._cs = cs @@ -1876,7 +1876,7 @@ def cs(self, cs): @property def gyro(self): return self._gyro - + @gyro.setter def gyro(self, gyro): self._gyro = gyro @@ -1925,17 +1925,6 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru self.right_motor._set_rel_position_degrees_and_speed_sp(right_degrees, right_speed_native_units) self.right_motor._set_brake(brake) - log.debug("{}: on_for_degrees {}".format(self, degrees)) - - # These debugs involve disk I/O to pull position and position_sp so only uncomment - # if you need to troubleshoot in more detail. - # log.debug("{}: left_speed {}, left_speed_native_units {}, left_degrees {}, left-position {}->{}".format( - # self, left_speed, left_speed_native_units, left_degrees, - # self.left_motor.position, self.left_motor.position_sp)) - # log.debug("{}: right_speed {}, right_speed_native_units {}, right_degrees {}, right-position {}->{}".format( - # self, right_speed, right_speed_native_units, right_degrees, - # self.right_motor.position, self.right_motor.position_sp)) - # Start the motors self.left_motor.run_to_rel_pos() self.right_motor.run_to_rel_pos() @@ -1995,11 +1984,6 @@ def on(self, left_speed, right_speed): self.left_motor.speed_sp = int(round(left_speed_native_units)) self.right_motor.speed_sp = int(round(right_speed_native_units)) - # This debug involves disk I/O to pull speed_sp so only uncomment - # if you need to troubleshoot in more detail. - # log.debug("%s: on at left-speed %s, right-speed %s" % - # (self, self.left_motor.speed_sp, self.right_motor.speed_sp)) - # Start the motors self.left_motor.run_forever() self.right_motor.run_forever() @@ -2070,7 +2054,8 @@ def follow_line(self, tank.stop() raise """ - assert self._cs, "ColorSensor must be defined" + if not self._cs: + raise DeviceNotDefined("The 'cs' variable must be defined with a ColorSensor. Example: tank.cs = ColorSensor()") if target_light_intensity is None: target_light_intensity = self._cs.reflected_light_intensity @@ -2123,19 +2108,6 @@ def follow_line(self, self.stop() - def calibrate_gyro(self): - """ - Calibrates the gyro sensor. - - NOTE: This takes 1sec to run - """ - assert self._gyro, "GyroSensor must be defined" - - for x in range(2): - self._gyro.mode = 'GYRO-RATE' - self._gyro.mode = 'GYRO-ANG' - time.sleep(0.5) - def follow_gyro_angle(self, kp, ki, kd, speed, @@ -2181,7 +2153,7 @@ def follow_gyro_angle(self, try: # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.calibrate_gyro() + tank.gyro.calibrate() # Follow the line for 4500ms tank.follow_gyro_angle( @@ -2195,7 +2167,8 @@ def follow_gyro_angle(self, tank.stop() raise """ - assert self._gyro, "GyroSensor must be defined" + if not self._gyro: + raise DeviceNotDefined("The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") integral = 0.0 last_error = 0.0 @@ -2237,30 +2210,36 @@ def follow_gyro_angle(self, self.stop() - def turn_to_angle_gyro(self, + def turn_degrees( + self, speed, - target_angle=0, - wiggle_room=2, + target_angle, + brake=True, + error_margin=2, sleep_time=0.01 ): """ - Pivot Turn + Use a GyroSensor to rotate in place for ``target_angle`` ``speed`` is the desired speed of the midpoint of the robot - ``target_angle`` is the target angle we want to pivot to + ``target_angle`` is the number of degrees we want to rotate + + ``brake`` hit the brakes once we reach ``target_angle`` - ``wiggle_room`` is the +/- angle threshold to control how accurate the turn should be + ``error_margin`` is the +/- angle threshold to control how accurate the turn should be ``sleep_time`` is how many seconds we sleep on each pass through the loop. This is to give the robot a chance to react to the new motor settings. This should be something small such as 0.01 (10ms). + Rotate in place for ``target_degrees`` at ``speed`` + Example: .. code:: python - + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent from ev3dev2.sensor.lego import GyroSensor @@ -2271,35 +2250,58 @@ def turn_to_angle_gyro(self, tank.gyro = GyroSensor() # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.calibrate_gyro() + tank.gyro.calibrate() # Pivot 30 degrees - tank.turn_to_angle_gyro( + tank.turn_degrees( speed=SpeedPercent(5), - target_angle(30) + target_angle=30 ) """ - assert self._gyro, "GyroSensor must be defined" + + # MoveTank does not have information on wheel size and distance (that is + # MoveDifferential) so we must use a GyroSensor to control how far we rotate. + if not self._gyro: + raise DeviceNotDefined("The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") speed_native_units = speed.to_native_units(self.left_motor) - target_reached = False + target_angle = self._gyro.angle + target_angle - while not target_reached: + while True: current_angle = self._gyro.angle - if abs(current_angle - target_angle) <= wiggle_room: - target_reached = True - self.stop() - elif (current_angle > target_angle): - left_speed = SpeedNativeUnits(-1 * speed_native_units) - right_speed = SpeedNativeUnits(speed_native_units) - else: + delta = abs(target_angle - current_angle) + + if delta <= error_margin: + self.stop(brake=brake) + break + + # we are left of our target, rotate clockwise + if current_angle < target_angle: left_speed = SpeedNativeUnits(speed_native_units) right_speed = SpeedNativeUnits(-1 * speed_native_units) + # we are right of our target, rotate counter-clockwise + else: + left_speed = SpeedNativeUnits(-1 * speed_native_units) + right_speed = SpeedNativeUnits(speed_native_units) + + self.on(left_speed, right_speed) + if sleep_time: time.sleep(sleep_time) - self.on(left_speed, right_speed) + def turn_right(self, speed, degrees, brake=True, error_margin=2, sleep_time=0.01): + """ + Rotate clockwise ``degrees`` in place + """ + self.turn_degrees(speed, abs(degrees), brake, error_margin, sleep_time) + + def turn_left(self, speed, degrees, brake=True, error_margin=2, sleep_time=0.01): + """ + Rotate counter-clockwise ``degrees`` in place + """ + self.turn_degrees(speed, abs(degrees) * -1, brake, error_margin, sleep_time) + class MoveSteering(MoveTank): """ @@ -2482,15 +2484,15 @@ def __init__(self, left_motor_port, right_motor_port, self.x_pos_mm = 0.0 # robot X position in mm self.y_pos_mm = 0.0 # robot Y position in mm self.odometry_thread_run = False - self.odometry_thread_id = None self.theta = 0.0 def on_for_distance(self, speed, distance_mm, brake=True, block=True): """ - Drive distance_mm + Drive in a straight line for ``distance_mm`` """ rotations = distance_mm / self.wheel.circumference_mm - log.debug("%s: on_for_rotations distance_mm %s, rotations %s, speed %s" % (self, distance_mm, rotations, speed)) + log.debug("%s: on_for_rotations distance_mm %s, rotations %s, speed %s" % + (self, distance_mm, rotations, speed)) MoveTank.on_for_rotations(self, speed, speed, rotations, brake, block) @@ -2561,20 +2563,47 @@ def on_arc_left(self, speed, radius_mm, distance_mm, brake=True, block=True): """ self._on_arc(speed, radius_mm, distance_mm, brake, block, False) - def _turn(self, speed, degrees, brake=True, block=True): + def turn_degrees(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): """ - Rotate in place 'degrees'. Both wheels must turn at the same speed for us - to rotate in place. + Rotate in place ``degrees``. Both wheels must turn at the same speed for us + to rotate in place. If the following conditions are met the GryoSensor will + be used to improve the accuracy of our turn: + - ``use_gyro``, ``brake`` and ``block`` are all True + - A GyroSensor has been defined via ``self.gyro = GyroSensor()`` """ + def final_angle(init_angle, degrees): + result = init_angle - degrees + + while result <= -360: + result += 360 + + while result >= 360: + result -= 360 + + if result < 0: + result += 360 + + return result + + # use the gyro to check that we turned the correct amount? + use_gyro = bool(use_gyro and block and brake and self._gyro) + + if use_gyro: + angle_init_degrees = self._gyro.circle_angle() + else: + angle_init_degrees = math.degrees(self.theta) + + angle_target_degrees = final_angle(angle_init_degrees, degrees) + + log.info("%s: turn_degrees() %d degrees from %s to %s" % + (self, degrees, angle_init_degrees, angle_target_degrees)) + # The distance each wheel needs to travel distance_mm = (abs(degrees) / 360) * self.circumference_mm # The number of rotations to move distance_mm - rotations = distance_mm/self.wheel.circumference_mm - - log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % - (self, degrees, distance_mm, rotations, degrees)) + rotations = distance_mm / self.wheel.circumference_mm # If degrees is positive rotate clockwise if degrees > 0: @@ -2582,20 +2611,86 @@ def _turn(self, speed, degrees, brake=True, block=True): # If degrees is negative rotate counter-clockwise else: - rotations = distance_mm / self.wheel.circumference_mm MoveTank.on_for_rotations(self, speed * -1, speed, rotations, brake, block) - def turn_right(self, speed, degrees, brake=True, block=True): + if use_gyro: + angle_current_degrees = self._gyro.circle_angle() + + # This can happen if we are aiming for 2 degrees and overrotate to 358 degrees + # We need to rotate counter-clockwise + if 90 >= angle_target_degrees >= 0 and 270 <= angle_current_degrees <= 360: + degrees_error = (angle_target_degrees + (360 - angle_current_degrees)) * -1 + + # This can happen if we are aiming for 358 degrees and overrotate to 2 degrees + # We need to rotate clockwise + elif 360 >= angle_target_degrees >= 270 and 0 <= angle_current_degrees <= 90: + degrees_error = angle_current_degrees + (360 - angle_target_degrees) + + # We need to rotate clockwise + elif angle_current_degrees > angle_target_degrees: + degrees_error = angle_current_degrees - angle_target_degrees + + # We need to rotate counter-clockwise + else: + degrees_error = (angle_target_degrees - angle_current_degrees) * -1 + + log.info("%s: turn_degrees() ended up at %s, error %s, error_margin %s" % + (self, angle_current_degrees, degrees_error, error_margin)) + + if abs(degrees_error) > error_margin: + self.turn_degrees(speed, degrees_error, brake, block, error_margin, use_gyro) + + def turn_right(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): """ - Rotate clockwise 'degrees' in place + Rotate clockwise ``degrees`` in place """ - self._turn(speed, abs(degrees), brake, block) + self.turn_degrees(speed, abs(degrees), brake, block, error_margin, use_gyro) - def turn_left(self, speed, degrees, brake=True, block=True): + def turn_left(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): """ - Rotate counter-clockwise 'degrees' in place + Rotate counter-clockwise ``degrees`` in place """ - self._turn(speed, abs(degrees) * -1, brake, block) + self.turn_degrees(speed, abs(degrees) * -1, brake, block, error_margin, use_gyro) + + def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True, error_margin=2, use_gyro=False): + """ + Rotate in place to ``angle_target_degrees`` at ``speed`` + """ + if not self.odometry_thread_run: + raise ThreadNotRunning("odometry_start() must be called to track robot coordinates") + + # Make both target and current angles positive numbers between 0 and 360 + while angle_target_degrees < 0: + angle_target_degrees += 360 + + angle_current_degrees = math.degrees(self.theta) + + while angle_current_degrees < 0: + angle_current_degrees += 360 + + # Is it shorter to rotate to the right or left + # to reach angle_target_degrees? + if angle_current_degrees > angle_target_degrees: + turn_right = True + angle_delta = angle_current_degrees - angle_target_degrees + else: + turn_right = False + angle_delta = angle_target_degrees - angle_current_degrees + + if angle_delta > 180: + angle_delta = 360 - angle_delta + turn_right = not turn_right + + log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % + (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) + self.odometry_coordinates_log() + + if turn_right: + self.turn_degrees(speed, abs(angle_delta), brake, block, error_margin, use_gyro) + else: + self.turn_degrees(speed, abs(angle_delta) * -1, brake, block, error_margin, use_gyro) + + self.odometry_coordinates_log() def odometry_coordinates_log(self): log.debug("%s: odometry angle %s at (%d, %d)" % @@ -2619,6 +2714,7 @@ def _odometry_monitor(): self.x_pos_mm = x_pos_start # robot X position in mm self.y_pos_mm = y_pos_start # robot Y position in mm TWO_PI = 2 * math.pi + self.odometry_thread_run = True while self.odometry_thread_run: @@ -2637,11 +2733,6 @@ def _odometry_monitor(): time.sleep(sleep_time) continue - # log.debug("%s: left_ticks %s (from %s to %s)" % - # (self, left_ticks, left_previous, left_current)) - # log.debug("%s: right_ticks %s (from %s to %s)" % - # (self, right_ticks, right_previous, right_current)) - # update _previous for next time left_previous = left_current right_previous = right_current @@ -2670,66 +2761,26 @@ def _odometry_monitor(): if sleep_time: time.sleep(sleep_time) - self.odometry_thread_id = None + _thread.start_new_thread(_odometry_monitor, ()) - self.odometry_thread_run = True - self.odometry_thread_id = _thread.start_new_thread(_odometry_monitor, ()) + # Block until the thread has started doing work + while not self.odometry_thread_run: + pass def odometry_stop(self): """ - Signal the odometry thread to exit and wait for it to exit + Signal the odometry thread to exit """ - if self.odometry_thread_id: + if self.odometry_thread_run: self.odometry_thread_run = False - while self.odometry_thread_id: - pass - - def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): - """ - Rotate in place to ``angle_target_degrees`` at ``speed`` - """ - assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" - - # Make both target and current angles positive numbers between 0 and 360 - if angle_target_degrees < 0: - angle_target_degrees += 360 - - angle_current_degrees = math.degrees(self.theta) - - if angle_current_degrees < 0: - angle_current_degrees += 360 - - # Is it shorter to rotate to the right or left - # to reach angle_target_degrees? - if angle_current_degrees > angle_target_degrees: - turn_right = True - angle_delta = angle_current_degrees - angle_target_degrees - else: - turn_right = False - angle_delta = angle_target_degrees - angle_current_degrees - - if angle_delta > 180: - angle_delta = 360 - angle_delta - turn_right = not turn_right - - log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % - (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) - self.odometry_coordinates_log() - - if turn_right: - self.turn_right(speed, angle_delta, brake, block) - else: - self.turn_left(speed, angle_delta, brake, block) - - self.odometry_coordinates_log() - def on_to_coordinates(self, speed, x_target_mm, y_target_mm, brake=True, block=True): """ Drive to (``x_target_mm``, ``y_target_mm``) coordinates at ``speed`` """ - assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" + if not self.odometry_thread_run: + raise ThreadNotRunning("odometry_start() must be called to track robot coordinates") # stop moving self.off(brake='hold') @@ -2753,7 +2804,7 @@ class MoveJoystick(MoveTank): def on(self, x, y, radius=100.0): """ - Convert x,y joystick coordinates to left/right motor speed percentages + Convert ``x``,``y`` joystick coordinates to left/right motor speed percentages and move the motors. This will use a classic "arcade drive" algorithm: a full-forward joystick @@ -2762,11 +2813,11 @@ def on(self, x, y, radius=100.0): Positions in the middle will control how fast the vehicle moves and how sharply it turns. - "x", "y": + ``x``, ``y``: The X and Y coordinates of the joystick's position, with (0,0) representing the center position. X is horizontal and Y is vertical. - radius (default 100): + ``radius`` (default 100): The radius of the joystick, controlling the range of the input (x, y) values. e.g. if "x" and "y" can be between -1 and 1, radius should be set to "1". """ diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index ffe2b92..ad39d21 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -274,7 +274,7 @@ def bin_data(self, fmt=None): if fmt is None: return raw return unpack(fmt, raw) - + def _ensure_mode(self, mode): if self.mode != mode: self.mode = mode diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index b5c7301..fb9c71e 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -28,10 +28,13 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') +import logging import time from ev3dev2.button import ButtonBase from ev3dev2.sensor import Sensor +log = logging.getLogger(__name__) + class TouchSensor(Sensor): """ @@ -226,7 +229,7 @@ def color_name(self): def raw(self): """ Red, green, and blue components of the detected color, as a tuple. - + Officially in the range 0-1020 but the values returned will never be that high. We do not yet know why the values returned are low, but pointing the color sensor at a well lit sheet of white paper will return @@ -586,6 +589,7 @@ class GyroSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-ev3-gyro', **kwargs) self._direct = None + self._init_angle = self.angle @property def angle(self): @@ -622,6 +626,15 @@ def tilt_rate(self): self._ensure_mode(self.MODE_TILT_RATE) return self.value(0) + def calibrate(self): + """ + The robot should be perfectly still when you call this + """ + current_mode = self.mode + self._ensure_mode(self.MODE_GYRO_CAL) + time.sleep(2) + self._ensure_mode(current_mode) + def reset(self): """Resets the angle to 0. @@ -632,6 +645,7 @@ def reset(self): """ # 17 comes from inspecting the .vix file of the Gyro sensor block in EV3-G self._direct = self.set_attr_raw(self._direct, 'direct', b'\x11') + self._init_angle = self.angle def wait_until_angle_changed_by(self, delta, direction_sensitive=False): """ @@ -660,6 +674,61 @@ def wait_until_angle_changed_by(self, delta, direction_sensitive=False): while abs(start_angle - self.value(0)) < delta: time.sleep(0.01) + def circle_angle(self): + """ + As the gryo rotates clockwise the angle increases, it will increase + by 360 for each full rotation. As the gyro rotates counter-clockwise + the gyro angle will decrease. + + The angles on a circle have the opposite behavior though, they start + at 0 and increase as you move counter-clockwise around the circle. + + Convert the gyro angle to the angle on a circle. We consider the initial + position of the gyro to be at 90 degrees on the cirlce. + """ + current_angle = self.angle + delta = abs(current_angle - self._init_angle) % 360 + + if delta == 0: + result = 90 + + # the gyro has turned clockwise relative to where we started + elif current_angle > self._init_angle: + + if delta <= 90: + result = 90 - delta + + elif delta <= 180: + result = 360 - (delta - 90) + + elif delta <= 270: + result = 270 - (delta - 180) + + else: + result = 180 - (delta - 270) + + # This can be chatty (but helpful) so save it for a rainy day + # log.info("%s moved clockwise %s degrees to %s" % (self, delta, result)) + + # the gyro has turned counter-clockwise relative to where we started + else: + if delta <= 90: + result = 90 + delta + + elif delta <= 180: + result = 180 + (delta - 90) + + elif delta <= 270: + result = 270 + (delta - 180) + + else: + result = delta - 270 + + # This can be chatty (but helpful) so save it for a rainy day + # log.info("%s moved counter-clockwise %s degrees to %s" % (self, delta, result)) + + return result + class InfraredSensor(Sensor, ButtonBase): """ @@ -861,7 +930,7 @@ def process(self): old state, call the appropriate button event handlers. To use the on_channel1_top_left, etc handlers your program would do something like: - + .. code:: python def top_left_channel_1_action(state): @@ -877,7 +946,7 @@ def bottom_right_channel_4_action(state): while True: ir.process() time.sleep(0.01) - + """ new_state = [] state_diff = [] diff --git a/ev3dev2/stopwatch.py b/ev3dev2/stopwatch.py index 35b804b..3a87738 100644 --- a/ev3dev2/stopwatch.py +++ b/ev3dev2/stopwatch.py @@ -70,14 +70,14 @@ def reset(self): """ self._start_time = None self._stopped_total_time = None - + def restart(self): """ Resets and then starts the timer. """ self.reset() self.start() - + @property def is_started(self): """ diff --git a/tests/api_tests.py b/tests/api_tests.py index 527b226..78585d4 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -378,7 +378,7 @@ def test_units(self): def test_stopwatch(self): sw = StopWatch() self.assertEqual(str(sw), "StopWatch: 00:00:00.000") - + sw = StopWatch(desc="test sw") self.assertEqual(str(sw), "test sw: 00:00:00.000") self.assertEqual(sw.is_started, False) diff --git a/tests/motor/ev3dev_port_logger.py b/tests/motor/ev3dev_port_logger.py index 1e19c3d..5f0972e 100644 --- a/tests/motor/ev3dev_port_logger.py +++ b/tests/motor/ev3dev_port_logger.py @@ -49,7 +49,7 @@ def execute_actions(actions): for b in c: for k,v in b.items(): setattr( device[p], k, v ) - + device = {} logs = {} diff --git a/tests/motor/motor_run_direct_unittest.py b/tests/motor/motor_run_direct_unittest.py index c0d63de..0ffb414 100755 --- a/tests/motor/motor_run_direct_unittest.py +++ b/tests/motor/motor_run_direct_unittest.py @@ -30,7 +30,7 @@ def run_direct_duty_cycles(self,stop_action,duty_cycles): self._param['motor'].command = 'run-direct' for i in duty_cycles: - self._param['motor'].duty_cycle_sp = i + self._param['motor'].duty_cycle_sp = i time.sleep(0.5) self._param['motor'].command = 'stop' diff --git a/tests/motor/motor_unittest.py b/tests/motor/motor_unittest.py index 5933ed3..efd7f9e 100644 --- a/tests/motor/motor_unittest.py +++ b/tests/motor/motor_unittest.py @@ -298,7 +298,7 @@ def test_ramp_down_sp_after_reset(self): self.assertEqual(self._param['motor'].ramp_down_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_down_sp, 0) - + class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): def test_ramp_up_negative_value(self): @@ -326,7 +326,7 @@ def test_ramp_up_sp_after_reset(self): self.assertEqual(self._param['motor'].ramp_up_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_up_sp, 0) - + class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): def test_speed_value_is_read_only(self): @@ -337,7 +337,7 @@ def test_speed_value_is_read_only(self): def test_speed_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed, 0) - + class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): def test_speed_sp_large_negative(self): @@ -373,7 +373,7 @@ def test_speed_sp_after_reset(self): self.assertEqual(self._param['motor'].speed_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed_sp, 0) - + class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): def test_speed_i_negative(self): From 3a19ff7123aa626ff6d420403d1a3725bb436a70 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Thu, 16 Jan 2020 19:13:16 -0500 Subject: [PATCH 157/172] Revert "MoveDifferential: use gyro for better accuracy (#705)" This reverts commit 69e7bde46bd0faaea7e5c06fd7f4681442267fac. --- debian/changelog | 4 +- ev3dev2/__init__.py | 8 +- ev3dev2/motor.py | 309 ++++++++++------------- ev3dev2/sensor/__init__.py | 2 +- ev3dev2/sensor/lego.py | 75 +----- ev3dev2/stopwatch.py | 4 +- tests/api_tests.py | 2 +- tests/motor/ev3dev_port_logger.py | 2 +- tests/motor/motor_run_direct_unittest.py | 2 +- tests/motor/motor_unittest.py | 8 +- 10 files changed, 144 insertions(+), 272 deletions(-) diff --git a/debian/changelog b/debian/changelog index efe3f8e..29947f4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -3,8 +3,6 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Daniel Walton] * RPyC update docs and make it easier to use * use double backticks consistently in docstrings - * MoveDifferential use gyro for better accuracy. Make MoveTank and - MoveDifferential "turn" APIs more consistent. [Matěj Volf] * LED animation fix duration None @@ -14,7 +12,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium * Add "value_secs" and "is_elapsed_*" methods to StopWatch * Rename "value_hms" to "hms_str". New "value_hms" returns original tuple. * Fix bug in Button.process() on Micropython - + [Nithin Shenoy] * Add Gyro-based driving support to MoveTank diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 502c3e7..2d2895e 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -142,12 +142,6 @@ def library_load_warning_message(library_name, dependent_class): class DeviceNotFound(Exception): pass -class DeviceNotDefined(Exception): - pass - -class ThreadNotRunning(Exception): - pass - # ----------------------------------------------------------------------------- # Define the base class from which all other ev3dev classes are defined. @@ -220,7 +214,7 @@ def __str__(self): return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address')) else: return self.__class__.__name__ - + def __repr__(self): return self.__str__() diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index f7638af..1c7e4b3 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -39,7 +39,7 @@ from logging import getLogger from os.path import abspath -from ev3dev2 import get_current_platform, Device, list_device_names, DeviceNotDefined, ThreadNotRunning +from ev3dev2 import get_current_platform, Device, list_device_names from ev3dev2.stopwatch import StopWatch log = getLogger(__name__) @@ -1867,7 +1867,7 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar @property def cs(self): return self._cs - + @cs.setter def cs(self, cs): self._cs = cs @@ -1876,7 +1876,7 @@ def cs(self, cs): @property def gyro(self): return self._gyro - + @gyro.setter def gyro(self, gyro): self._gyro = gyro @@ -1925,6 +1925,17 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru self.right_motor._set_rel_position_degrees_and_speed_sp(right_degrees, right_speed_native_units) self.right_motor._set_brake(brake) + log.debug("{}: on_for_degrees {}".format(self, degrees)) + + # These debugs involve disk I/O to pull position and position_sp so only uncomment + # if you need to troubleshoot in more detail. + # log.debug("{}: left_speed {}, left_speed_native_units {}, left_degrees {}, left-position {}->{}".format( + # self, left_speed, left_speed_native_units, left_degrees, + # self.left_motor.position, self.left_motor.position_sp)) + # log.debug("{}: right_speed {}, right_speed_native_units {}, right_degrees {}, right-position {}->{}".format( + # self, right_speed, right_speed_native_units, right_degrees, + # self.right_motor.position, self.right_motor.position_sp)) + # Start the motors self.left_motor.run_to_rel_pos() self.right_motor.run_to_rel_pos() @@ -1984,6 +1995,11 @@ def on(self, left_speed, right_speed): self.left_motor.speed_sp = int(round(left_speed_native_units)) self.right_motor.speed_sp = int(round(right_speed_native_units)) + # This debug involves disk I/O to pull speed_sp so only uncomment + # if you need to troubleshoot in more detail. + # log.debug("%s: on at left-speed %s, right-speed %s" % + # (self, self.left_motor.speed_sp, self.right_motor.speed_sp)) + # Start the motors self.left_motor.run_forever() self.right_motor.run_forever() @@ -2054,8 +2070,7 @@ def follow_line(self, tank.stop() raise """ - if not self._cs: - raise DeviceNotDefined("The 'cs' variable must be defined with a ColorSensor. Example: tank.cs = ColorSensor()") + assert self._cs, "ColorSensor must be defined" if target_light_intensity is None: target_light_intensity = self._cs.reflected_light_intensity @@ -2108,6 +2123,19 @@ def follow_line(self, self.stop() + def calibrate_gyro(self): + """ + Calibrates the gyro sensor. + + NOTE: This takes 1sec to run + """ + assert self._gyro, "GyroSensor must be defined" + + for x in range(2): + self._gyro.mode = 'GYRO-RATE' + self._gyro.mode = 'GYRO-ANG' + time.sleep(0.5) + def follow_gyro_angle(self, kp, ki, kd, speed, @@ -2153,7 +2181,7 @@ def follow_gyro_angle(self, try: # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.gyro.calibrate() + tank.calibrate_gyro() # Follow the line for 4500ms tank.follow_gyro_angle( @@ -2167,8 +2195,7 @@ def follow_gyro_angle(self, tank.stop() raise """ - if not self._gyro: - raise DeviceNotDefined("The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") + assert self._gyro, "GyroSensor must be defined" integral = 0.0 last_error = 0.0 @@ -2210,36 +2237,30 @@ def follow_gyro_angle(self, self.stop() - def turn_degrees( - self, + def turn_to_angle_gyro(self, speed, - target_angle, - brake=True, - error_margin=2, + target_angle=0, + wiggle_room=2, sleep_time=0.01 ): """ - Use a GyroSensor to rotate in place for ``target_angle`` + Pivot Turn ``speed`` is the desired speed of the midpoint of the robot - ``target_angle`` is the number of degrees we want to rotate - - ``brake`` hit the brakes once we reach ``target_angle`` + ``target_angle`` is the target angle we want to pivot to - ``error_margin`` is the +/- angle threshold to control how accurate the turn should be + ``wiggle_room`` is the +/- angle threshold to control how accurate the turn should be ``sleep_time`` is how many seconds we sleep on each pass through the loop. This is to give the robot a chance to react to the new motor settings. This should be something small such as 0.01 (10ms). - Rotate in place for ``target_degrees`` at ``speed`` - Example: .. code:: python - + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent from ev3dev2.sensor.lego import GyroSensor @@ -2250,58 +2271,35 @@ def turn_degrees( tank.gyro = GyroSensor() # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.gyro.calibrate() + tank.calibrate_gyro() # Pivot 30 degrees - tank.turn_degrees( + tank.turn_to_angle_gyro( speed=SpeedPercent(5), - target_angle=30 + target_angle(30) ) """ - - # MoveTank does not have information on wheel size and distance (that is - # MoveDifferential) so we must use a GyroSensor to control how far we rotate. - if not self._gyro: - raise DeviceNotDefined("The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") + assert self._gyro, "GyroSensor must be defined" speed_native_units = speed.to_native_units(self.left_motor) - target_angle = self._gyro.angle + target_angle + target_reached = False - while True: + while not target_reached: current_angle = self._gyro.angle - delta = abs(target_angle - current_angle) - - if delta <= error_margin: - self.stop(brake=brake) - break - - # we are left of our target, rotate clockwise - if current_angle < target_angle: - left_speed = SpeedNativeUnits(speed_native_units) - right_speed = SpeedNativeUnits(-1 * speed_native_units) - - # we are right of our target, rotate counter-clockwise - else: + if abs(current_angle - target_angle) <= wiggle_room: + target_reached = True + self.stop() + elif (current_angle > target_angle): left_speed = SpeedNativeUnits(-1 * speed_native_units) right_speed = SpeedNativeUnits(speed_native_units) - - self.on(left_speed, right_speed) + else: + left_speed = SpeedNativeUnits(speed_native_units) + right_speed = SpeedNativeUnits(-1 * speed_native_units) if sleep_time: time.sleep(sleep_time) - def turn_right(self, speed, degrees, brake=True, error_margin=2, sleep_time=0.01): - """ - Rotate clockwise ``degrees`` in place - """ - self.turn_degrees(speed, abs(degrees), brake, error_margin, sleep_time) - - def turn_left(self, speed, degrees, brake=True, error_margin=2, sleep_time=0.01): - """ - Rotate counter-clockwise ``degrees`` in place - """ - self.turn_degrees(speed, abs(degrees) * -1, brake, error_margin, sleep_time) - + self.on(left_speed, right_speed) class MoveSteering(MoveTank): """ @@ -2484,15 +2482,15 @@ def __init__(self, left_motor_port, right_motor_port, self.x_pos_mm = 0.0 # robot X position in mm self.y_pos_mm = 0.0 # robot Y position in mm self.odometry_thread_run = False + self.odometry_thread_id = None self.theta = 0.0 def on_for_distance(self, speed, distance_mm, brake=True, block=True): """ - Drive in a straight line for ``distance_mm`` + Drive distance_mm """ rotations = distance_mm / self.wheel.circumference_mm - log.debug("%s: on_for_rotations distance_mm %s, rotations %s, speed %s" % - (self, distance_mm, rotations, speed)) + log.debug("%s: on_for_rotations distance_mm %s, rotations %s, speed %s" % (self, distance_mm, rotations, speed)) MoveTank.on_for_rotations(self, speed, speed, rotations, brake, block) @@ -2563,47 +2561,20 @@ def on_arc_left(self, speed, radius_mm, distance_mm, brake=True, block=True): """ self._on_arc(speed, radius_mm, distance_mm, brake, block, False) - def turn_degrees(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): + def _turn(self, speed, degrees, brake=True, block=True): """ - Rotate in place ``degrees``. Both wheels must turn at the same speed for us - to rotate in place. If the following conditions are met the GryoSensor will - be used to improve the accuracy of our turn: - - ``use_gyro``, ``brake`` and ``block`` are all True - - A GyroSensor has been defined via ``self.gyro = GyroSensor()`` + Rotate in place 'degrees'. Both wheels must turn at the same speed for us + to rotate in place. """ - def final_angle(init_angle, degrees): - result = init_angle - degrees - - while result <= -360: - result += 360 - - while result >= 360: - result -= 360 - - if result < 0: - result += 360 - - return result - - # use the gyro to check that we turned the correct amount? - use_gyro = bool(use_gyro and block and brake and self._gyro) - - if use_gyro: - angle_init_degrees = self._gyro.circle_angle() - else: - angle_init_degrees = math.degrees(self.theta) - - angle_target_degrees = final_angle(angle_init_degrees, degrees) - - log.info("%s: turn_degrees() %d degrees from %s to %s" % - (self, degrees, angle_init_degrees, angle_target_degrees)) - # The distance each wheel needs to travel distance_mm = (abs(degrees) / 360) * self.circumference_mm # The number of rotations to move distance_mm - rotations = distance_mm / self.wheel.circumference_mm + rotations = distance_mm/self.wheel.circumference_mm + + log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % + (self, degrees, distance_mm, rotations, degrees)) # If degrees is positive rotate clockwise if degrees > 0: @@ -2611,86 +2582,20 @@ def final_angle(init_angle, degrees): # If degrees is negative rotate counter-clockwise else: + rotations = distance_mm / self.wheel.circumference_mm MoveTank.on_for_rotations(self, speed * -1, speed, rotations, brake, block) - if use_gyro: - angle_current_degrees = self._gyro.circle_angle() - - # This can happen if we are aiming for 2 degrees and overrotate to 358 degrees - # We need to rotate counter-clockwise - if 90 >= angle_target_degrees >= 0 and 270 <= angle_current_degrees <= 360: - degrees_error = (angle_target_degrees + (360 - angle_current_degrees)) * -1 - - # This can happen if we are aiming for 358 degrees and overrotate to 2 degrees - # We need to rotate clockwise - elif 360 >= angle_target_degrees >= 270 and 0 <= angle_current_degrees <= 90: - degrees_error = angle_current_degrees + (360 - angle_target_degrees) - - # We need to rotate clockwise - elif angle_current_degrees > angle_target_degrees: - degrees_error = angle_current_degrees - angle_target_degrees - - # We need to rotate counter-clockwise - else: - degrees_error = (angle_target_degrees - angle_current_degrees) * -1 - - log.info("%s: turn_degrees() ended up at %s, error %s, error_margin %s" % - (self, angle_current_degrees, degrees_error, error_margin)) - - if abs(degrees_error) > error_margin: - self.turn_degrees(speed, degrees_error, brake, block, error_margin, use_gyro) - - def turn_right(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): + def turn_right(self, speed, degrees, brake=True, block=True): """ - Rotate clockwise ``degrees`` in place + Rotate clockwise 'degrees' in place """ - self.turn_degrees(speed, abs(degrees), brake, block, error_margin, use_gyro) + self._turn(speed, abs(degrees), brake, block) - def turn_left(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): + def turn_left(self, speed, degrees, brake=True, block=True): """ - Rotate counter-clockwise ``degrees`` in place + Rotate counter-clockwise 'degrees' in place """ - self.turn_degrees(speed, abs(degrees) * -1, brake, block, error_margin, use_gyro) - - def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True, error_margin=2, use_gyro=False): - """ - Rotate in place to ``angle_target_degrees`` at ``speed`` - """ - if not self.odometry_thread_run: - raise ThreadNotRunning("odometry_start() must be called to track robot coordinates") - - # Make both target and current angles positive numbers between 0 and 360 - while angle_target_degrees < 0: - angle_target_degrees += 360 - - angle_current_degrees = math.degrees(self.theta) - - while angle_current_degrees < 0: - angle_current_degrees += 360 - - # Is it shorter to rotate to the right or left - # to reach angle_target_degrees? - if angle_current_degrees > angle_target_degrees: - turn_right = True - angle_delta = angle_current_degrees - angle_target_degrees - else: - turn_right = False - angle_delta = angle_target_degrees - angle_current_degrees - - if angle_delta > 180: - angle_delta = 360 - angle_delta - turn_right = not turn_right - - log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % - (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) - self.odometry_coordinates_log() - - if turn_right: - self.turn_degrees(speed, abs(angle_delta), brake, block, error_margin, use_gyro) - else: - self.turn_degrees(speed, abs(angle_delta) * -1, brake, block, error_margin, use_gyro) - - self.odometry_coordinates_log() + self._turn(speed, abs(degrees) * -1, brake, block) def odometry_coordinates_log(self): log.debug("%s: odometry angle %s at (%d, %d)" % @@ -2714,7 +2619,6 @@ def _odometry_monitor(): self.x_pos_mm = x_pos_start # robot X position in mm self.y_pos_mm = y_pos_start # robot Y position in mm TWO_PI = 2 * math.pi - self.odometry_thread_run = True while self.odometry_thread_run: @@ -2733,6 +2637,11 @@ def _odometry_monitor(): time.sleep(sleep_time) continue + # log.debug("%s: left_ticks %s (from %s to %s)" % + # (self, left_ticks, left_previous, left_current)) + # log.debug("%s: right_ticks %s (from %s to %s)" % + # (self, right_ticks, right_previous, right_current)) + # update _previous for next time left_previous = left_current right_previous = right_current @@ -2761,26 +2670,66 @@ def _odometry_monitor(): if sleep_time: time.sleep(sleep_time) - _thread.start_new_thread(_odometry_monitor, ()) + self.odometry_thread_id = None - # Block until the thread has started doing work - while not self.odometry_thread_run: - pass + self.odometry_thread_run = True + self.odometry_thread_id = _thread.start_new_thread(_odometry_monitor, ()) def odometry_stop(self): """ - Signal the odometry thread to exit + Signal the odometry thread to exit and wait for it to exit """ - if self.odometry_thread_run: + if self.odometry_thread_id: self.odometry_thread_run = False + while self.odometry_thread_id: + pass + + def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): + """ + Rotate in place to ``angle_target_degrees`` at ``speed`` + """ + assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" + + # Make both target and current angles positive numbers between 0 and 360 + if angle_target_degrees < 0: + angle_target_degrees += 360 + + angle_current_degrees = math.degrees(self.theta) + + if angle_current_degrees < 0: + angle_current_degrees += 360 + + # Is it shorter to rotate to the right or left + # to reach angle_target_degrees? + if angle_current_degrees > angle_target_degrees: + turn_right = True + angle_delta = angle_current_degrees - angle_target_degrees + else: + turn_right = False + angle_delta = angle_target_degrees - angle_current_degrees + + if angle_delta > 180: + angle_delta = 360 - angle_delta + turn_right = not turn_right + + log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % + (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) + self.odometry_coordinates_log() + + if turn_right: + self.turn_right(speed, angle_delta, brake, block) + else: + self.turn_left(speed, angle_delta, brake, block) + + self.odometry_coordinates_log() + def on_to_coordinates(self, speed, x_target_mm, y_target_mm, brake=True, block=True): """ Drive to (``x_target_mm``, ``y_target_mm``) coordinates at ``speed`` """ - if not self.odometry_thread_run: - raise ThreadNotRunning("odometry_start() must be called to track robot coordinates") + assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" # stop moving self.off(brake='hold') @@ -2804,7 +2753,7 @@ class MoveJoystick(MoveTank): def on(self, x, y, radius=100.0): """ - Convert ``x``,``y`` joystick coordinates to left/right motor speed percentages + Convert x,y joystick coordinates to left/right motor speed percentages and move the motors. This will use a classic "arcade drive" algorithm: a full-forward joystick @@ -2813,11 +2762,11 @@ def on(self, x, y, radius=100.0): Positions in the middle will control how fast the vehicle moves and how sharply it turns. - ``x``, ``y``: + "x", "y": The X and Y coordinates of the joystick's position, with (0,0) representing the center position. X is horizontal and Y is vertical. - ``radius`` (default 100): + radius (default 100): The radius of the joystick, controlling the range of the input (x, y) values. e.g. if "x" and "y" can be between -1 and 1, radius should be set to "1". """ diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index ad39d21..ffe2b92 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -274,7 +274,7 @@ def bin_data(self, fmt=None): if fmt is None: return raw return unpack(fmt, raw) - + def _ensure_mode(self, mode): if self.mode != mode: self.mode = mode diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index fb9c71e..b5c7301 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -28,13 +28,10 @@ if sys.version_info < (3,4): raise SystemError('Must be using Python 3.4 or higher') -import logging import time from ev3dev2.button import ButtonBase from ev3dev2.sensor import Sensor -log = logging.getLogger(__name__) - class TouchSensor(Sensor): """ @@ -229,7 +226,7 @@ def color_name(self): def raw(self): """ Red, green, and blue components of the detected color, as a tuple. - + Officially in the range 0-1020 but the values returned will never be that high. We do not yet know why the values returned are low, but pointing the color sensor at a well lit sheet of white paper will return @@ -589,7 +586,6 @@ class GyroSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-ev3-gyro', **kwargs) self._direct = None - self._init_angle = self.angle @property def angle(self): @@ -626,15 +622,6 @@ def tilt_rate(self): self._ensure_mode(self.MODE_TILT_RATE) return self.value(0) - def calibrate(self): - """ - The robot should be perfectly still when you call this - """ - current_mode = self.mode - self._ensure_mode(self.MODE_GYRO_CAL) - time.sleep(2) - self._ensure_mode(current_mode) - def reset(self): """Resets the angle to 0. @@ -645,7 +632,6 @@ def reset(self): """ # 17 comes from inspecting the .vix file of the Gyro sensor block in EV3-G self._direct = self.set_attr_raw(self._direct, 'direct', b'\x11') - self._init_angle = self.angle def wait_until_angle_changed_by(self, delta, direction_sensitive=False): """ @@ -674,61 +660,6 @@ def wait_until_angle_changed_by(self, delta, direction_sensitive=False): while abs(start_angle - self.value(0)) < delta: time.sleep(0.01) - def circle_angle(self): - """ - As the gryo rotates clockwise the angle increases, it will increase - by 360 for each full rotation. As the gyro rotates counter-clockwise - the gyro angle will decrease. - - The angles on a circle have the opposite behavior though, they start - at 0 and increase as you move counter-clockwise around the circle. - - Convert the gyro angle to the angle on a circle. We consider the initial - position of the gyro to be at 90 degrees on the cirlce. - """ - current_angle = self.angle - delta = abs(current_angle - self._init_angle) % 360 - - if delta == 0: - result = 90 - - # the gyro has turned clockwise relative to where we started - elif current_angle > self._init_angle: - - if delta <= 90: - result = 90 - delta - - elif delta <= 180: - result = 360 - (delta - 90) - - elif delta <= 270: - result = 270 - (delta - 180) - - else: - result = 180 - (delta - 270) - - # This can be chatty (but helpful) so save it for a rainy day - # log.info("%s moved clockwise %s degrees to %s" % (self, delta, result)) - - # the gyro has turned counter-clockwise relative to where we started - else: - if delta <= 90: - result = 90 + delta - - elif delta <= 180: - result = 180 + (delta - 90) - - elif delta <= 270: - result = 270 + (delta - 180) - - else: - result = delta - 270 - - # This can be chatty (but helpful) so save it for a rainy day - # log.info("%s moved counter-clockwise %s degrees to %s" % (self, delta, result)) - - return result - class InfraredSensor(Sensor, ButtonBase): """ @@ -930,7 +861,7 @@ def process(self): old state, call the appropriate button event handlers. To use the on_channel1_top_left, etc handlers your program would do something like: - + .. code:: python def top_left_channel_1_action(state): @@ -946,7 +877,7 @@ def bottom_right_channel_4_action(state): while True: ir.process() time.sleep(0.01) - + """ new_state = [] state_diff = [] diff --git a/ev3dev2/stopwatch.py b/ev3dev2/stopwatch.py index 3a87738..35b804b 100644 --- a/ev3dev2/stopwatch.py +++ b/ev3dev2/stopwatch.py @@ -70,14 +70,14 @@ def reset(self): """ self._start_time = None self._stopped_total_time = None - + def restart(self): """ Resets and then starts the timer. """ self.reset() self.start() - + @property def is_started(self): """ diff --git a/tests/api_tests.py b/tests/api_tests.py index 78585d4..527b226 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -378,7 +378,7 @@ def test_units(self): def test_stopwatch(self): sw = StopWatch() self.assertEqual(str(sw), "StopWatch: 00:00:00.000") - + sw = StopWatch(desc="test sw") self.assertEqual(str(sw), "test sw: 00:00:00.000") self.assertEqual(sw.is_started, False) diff --git a/tests/motor/ev3dev_port_logger.py b/tests/motor/ev3dev_port_logger.py index 5f0972e..1e19c3d 100644 --- a/tests/motor/ev3dev_port_logger.py +++ b/tests/motor/ev3dev_port_logger.py @@ -49,7 +49,7 @@ def execute_actions(actions): for b in c: for k,v in b.items(): setattr( device[p], k, v ) - + device = {} logs = {} diff --git a/tests/motor/motor_run_direct_unittest.py b/tests/motor/motor_run_direct_unittest.py index 0ffb414..c0d63de 100755 --- a/tests/motor/motor_run_direct_unittest.py +++ b/tests/motor/motor_run_direct_unittest.py @@ -30,7 +30,7 @@ def run_direct_duty_cycles(self,stop_action,duty_cycles): self._param['motor'].command = 'run-direct' for i in duty_cycles: - self._param['motor'].duty_cycle_sp = i + self._param['motor'].duty_cycle_sp = i time.sleep(0.5) self._param['motor'].command = 'stop' diff --git a/tests/motor/motor_unittest.py b/tests/motor/motor_unittest.py index efd7f9e..5933ed3 100644 --- a/tests/motor/motor_unittest.py +++ b/tests/motor/motor_unittest.py @@ -298,7 +298,7 @@ def test_ramp_down_sp_after_reset(self): self.assertEqual(self._param['motor'].ramp_down_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_down_sp, 0) - + class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): def test_ramp_up_negative_value(self): @@ -326,7 +326,7 @@ def test_ramp_up_sp_after_reset(self): self.assertEqual(self._param['motor'].ramp_up_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_up_sp, 0) - + class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): def test_speed_value_is_read_only(self): @@ -337,7 +337,7 @@ def test_speed_value_is_read_only(self): def test_speed_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed, 0) - + class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): def test_speed_sp_large_negative(self): @@ -373,7 +373,7 @@ def test_speed_sp_after_reset(self): self.assertEqual(self._param['motor'].speed_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed_sp, 0) - + class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): def test_speed_i_negative(self): From 659f539c9c8e7d6668fe70960ffeb884c4d08cf7 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 19 Jan 2020 19:29:23 -0500 Subject: [PATCH 158/172] format code via yapf (#716) --- .yapf.cfg | 3 + Makefile | 7 + docs/conf.py | 59 ++-- docs/sphinx3-build | 1 - ev3dev2/__init__.py | 28 +- ev3dev2/_platform/brickpi.py | 9 +- ev3dev2/_platform/brickpi3.py | 6 +- ev3dev2/_platform/ev3.py | 1 - ev3dev2/_platform/evb.py | 1 - ev3dev2/_platform/fake.py | 1 - ev3dev2/_platform/pistorms.py | 3 - ev3dev2/button.py | 67 +++-- ev3dev2/console.py | 1 - ev3dev2/control/GyroBalancer.py | 33 +-- ev3dev2/control/rc_tank.py | 6 +- ev3dev2/control/webserver.py | 36 ++- ev3dev2/display.py | 34 +-- ev3dev2/fonts/__init__.py | 7 +- ev3dev2/led.py | 33 +-- ev3dev2/motor.py | 348 +++++++++++------------ ev3dev2/port.py | 14 +- ev3dev2/power.py | 14 +- ev3dev2/sensor/__init__.py | 44 ++- ev3dev2/sensor/lego.py | 135 +++++---- ev3dev2/sound.py | 37 +-- ev3dev2/stopwatch.py | 8 +- ev3dev2/unit.py | 11 +- ev3dev2/wheel.py | 5 - git_version.py | 7 +- setup.py | 32 +-- tests/api_tests.py | 105 +++---- tests/motor/ev3dev_port_logger.py | 48 ++-- tests/motor/motor_info.py | 116 ++++---- tests/motor/motor_motion_unittest.py | 43 +-- tests/motor/motor_param_unittest.py | 119 ++++---- tests/motor/motor_run_direct_unittest.py | 19 +- tests/motor/motor_unittest.py | 120 ++++---- tests/motor/parameterizedtestcase.py | 3 +- tests/motor/plot_matplotlib.py | 73 ++--- utils/console_fonts.py | 11 +- utils/line-follower-find-kp-ki-kd.py | 16 +- utils/move_differential.py | 6 +- utils/move_motor.py | 8 +- utils/stop_all_motors.py | 1 - 44 files changed, 835 insertions(+), 844 deletions(-) create mode 100644 .yapf.cfg diff --git a/.yapf.cfg b/.yapf.cfg new file mode 100644 index 0000000..c30b99c --- /dev/null +++ b/.yapf.cfg @@ -0,0 +1,3 @@ +[style] +based_on_style = pep8 +COLUMN_LIMIT = 120 diff --git a/Makefile b/Makefile index 697b250..00429e4 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,10 @@ install: micropython-install: ${MPYS} cp -R $(OUT)/* /usr/lib/micropython/ + +clean: + find . -name __pycache__ | xargs rm -rf + find . -name *.pyc | xargs rm -rf + +format: + yapf --style .yapf.cfg --in-place --exclude tests/fake-sys/ --recursive . diff --git a/docs/conf.py b/docs/conf.py index 5af6eb7..c43d424 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -124,7 +124,6 @@ # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False - # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for @@ -132,11 +131,9 @@ html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() html_theme = 'bootstrap' html_theme_options = { - 'bootswatch_theme': 'yeti', - 'navbar_links' : [ - ("GitHub", "https://github.com/ev3dev/ev3dev-lang-python", True) - ] - } + 'bootswatch_theme': 'yeti', + 'navbar_links': [("GitHub", "https://github.com/ev3dev/ev3dev-lang-python", True)] +} # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -233,25 +230,24 @@ # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'python-ev3dev.tex', 'python-ev3dev Documentation', - 'Ralph Hempel et al', 'manual'), + (master_doc, 'python-ev3dev.tex', 'python-ev3dev Documentation', 'Ralph Hempel et al', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -274,29 +270,23 @@ # If false, no module index is generated. #latex_domain_indices = True - # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'python-ev3dev', 'python-ev3dev Documentation', - [author], 1) -] +man_pages = [(master_doc, 'python-ev3dev', 'python-ev3dev Documentation', [author], 1)] # If true, show URL addresses after external links. #man_show_urls = False - # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'python-ev3dev', 'python-ev3dev Documentation', - author, 'python-ev3dev', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'python-ev3dev', 'python-ev3dev Documentation', author, 'python-ev3dev', + 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. @@ -315,20 +305,13 @@ suppress_warnings = ['image.nonlocal_uri'] -nitpick_ignore = [ - ('py:class', 'ev3dev2.display.FbMem'), - ('py:class', 'ev3dev2.button.ButtonBase'), - ('py:class', 'int'), - ('py:class', 'float'), - ('py:class', 'string'), - ('py:class', 'iterable'), - ('py:class', 'tuple'), - ('py:class', 'list'), - ('py:exc', 'ValueError') -] +nitpick_ignore = [('py:class', 'ev3dev2.display.FbMem'), ('py:class', 'ev3dev2.button.ButtonBase'), ('py:class', 'int'), + ('py:class', 'float'), ('py:class', 'string'), ('py:class', 'iterable'), ('py:class', 'tuple'), + ('py:class', 'list'), ('py:exc', 'ValueError')] + def setup(app): app.add_config_value('recommonmark_config', { - 'enable_eval_rst': True, - }, True) + 'enable_eval_rst': True, + }, True) app.add_transform(AutoStructify) diff --git a/docs/sphinx3-build b/docs/sphinx3-build index 9c3e5eb..89b4cee 100755 --- a/docs/sphinx3-build +++ b/docs/sphinx3-build @@ -15,4 +15,3 @@ if __name__ == '__main__': sys.exit(make_main(sys.argv)) else: sys.exit(main(sys.argv)) - diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index 2d2895e..a2a20e1 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -25,18 +25,21 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') + def is_micropython(): return sys.implementation.name == "micropython" + def chain_exception(exception, cause): if is_micropython(): raise exception else: raise exception from cause + try: # if we are in a released build, there will be an auto-generated "version" # module @@ -52,6 +55,7 @@ def chain_exception(exception, cause): import errno from os.path import abspath + def get_current_platform(): """ Look in /sys/class/board-info/ to determine the platform type. @@ -139,12 +143,15 @@ def matches(attribute, pattern): def library_load_warning_message(library_name, dependent_class): return 'Import warning: Failed to import "{}". {} will be unusable!'.format(library_name, dependent_class) + class DeviceNotFound(Exception): pass + # ----------------------------------------------------------------------------- # Define the base class from which all other ev3dev classes are defined. + class Device(object): """The ev3dev device base class""" @@ -214,7 +221,7 @@ def __str__(self): return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address')) else: return self.__class__.__name__ - + def __repr__(self): return self.__str__() @@ -241,7 +248,7 @@ def _get_attribute(self, attribute, name): """Device attribute getter""" try: if attribute is None: - attribute = self._attribute_file_open( name ) + attribute = self._attribute_file_open(name) else: attribute.seek(0) return attribute, attribute.read().strip().decode() @@ -252,7 +259,7 @@ def _set_attribute(self, attribute, name, value): """Device attribute setter""" try: if attribute is None: - attribute = self._attribute_file_open( name ) + attribute = self._attribute_file_open(name) else: attribute.seek(0) @@ -275,11 +282,13 @@ def _raise_friendly_access_error(self, driver_error, attribute, value): try: max_speed = self.max_speed except (AttributeError, Exception): - chain_exception(ValueError("The given speed value {} was out of range".format(value)), - driver_error) + chain_exception(ValueError("The given speed value {} was out of range".format(value)), driver_error) else: - chain_exception(ValueError("The given speed value {} was out of range. Max speed: +/-{}".format(value, max_speed)), driver_error) - chain_exception(ValueError("One or more arguments were out of range or invalid, value {}".format(value)), driver_error) + chain_exception( + ValueError("The given speed value {} was out of range. Max speed: +/-{}".format( + value, max_speed)), driver_error) + chain_exception(ValueError("One or more arguments were out of range or invalid, value {}".format(value)), + driver_error) elif driver_errorno == errno.ENODEV or driver_errorno == errno.ENOENT: # We will assume that a file-not-found error is the result of a disconnected device # rather than a library error. If that isn't the case, at a minimum the underlying @@ -369,5 +378,4 @@ def list_devices(class_name, name_pattern, **kwargs): """ classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name) - return (Device(class_name, name, name_exact=True) - for name in list_device_names(classpath, name_pattern, **kwargs)) + return (Device(class_name, name, name_exact=True) for name in list_device_names(classpath, name_pattern, **kwargs)) diff --git a/ev3dev2/_platform/brickpi.py b/ev3dev2/_platform/brickpi.py index 47768ae..75e6991 100644 --- a/ev3dev2/_platform/brickpi.py +++ b/ev3dev2/_platform/brickpi.py @@ -21,7 +21,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # ----------------------------------------------------------------------------- - """ An assortment of classes modeling specific features of the BrickPi. """ @@ -46,11 +45,11 @@ LEDS['blue_led2'] = 'led2:blue:brick-status' LED_GROUPS = OrderedDict() -LED_GROUPS['LED1'] = ('blue_led1',) -LED_GROUPS['LED2'] = ('blue_led2',) +LED_GROUPS['LED1'] = ('blue_led1', ) +LED_GROUPS['LED2'] = ('blue_led2', ) LED_COLORS = OrderedDict() -LED_COLORS['BLACK'] = (0,) -LED_COLORS['BLUE'] = (1,) +LED_COLORS['BLACK'] = (0, ) +LED_COLORS['BLUE'] = (1, ) LED_DEFAULT_COLOR = 'BLUE' diff --git a/ev3dev2/_platform/brickpi3.py b/ev3dev2/_platform/brickpi3.py index 25a414c..b875b9b 100644 --- a/ev3dev2/_platform/brickpi3.py +++ b/ev3dev2/_platform/brickpi3.py @@ -48,10 +48,10 @@ LEDS['amber_led'] = 'led1:amber:brick-status' LED_GROUPS = OrderedDict() -LED_GROUPS['LED'] = ('amber_led',) +LED_GROUPS['LED'] = ('amber_led', ) LED_COLORS = OrderedDict() -LED_COLORS['BLACK'] = (0,) -LED_COLORS['AMBER'] = (1,) +LED_COLORS['BLACK'] = (0, ) +LED_COLORS['AMBER'] = (1, ) LED_DEFAULT_COLOR = 'AMBER' diff --git a/ev3dev2/_platform/ev3.py b/ev3dev2/_platform/ev3.py index 6bf166b..92e0c6c 100644 --- a/ev3dev2/_platform/ev3.py +++ b/ev3dev2/_platform/ev3.py @@ -21,7 +21,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # ----------------------------------------------------------------------------- - """ An assortment of classes modeling specific features of the EV3 brick. """ diff --git a/ev3dev2/_platform/evb.py b/ev3dev2/_platform/evb.py index 9350470..7c6470e 100644 --- a/ev3dev2/_platform/evb.py +++ b/ev3dev2/_platform/evb.py @@ -1,4 +1,3 @@ - """ An assortment of classes modeling specific features of the EVB. """ diff --git a/ev3dev2/_platform/fake.py b/ev3dev2/_platform/fake.py index d13a1fd..26a0498 100644 --- a/ev3dev2/_platform/fake.py +++ b/ev3dev2/_platform/fake.py @@ -1,4 +1,3 @@ - OUTPUT_A = 'outA' OUTPUT_B = 'outB' OUTPUT_C = 'outC' diff --git a/ev3dev2/_platform/pistorms.py b/ev3dev2/_platform/pistorms.py index 38a4f91..1dfa0b2 100644 --- a/ev3dev2/_platform/pistorms.py +++ b/ev3dev2/_platform/pistorms.py @@ -1,4 +1,3 @@ - """ An assortment of classes modeling specific features of the PiStorms. """ @@ -14,11 +13,9 @@ INPUT_3 = 'pistorms:BBS1' INPUT_4 = 'pistorms:BBS2' - BUTTONS_FILENAME = '/dev/input/by-path/platform-3f804000.i2c-event' EVDEV_DEVICE_NAME = 'PiStorms' - LEDS = OrderedDict() LEDS['red_left'] = 'pistorms:BB:red:brick-status' LEDS['red_right'] = 'pistorms:BA:red:brick-status' diff --git a/ev3dev2/button.py b/ev3dev2/button.py index f43d1e6..c13c8e2 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') from ev3dev2.stopwatch import StopWatch @@ -64,7 +64,6 @@ class MissingButton(Exception): class ButtonCommon(object): - def __str__(self): return self.__class__.__name__ @@ -213,13 +212,11 @@ def backspace(self): if platform not in ("ev3", "fake"): raise Exception("micropython button support has not been implemented for '%s'" % platform) - def _test_bit(buf, index): byte = buf[int(index >> 3)] bit = byte & (1 << (index % 8)) return bool(bit) - class ButtonBase(ButtonCommon): pass @@ -241,12 +238,12 @@ class Button(ButtonCommon, EV3ButtonCommon): _BUTTON_DEV = '/dev/input/by-path/platform-gpio_keys-event' _BUTTON_TO_STRING = { - UP : "up", - DOWN : "down", - LEFT : "left", - RIGHT : "right", - ENTER : "enter", - BACK : "backspace", + UP: "up", + DOWN: "down", + LEFT: "left", + RIGHT: "right", + ENTER: "enter", + BACK: "backspace", } # stuff from linux/input.h and linux/input-event-codes.h @@ -285,10 +282,14 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): # with the name of a single button. If it is a string of a single # button convert that to a list. if isinstance(wait_for_button_press, str): - wait_for_button_press = [wait_for_button_press, ] + wait_for_button_press = [ + wait_for_button_press, + ] if isinstance(wait_for_button_release, str): - wait_for_button_release = [wait_for_button_release, ] + wait_for_button_release = [ + wait_for_button_release, + ] while True: all_pressed = True @@ -311,6 +312,7 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): if timeout_ms is not None and stopwatch.value_ms >= timeout_ms: return False + # python3 implementation else: import array @@ -331,7 +333,6 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): except ImportError: log.warning(library_load_warning_message("evdev", "Button")) - class ButtonBase(ButtonCommon): """ Abstract button interface. @@ -356,7 +357,6 @@ def process_forever(self): if event.type == evdev.ecodes.EV_KEY: self.process() - class ButtonEVIO(ButtonBase): """ Provides a generic button reading mechanism that works with event interface @@ -420,10 +420,14 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): # with the name of a single button. If it is a string of a single # button convert that to a list. if isinstance(wait_for_button_press, str): - wait_for_button_press = [wait_for_button_press, ] + wait_for_button_press = [ + wait_for_button_press, + ] if isinstance(wait_for_button_release, str): - wait_for_button_release = [wait_for_button_release, ] + wait_for_button_release = [ + wait_for_button_release, + ] for event in self.evdev_device.read_loop(): if event.type == evdev.ecodes.EV_KEY: @@ -447,18 +451,35 @@ def _wait(self, wait_for_button_press, wait_for_button_release, timeout_ms): if timeout_ms is not None and stopwatch.value_ms >= timeout_ms: return False - class Button(ButtonEVIO, EV3ButtonCommon): """ EV3 Buttons """ _buttons = { - 'up': {'name': BUTTONS_FILENAME, 'value': 103}, - 'down': {'name': BUTTONS_FILENAME, 'value': 108}, - 'left': {'name': BUTTONS_FILENAME, 'value': 105}, - 'right': {'name': BUTTONS_FILENAME, 'value': 106}, - 'enter': {'name': BUTTONS_FILENAME, 'value': 28}, - 'backspace': {'name': BUTTONS_FILENAME, 'value': 14}, + 'up': { + 'name': BUTTONS_FILENAME, + 'value': 103 + }, + 'down': { + 'name': BUTTONS_FILENAME, + 'value': 108 + }, + 'left': { + 'name': BUTTONS_FILENAME, + 'value': 105 + }, + 'right': { + 'name': BUTTONS_FILENAME, + 'value': 106 + }, + 'enter': { + 'name': BUTTONS_FILENAME, + 'value': 28 + }, + 'backspace': { + 'name': BUTTONS_FILENAME, + 'value': 14 + }, } evdev_device_name = EVDEV_DEVICE_NAME diff --git a/ev3dev2/console.py b/ev3dev2/console.py index a7d2dc0..4dec21e 100644 --- a/ev3dev2/console.py +++ b/ev3dev2/console.py @@ -27,7 +27,6 @@ class Console(): for cursor positioning, text color, and resetting the screen. Supports changing the console font using standard system fonts. """ - def __init__(self, font="Lat15-TerminusBold24x12"): """ Construct the Console instance, optionally with a font name specified. diff --git a/ev3dev2/control/GyroBalancer.py b/ev3dev2/control/GyroBalancer.py index 1d4929e..6df1169 100644 --- a/ev3dev2/control/GyroBalancer.py +++ b/ev3dev2/control/GyroBalancer.py @@ -81,7 +81,6 @@ class GyroBalancer(object): Robot will keep its balance. """ - def __init__(self, gain_gyro_angle=1700, gain_gyro_rate=120, @@ -138,13 +137,10 @@ def __init__(self, # Open sensor and motor files self.gyro_file = open(self.gyro._path + "/value0", "rb") self.touch_file = open(self.touch._path + "/value0", "rb") - self.encoder_left_file = open(self.motor_left._path + "/position", - "rb") - self.encoder_right_file = open(self.motor_right._path + "/position", - "rb") + self.encoder_left_file = open(self.motor_left._path + "/position", "rb") + self.encoder_right_file = open(self.motor_right._path + "/position", "rb") self.dc_left_file = open(self.motor_left._path + "/duty_cycle_sp", "w") - self.dc_right_file = open(self.motor_right._path + "/duty_cycle_sp", - "w") + self.dc_right_file = open(self.motor_right._path + "/duty_cycle_sp", "w") # Drive queue self.drive_queue = queue.Queue() @@ -174,7 +170,7 @@ def shutdown(self): def _fast_read(self, infile): """Function for fast reading from sensor files.""" infile.seek(0) - return(int(infile.read().decode().strip())) + return (int(infile.read().decode().strip())) def _fast_write(self, outfile, value): """Function for fast writing to motor files.""" @@ -182,11 +178,10 @@ def _fast_write(self, outfile, value): outfile.write(str(int(value))) outfile.flush() - def _set_duty(self, motor_duty_file, duty, friction_offset, - voltage_comp): + def _set_duty(self, motor_duty_file, duty, friction_offset, voltage_comp): """Function to set the duty cycle of the motors.""" # Compensate for nominal voltage and round the input - duty_int = int(round(duty*voltage_comp)) + duty_int = int(round(duty * voltage_comp)) # Add or subtract offset and clamp the value between -100 and 100 if duty_int > 0: @@ -298,8 +293,7 @@ def _balance(self): voltage_comp = self.power_voltage_nominal / voltage_idle # Offset to limit friction deadlock - friction_offset = int(round(self.pwr_friction_offset_nom * - voltage_comp)) + friction_offset = int(round(self.pwr_friction_offset_nom * voltage_comp)) # Timing settings for the program # Time of each loop, measured in seconds. @@ -375,9 +369,8 @@ def _balance(self): touch_pressed = self._fast_read(self.touch_file) # Read the Motor Position - motor_angle_raw = ((self._fast_read(self.encoder_left_file) + - self._fast_read(self.encoder_right_file)) / - 2.0) + motor_angle_raw = ( + (self._fast_read(self.encoder_left_file) + self._fast_read(self.encoder_right_file)) / 2.0) motor_angle = motor_angle_raw * RAD_PER_RAW_MOTOR_UNIT # Read the Gyro @@ -385,7 +378,7 @@ def _balance(self): # Busy wait for the loop to reach target time length loop_time = 0 - while(loop_time < loop_time_target): + while (loop_time < loop_time_target): loop_time = time.time() - loop_start_time time.sleep(0.001) @@ -430,10 +423,8 @@ def _balance(self): motor_angle_error_acc) # Apply the signal to the motor, and add steering - self._set_duty(self.dc_right_file, motor_duty_cycle + steering, - friction_offset, voltage_comp) - self._set_duty(self.dc_left_file, motor_duty_cycle - steering, - friction_offset, voltage_comp) + self._set_duty(self.dc_right_file, motor_duty_cycle + steering, friction_offset, voltage_comp) + self._set_duty(self.dc_left_file, motor_duty_cycle - steering, friction_offset, voltage_comp) # Update angle estimate and gyro offset estimate gyro_est_angle = gyro_est_angle + gyro_rate *\ diff --git a/ev3dev2/control/rc_tank.py b/ev3dev2/control/rc_tank.py index 4e367b8..6b93a42 100644 --- a/ev3dev2/control/rc_tank.py +++ b/ev3dev2/control/rc_tank.py @@ -1,4 +1,3 @@ - import logging from ev3dev2.motor import MoveTank from ev3dev2.sensor.lego import InfraredSensor @@ -6,11 +5,11 @@ log = logging.getLogger(__name__) + # ============ # Tank classes # ============ class RemoteControlledTank(MoveTank): - def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed=400, channel=1): MoveTank.__init__(self, left_motor_port, right_motor_port) self.set_polarity(polarity) @@ -20,7 +19,7 @@ def __init__(self, left_motor_port, right_motor_port, polarity='inversed', speed self.speed_sp = speed self.remote = InfraredSensor() self.remote.on_channel1_top_left = self.make_move(left_motor, self.speed_sp) - self.remote.on_channel1_bottom_left = self.make_move(left_motor, self.speed_sp* -1) + self.remote.on_channel1_bottom_left = self.make_move(left_motor, self.speed_sp * -1) self.remote.on_channel1_top_right = self.make_move(right_motor, self.speed_sp) self.remote.on_channel1_bottom_right = self.make_move(right_motor, self.speed_sp * -1) self.channel = channel @@ -31,6 +30,7 @@ def move(state): motor.run_forever(speed_sp=dc_sp) else: motor.stop() + return move def main(self): diff --git a/ev3dev2/control/webserver.py b/ev3dev2/control/webserver.py index 36142bc..2fa6bfc 100644 --- a/ev3dev2/control/webserver.py +++ b/ev3dev2/control/webserver.py @@ -24,13 +24,13 @@ class to handle REST APIish GETs via their do_GET() # File extension to mimetype mimetype = { - 'css' : 'text/css', - 'gif' : 'image/gif', - 'html' : 'text/html', - 'ico' : 'image/x-icon', - 'jpg' : 'image/jpg', - 'js' : 'application/javascript', - 'png' : 'image/png' + 'css': 'text/css', + 'gif': 'image/gif', + 'html': 'text/html', + 'ico': 'image/x-icon', + 'jpg': 'image/jpg', + 'js': 'application/javascript', + 'png': 'image/png' } def do_GET(self): @@ -83,8 +83,8 @@ def log_message(self, format, *args): medium_motor_max_speed = None joystick_engaged = False -class TankWebHandler(RobotWebHandler): +class TankWebHandler(RobotWebHandler): def __str__(self): return "%s-TankWebHandler" % self.robot @@ -108,7 +108,6 @@ def do_GET(self): medium_motor_max_speed = self.robot.medium_motor.max_speed else: medium_motor_max_speed = 0 - ''' Sometimes we get AJAX requests out of order like this: 2016-09-06 02:29:35,846 DEBUG: seq 65: (x, y): 0, 44 -> speed 462 462 @@ -145,8 +144,8 @@ def do_GET(self): speed_percentage = path[4] log.debug("seq %d: move %s" % (seq, direction)) - left_speed = int(int(speed_percentage) * motor_max_speed)/100.0 - right_speed = int(int(speed_percentage) * motor_max_speed)/100.0 + left_speed = int(int(speed_percentage) * motor_max_speed) / 100.0 + right_speed = int(int(speed_percentage) * motor_max_speed) / 100.0 if direction == 'forward': self.robot.left_motor.run_forever(speed_sp=left_speed) @@ -186,16 +185,17 @@ def do_GET(self): motor = path[3] direction = path[4] speed_percentage = path[5] - log.debug("seq %d: start motor %s, direction %s, speed_percentage %s" % (seq, motor, direction, speed_percentage)) + log.debug("seq %d: start motor %s, direction %s, speed_percentage %s" % + (seq, motor, direction, speed_percentage)) if motor == 'medium': if hasattr(self.robot, 'medium_motor'): if direction == 'clockwise': - medium_speed = int(int(speed_percentage) * medium_motor_max_speed)/100.0 + medium_speed = int(int(speed_percentage) * medium_motor_max_speed) / 100.0 self.robot.medium_motor.run_forever(speed_sp=medium_speed) elif direction == 'counter-clockwise': - medium_speed = int(int(speed_percentage) * medium_motor_max_speed)/100.0 + medium_speed = int(int(speed_percentage) * medium_motor_max_speed) / 100.0 self.robot.medium_motor.run_forever(speed_sp=medium_speed * -1) else: log.info("we do not have a medium_motor") @@ -213,11 +213,9 @@ def do_GET(self): max_move_xy_seq = seq log.debug("seq %d: (x, y) (%4d, %4d)" % (seq, x, y)) else: - log.debug("seq %d: (x, y) %4d, %4d (ignore, max seq %d)" % - (seq, x, y, max_move_xy_seq)) + log.debug("seq %d: (x, y) %4d, %4d (ignore, max seq %d)" % (seq, x, y, max_move_xy_seq)) else: - log.debug("seq %d: (x, y) %4d, %4d (ignore, joystick idle)" % - (seq, x, y)) + log.debug("seq %d: (x, y) %4d, %4d (ignore, joystick idle)" % (seq, x, y)) elif action == 'joystick-engaged': joystick_engaged = True @@ -247,7 +245,6 @@ class RobotWebServer(object): """ A Web server so that 'robot' can be controlled via 'handler_class' """ - def __init__(self, robot, handler_class, port_number=8000): self.content_server = None self.handler_class = handler_class @@ -277,7 +274,6 @@ class WebControlledTank(MoveJoystick): """ A tank that is controlled via a web browser """ - def __init__(self, left_motor, right_motor, port_number=8000, desc=None, motor_class=LargeMotor): MoveJoystick.__init__(self, left_motor, right_motor, desc, motor_class) self.www = RobotWebServer(self, TankWebHandler, port_number) diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 2a46744..9a2303b 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') import os @@ -49,7 +49,6 @@ class FbMem(object): - """The framebuffer memory object. Made of: @@ -83,7 +82,6 @@ class FbMem(object): FB_VISUAL_MONO10 = 1 class FixScreenInfo(ctypes.Structure): - """The fb_fix_screeninfo from fb.h.""" _fields_ = [ @@ -104,9 +102,7 @@ class FixScreenInfo(ctypes.Structure): ] class VarScreenInfo(ctypes.Structure): - class FbBitField(ctypes.Structure): - """The fb_bitfield struct from fb.h.""" _fields_ = [ @@ -128,10 +124,8 @@ def __str__(self): ('yres_virtual', ctypes.c_uint32), ('xoffset', ctypes.c_uint32), ('yoffset', ctypes.c_uint32), - ('bits_per_pixel', ctypes.c_uint32), ('grayscale', ctypes.c_uint32), - ('red', FbBitField), ('green', FbBitField), ('blue', FbBitField), @@ -140,8 +134,8 @@ def __str__(self): def __str__(self): return ("%sx%s at (%s,%s), bpp %s, grayscale %s, red %s, green %s, blue %s, transp %s" % - (self.xres, self.yres, self.xoffset, self.yoffset, self.bits_per_pixel, self.grayscale, - self.red, self.green, self.blue, self.transp)) + (self.xres, self.yres, self.xoffset, self.yoffset, self.bits_per_pixel, self.grayscale, self.red, + self.green, self.blue, self.transp)) def __init__(self, fbdev=None): """Create the FbMem framebuffer memory object.""" @@ -181,13 +175,7 @@ def _get_var_info(fbfid): @staticmethod def _map_fb_memory(fbfid, fix_info): """Map the framebuffer memory.""" - return mmap.mmap( - fbfid, - fix_info.smem_len, - mmap.MAP_SHARED, - mmap.PROT_READ | mmap.PROT_WRITE, - offset=0 - ) + return mmap.mmap(fbfid, fix_info.smem_len, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE, offset=0) class Display(FbMem): @@ -217,12 +205,10 @@ def __init__(self, desc='Display'): else: raise Exception("Not supported - platform %s with bits_per_pixel %s" % - (self.platform, self.var_info.bits_per_pixel)) + (self.platform, self.var_info.bits_per_pixel)) - self._img = Image.new( - im_type, - (self.fix_info.line_length * 8 // self.var_info.bits_per_pixel, self.yres), - "white") + self._img = Image.new(im_type, (self.fix_info.line_length * 8 // self.var_info.bits_per_pixel, self.yres), + "white") self._draw = ImageDraw.Draw(self._img) self.desc = desc @@ -307,7 +293,7 @@ def update(self): else: raise Exception("Not supported - platform %s with bits_per_pixel %s" % - (self.platform, self.var_info.bits_per_pixel)) + (self.platform, self.var_info.bits_per_pixel)) def image_filename(self, filename, clear_screen=True, x1=0, y1=0, x2=None, y2=None): @@ -431,9 +417,7 @@ def text_grid(self, text, clear_screen=True, x=0, y=0, text_color='black', font= "grid rows must be between 0 and %d, %d was requested" %\ ((Display.GRID_ROWS - 1), y) - return self.text_pixels(text, clear_screen, - x * Display.GRID_COLUMN_PIXELS, - y * Display.GRID_ROW_PIXELS, + return self.text_pixels(text, clear_screen, x * Display.GRID_COLUMN_PIXELS, y * Display.GRID_ROW_PIXELS, text_color, font) def reset_screen(self): diff --git a/ev3dev2/fonts/__init__.py b/ev3dev2/fonts/__init__.py index 2ec97cd..64b1cce 100644 --- a/ev3dev2/fonts/__init__.py +++ b/ev3dev2/fonts/__init__.py @@ -2,15 +2,16 @@ from glob import glob from PIL import ImageFont + def available(): """ Returns list of available font names. """ font_dir = os.path.dirname(__file__) - names = [os.path.basename(os.path.splitext(f)[0]) - for f in glob(os.path.join(font_dir, '*.pil'))] + names = [os.path.basename(os.path.splitext(f)[0]) for f in glob(os.path.join(font_dir, '*.pil'))] return sorted(names) + def load(name): """ Loads the font specified by name and returns it as an instance of @@ -24,4 +25,4 @@ def load(name): return ImageFont.load(pil_file) except FileNotFoundError: raise Exception('Failed to load font "{}". '.format(name) + - 'Check ev3dev.fonts.available() for the list of available fonts') + 'Check ev3dev.fonts.available() for the list of available fonts') diff --git a/ev3dev2/led.py b/ev3dev2/led.py index de1f1fd..369a199 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') import os @@ -72,18 +72,16 @@ class Led(Device): SYSTEM_CLASS_NAME = 'leds' SYSTEM_DEVICE_NAME_CONVENTION = '*' __slots__ = [ - '_max_brightness', - '_brightness', - '_triggers', - '_trigger', - '_delay_on', - '_delay_off', - 'desc', + '_max_brightness', + '_brightness', + '_triggers', + '_trigger', + '_delay_on', + '_delay_off', + 'desc', ] - def __init__(self, - name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, - desc=None, **kwargs): + def __init__(self, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, desc=None, **kwargs): self.desc = desc super(Led, self).__init__(self.SYSTEM_CLASS_NAME, name_pattern, name_exact, **kwargs) self._max_brightness = None @@ -271,7 +269,6 @@ def brightness_pct(self, value): class Leds(object): - def __init__(self): self.leds = OrderedDict() self.led_groups = OrderedDict() @@ -385,7 +382,14 @@ def animate_stop(self): while self.animate_thread_id: pass - def animate_police_lights(self, color1, color2, group1='LEFT', group2='RIGHT', sleeptime=0.5, duration=5, block=True): + def animate_police_lights(self, + color1, + color2, + group1='LEFT', + group2='RIGHT', + sleeptime=0.5, + duration=5, + block=True): """ Cycle the ``group1`` and ``group2`` LEDs between ``color1`` and ``color2`` to give the effect of police lights. Alternate the ``group1`` and ``group2`` @@ -401,7 +405,6 @@ def animate_police_lights(self, color1, color2, group1='LEFT', group2='RIGHT', s leds = Leds() leds.animate_police_lights('RED', 'GREEN', sleeptime=0.75, duration=10) """ - def _animate_police_lights(): self.all_off() even = True @@ -447,7 +450,6 @@ def animate_flash(self, color, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duration leds = Leds() leds.animate_flash('AMBER', sleeptime=0.75, duration=10) """ - def _animate_flash(): even = True duration_ms = duration * 1000 if duration is not None else None @@ -537,7 +539,6 @@ def animate_rainbow(self, group1='LEFT', group2='RIGHT', increment_by=0.1, sleep leds = Leds() leds.animate_rainbow() """ - def _animate_rainbow(): # state 0: (LEFT,RIGHT) from (0,0) to (1,0)...RED # state 1: (LEFT,RIGHT) from (1,0) to (1,1)...AMBER diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 1c7e4b3..411a0d3 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -22,7 +22,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') import math @@ -48,7 +48,6 @@ # update to 'running' in the "on_for_XYZ" methods of the Motor class WAIT_RUNNING_TIMEOUT = 100 - # OUTPUT ports have platform specific values that we must import platform = get_current_platform() @@ -83,7 +82,6 @@ class SpeedValue(object): :class:`SpeedPercent`, :class:`SpeedRPS`, :class:`SpeedRPM`, :class:`SpeedDPS`, and :class:`SpeedDPM`. """ - def __eq__(self, other): return self.to_native_units() == other.to_native_units() @@ -110,7 +108,6 @@ class SpeedPercent(SpeedValue): """ Speed as a percentage of the motor's maximum rated speed. """ - def __init__(self, percent): assert -100 <= percent <= 100,\ "{} is an invalid percentage, must be between -100 and 100 (inclusive)".format(percent) @@ -135,7 +132,6 @@ class SpeedNativeUnits(SpeedValue): """ Speed in tacho counts per second. """ - def __init__(self, native_counts): self.native_counts = native_counts @@ -157,7 +153,6 @@ class SpeedRPS(SpeedValue): """ Speed in rotations-per-second. """ - def __init__(self, rotations_per_second): self.rotations_per_second = rotations_per_second @@ -175,14 +170,13 @@ def to_native_units(self, motor): assert abs(self.rotations_per_second) <= motor.max_rps,\ "invalid rotations-per-second: {} max RPS is {}, {} was requested".format( motor, motor.max_rps, self.rotations_per_second) - return self.rotations_per_second/motor.max_rps * motor.max_speed + return self.rotations_per_second / motor.max_rps * motor.max_speed class SpeedRPM(SpeedValue): """ Speed in rotations-per-minute. """ - def __init__(self, rotations_per_minute): self.rotations_per_minute = rotations_per_minute @@ -200,14 +194,13 @@ def to_native_units(self, motor): assert abs(self.rotations_per_minute) <= motor.max_rpm,\ "invalid rotations-per-minute: {} max RPM is {}, {} was requested".format( motor, motor.max_rpm, self.rotations_per_minute) - return self.rotations_per_minute/motor.max_rpm * motor.max_speed + return self.rotations_per_minute / motor.max_rpm * motor.max_speed class SpeedDPS(SpeedValue): """ Speed in degrees-per-second. """ - def __init__(self, degrees_per_second): self.degrees_per_second = degrees_per_second @@ -225,14 +218,13 @@ def to_native_units(self, motor): assert abs(self.degrees_per_second) <= motor.max_dps,\ "invalid degrees-per-second: {} max DPS is {}, {} was requested".format( motor, motor.max_dps, self.degrees_per_second) - return self.degrees_per_second/motor.max_dps * motor.max_speed + return self.degrees_per_second / motor.max_dps * motor.max_speed class SpeedDPM(SpeedValue): """ Speed in degrees-per-minute. """ - def __init__(self, degrees_per_minute): self.degrees_per_minute = degrees_per_minute @@ -250,11 +242,10 @@ def to_native_units(self, motor): assert abs(self.degrees_per_minute) <= motor.max_dpm,\ "invalid degrees-per-minute: {} max DPM is {}, {} was requested".format( motor, motor.max_dpm, self.degrees_per_minute) - return self.degrees_per_minute/motor.max_dpm * motor.max_speed + return self.degrees_per_minute / motor.max_dpm * motor.max_speed class Motor(Device): - """ The motor class provides a uniform interface for using motors with positional and directional feedback such as the EV3 and NXT motors. @@ -266,38 +257,38 @@ class Motor(Device): SYSTEM_DEVICE_NAME_CONVENTION = '*' __slots__ = [ - '_address', - '_command', - '_commands', - '_count_per_rot', - '_count_per_m', - '_driver_name', - '_duty_cycle', - '_duty_cycle_sp', - '_full_travel_count', - '_polarity', - '_position', - '_position_p', - '_position_i', - '_position_d', - '_position_sp', - '_max_speed', - '_speed', - '_speed_sp', - '_ramp_up_sp', - '_ramp_down_sp', - '_speed_p', - '_speed_i', - '_speed_d', - '_state', - '_stop_action', - '_stop_actions', - '_time_sp', - '_poll', - 'max_rps', - 'max_rpm', - 'max_dps', - 'max_dpm', + '_address', + '_command', + '_commands', + '_count_per_rot', + '_count_per_m', + '_driver_name', + '_duty_cycle', + '_duty_cycle_sp', + '_full_travel_count', + '_polarity', + '_position', + '_position_p', + '_position_i', + '_position_d', + '_position_sp', + '_max_speed', + '_speed', + '_speed_sp', + '_ramp_up_sp', + '_ramp_down_sp', + '_speed_p', + '_speed_i', + '_speed_d', + '_state', + '_stop_action', + '_stop_actions', + '_time_sp', + '_poll', + 'max_rps', + 'max_rpm', + 'max_dps', + 'max_dpm', ] #: Run the motor until another command is sent. @@ -410,7 +401,7 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._stop_actions = None self._time_sp = None self._poll = None - self.max_rps = float(self.max_speed/self.count_per_rot) + self.max_rps = float(self.max_speed / self.count_per_rot) self.max_rpm = self.max_rps * 60 self.max_dps = self.max_rps * 360 self.max_dpm = self.max_rpm * 360 @@ -959,7 +950,7 @@ def _set_rel_position_degrees_and_speed_sp(self, degrees, speed): degrees = degrees if speed >= 0 else -degrees speed = abs(speed) - position_delta = int(round((degrees * self.count_per_rot)/360)) + position_delta = int(round((degrees * self.count_per_rot) / 360)) speed_sp = int(round(speed)) self.position_sp = position_delta @@ -1089,11 +1080,10 @@ def list_motors(name_pattern=Motor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): """ class_path = abspath(Device.DEVICE_ROOT_PATH + '/' + Motor.SYSTEM_CLASS_NAME) - return (Motor(name_pattern=name, name_exact=True) - for name in list_device_names(class_path, name_pattern, **kwargs)) + return (Motor(name_pattern=name, name_exact=True) for name in list_device_names(class_path, name_pattern, **kwargs)) -class LargeMotor(Motor): +class LargeMotor(Motor): """ EV3/NXT large servo motor. @@ -1106,11 +1096,14 @@ class LargeMotor(Motor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(LargeMotor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-l-motor', 'lego-nxt-motor'], **kwargs) + super(LargeMotor, self).__init__(address, + name_pattern, + name_exact, + driver_name=['lego-ev3-l-motor', 'lego-nxt-motor'], + **kwargs) class MediumMotor(Motor): - """ EV3 medium servo motor. @@ -1127,7 +1120,6 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam class ActuonixL1250Motor(Motor): - """ Actuonix L12 50 linear servo motor. @@ -1140,11 +1132,14 @@ class ActuonixL1250Motor(Motor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(ActuonixL1250Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-50'], **kwargs) + super(ActuonixL1250Motor, self).__init__(address, + name_pattern, + name_exact, + driver_name=['act-l12-ev3-50'], + **kwargs) class ActuonixL12100Motor(Motor): - """ Actuonix L12 100 linear servo motor. @@ -1157,11 +1152,14 @@ class ActuonixL12100Motor(Motor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(ActuonixL12100Motor, self).__init__(address, name_pattern, name_exact, driver_name=['act-l12-ev3-100'], **kwargs) + super(ActuonixL12100Motor, self).__init__(address, + name_pattern, + name_exact, + driver_name=['act-l12-ev3-100'], + **kwargs) class DcMotor(Device): - """ The DC motor class provides a uniform interface for using regular DC motors with no fancy controls or feedback. This includes LEGO MINDSTORMS RCX motors @@ -1171,19 +1169,19 @@ class DcMotor(Device): SYSTEM_CLASS_NAME = 'dc-motor' SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' __slots__ = [ - '_address', - '_command', - '_commands', - '_driver_name', - '_duty_cycle', - '_duty_cycle_sp', - '_polarity', - '_ramp_down_sp', - '_ramp_up_sp', - '_state', - '_stop_action', - '_stop_actions', - '_time_sp', + '_address', + '_command', + '_commands', + '_driver_name', + '_duty_cycle', + '_duty_cycle_sp', + '_polarity', + '_ramp_down_sp', + '_ramp_up_sp', + '_state', + '_stop_action', + '_stop_actions', + '_time_sp', ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -1423,7 +1421,6 @@ def stop(self, **kwargs): class ServoMotor(Device): - """ The servo motor class provides a uniform interface for using hobby type servo motors. @@ -1432,16 +1429,16 @@ class ServoMotor(Device): SYSTEM_CLASS_NAME = 'servo-motor' SYSTEM_DEVICE_NAME_CONVENTION = 'motor*' __slots__ = [ - '_address', - '_command', - '_driver_name', - '_max_pulse_sp', - '_mid_pulse_sp', - '_min_pulse_sp', - '_polarity', - '_position_sp', - '_rate_sp', - '_state', + '_address', + '_command', + '_driver_name', + '_max_pulse_sp', + '_mid_pulse_sp', + '_min_pulse_sp', + '_polarity', + '_position_sp', + '_rate_sp', + '_state', ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -1627,7 +1624,6 @@ def float(self, **kwargs): class MotorSet(object): - def __init__(self, motor_specs, desc=None): """ motor_specs is a dictionary such as @@ -1851,11 +1847,10 @@ class MoveTank(MotorSet): # drive in a turn for 10 rotations of the outer motor tank_drive.on_for_rotations(50, 75, 10) """ - def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=LargeMotor): motor_specs = { - left_motor_port : motor_class, - right_motor_port : motor_class, + left_motor_port: motor_class, + right_motor_port: motor_class, } MotorSet.__init__(self, motor_specs, desc) @@ -1867,7 +1862,7 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar @property def cs(self): return self._cs - + @cs.setter def cs(self, cs): self._cs = cs @@ -1876,7 +1871,7 @@ def cs(self, cs): @property def gyro(self): return self._gyro - + @gyro.setter def gyro(self, gyro): self._gyro = gyro @@ -1885,10 +1880,7 @@ def _unpack_speeds_to_native_units(self, left_speed, right_speed): left_speed = self.left_motor._speed_native_units(left_speed, "left_speed") right_speed = self.right_motor._speed_native_units(right_speed, "right_speed") - return ( - left_speed, - right_speed - ) + return (left_speed, right_speed) def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=True): """ @@ -1900,7 +1892,8 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru ``degrees`` while the motor on the inside will have its requested distance calculated according to the expected turn. """ - (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) + (left_speed_native_units, + right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # proof of the following distance calculation: consider the circle formed by each wheel's path # v_l = d_l/t, v_r = d_r/t @@ -1964,7 +1957,8 @@ def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=Tru if seconds < 0: raise ValueError("seconds is negative ({})".format(seconds)) - (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) + (left_speed_native_units, + right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # Set all parameters self.left_motor.speed_sp = int(round(left_speed_native_units)) @@ -1974,8 +1968,7 @@ def on_for_seconds(self, left_speed, right_speed, seconds, brake=True, block=Tru self.right_motor.time_sp = int(seconds * 1000) self.right_motor._set_brake(brake) - log.debug("%s: on_for_seconds %ss at left-speed %s, right-speed %s" % - (self, seconds, left_speed, right_speed)) + log.debug("%s: on_for_seconds %ss at left-speed %s, right-speed %s" % (self, seconds, left_speed, right_speed)) # Start the motors self.left_motor.run_timed() @@ -1989,7 +1982,8 @@ def on(self, left_speed, right_speed): Start rotating the motors according to ``left_speed`` and ``right_speed`` forever. Speeds can be percentages or any SpeedValue implementation. """ - (left_speed_native_units, right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) + (left_speed_native_units, + right_speed_native_units) = self._unpack_speeds_to_native_units(left_speed, right_speed) # Set all parameters self.left_motor.speed_sp = int(round(left_speed_native_units)) @@ -2005,16 +1999,17 @@ def on(self, left_speed, right_speed): self.right_motor.run_forever() def follow_line(self, - kp, ki, kd, - speed, - target_light_intensity=None, - follow_left_edge=True, - white=60, - off_line_count_max=20, - sleep_time=0.01, - follow_for=follow_for_forever, - **kwargs - ): + kp, + ki, + kd, + speed, + target_light_intensity=None, + follow_left_edge=True, + white=60, + off_line_count_max=20, + sleep_time=0.01, + follow_for=follow_for_forever, + **kwargs): """ PID line follower @@ -2097,12 +2092,12 @@ def follow_line(self, right_speed = SpeedNativeUnits(speed_native_units + turn_native_units) if left_speed > MAX_SPEED: - log.info("%s: left_speed %s is greater than MAX_SPEED %s" % (self, left_speed, MAX_SPEED)) + log.info("%s: left_speed %s is greater than MAX_SPEED %s" % (self, left_speed, MAX_SPEED)) self.stop() raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") if right_speed > MAX_SPEED: - log.info("%s: right_speed %s is greater than MAX_SPEED %s" % (self, right_speed, MAX_SPEED)) + log.info("%s: right_speed %s is greater than MAX_SPEED %s" % (self, right_speed, MAX_SPEED)) self.stop() raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") @@ -2137,13 +2132,14 @@ def calibrate_gyro(self): time.sleep(0.5) def follow_gyro_angle(self, - kp, ki, kd, - speed, - target_angle=0, - sleep_time=0.01, - follow_for=follow_for_forever, - **kwargs - ): + kp, + ki, + kd, + speed, + target_angle=0, + sleep_time=0.01, + follow_for=follow_for_forever, + **kwargs): """ PID gyro angle follower @@ -2217,18 +2213,14 @@ def follow_gyro_angle(self, right_speed = SpeedNativeUnits(speed_native_units + turn_native_units) if abs(left_speed) > MAX_SPEED: - log.info("%s: left_speed %s is greater than MAX_SPEED %s" % - (self, left_speed, MAX_SPEED)) + log.info("%s: left_speed %s is greater than MAX_SPEED %s" % (self, left_speed, MAX_SPEED)) self.stop() - raise FollowGyroAngleErrorTooFast( - "The robot is moving too fast to follow the angle") + raise FollowGyroAngleErrorTooFast("The robot is moving too fast to follow the angle") if abs(right_speed) > MAX_SPEED: - log.info("%s: right_speed %s is greater than MAX_SPEED %s" % - (self, right_speed, MAX_SPEED)) + log.info("%s: right_speed %s is greater than MAX_SPEED %s" % (self, right_speed, MAX_SPEED)) self.stop() - raise FollowGyroAngleErrorTooFast( - "The robot is moving too fast to follow the angle") + raise FollowGyroAngleErrorTooFast("The robot is moving too fast to follow the angle") if sleep_time: time.sleep(sleep_time) @@ -2237,12 +2229,7 @@ def follow_gyro_angle(self, self.stop() - def turn_to_angle_gyro(self, - speed, - target_angle=0, - wiggle_room=2, - sleep_time=0.01 - ): + def turn_to_angle_gyro(self, speed, target_angle=0, wiggle_room=2, sleep_time=0.01): """ Pivot Turn @@ -2301,6 +2288,7 @@ def turn_to_angle_gyro(self, self.on(left_speed, right_speed) + class MoveSteering(MoveTank): """ Controls a pair of motors simultaneously, via a single "steering" value and a speed. @@ -2327,7 +2315,8 @@ def on_for_rotations(self, steering, speed, rotations, brake=True, block=True): The distance each motor will travel follows the rules of :meth:`MoveTank.on_for_rotations`. """ (left_speed, right_speed) = self.get_speed_steering(steering, speed) - MoveTank.on_for_rotations(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), rotations, brake, block) + MoveTank.on_for_rotations(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), rotations, brake, + block) def on_for_degrees(self, steering, speed, degrees, brake=True, block=True): """ @@ -2336,14 +2325,16 @@ def on_for_degrees(self, steering, speed, degrees, brake=True, block=True): The distance each motor will travel follows the rules of :meth:`MoveTank.on_for_degrees`. """ (left_speed, right_speed) = self.get_speed_steering(steering, speed) - MoveTank.on_for_degrees(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), degrees, brake, block) + MoveTank.on_for_degrees(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), degrees, brake, + block) def on_for_seconds(self, steering, speed, seconds, brake=True, block=True): """ Rotate the motors according to the provided ``steering`` for ``seconds``. """ (left_speed, right_speed) = self.get_speed_steering(steering, speed) - MoveTank.on_for_seconds(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), seconds, brake, block) + MoveTank.on_for_seconds(self, SpeedNativeUnits(left_speed), SpeedNativeUnits(right_speed), seconds, brake, + block) def on(self, steering, speed): """ @@ -2464,10 +2455,13 @@ class MoveDifferential(MoveTank): # Disable odometry mdiff.odometry_stop() """ - - def __init__(self, left_motor_port, right_motor_port, - wheel_class, wheel_distance_mm, - desc=None, motor_class=LargeMotor): + def __init__(self, + left_motor_port, + right_motor_port, + wheel_class, + wheel_distance_mm, + desc=None, + motor_class=LargeMotor): MoveTank.__init__(self, left_motor_port, right_motor_port, desc, motor_class) self.wheel = wheel_class() @@ -2500,8 +2494,8 @@ def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): """ if radius_mm < self.min_circle_radius_mm: - raise ValueError("{}: radius_mm {} is less than min_circle_radius_mm {}" .format( - self, radius_mm, self.min_circle_radius_mm)) + raise ValueError("{}: radius_mm {} is less than min_circle_radius_mm {}".format( + self, radius_mm, self.min_circle_radius_mm)) # The circle formed at the halfway point between the two wheels is the # circle that must have a radius of radius_mm @@ -2513,20 +2507,18 @@ def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): # The left wheel is making the larger circle and will move at 'speed' # The right wheel is making a smaller circle so its speed will be a fraction of the left motor's speed left_speed = speed - right_speed = float(circle_inner_mm/circle_outer_mm) * left_speed + right_speed = float(circle_inner_mm / circle_outer_mm) * left_speed else: # The right wheel is making the larger circle and will move at 'speed' # The left wheel is making a smaller circle so its speed will be a fraction of the right motor's speed right_speed = speed - left_speed = float(circle_inner_mm/circle_outer_mm) * right_speed + left_speed = float(circle_inner_mm / circle_outer_mm) * right_speed - log.debug("%s: arc %s, radius %s, distance %s, left-speed %s, right-speed %s, circle_outer_mm %s, circle_middle_mm %s, circle_inner_mm %s" % - (self, "right" if arc_right else "left", - radius_mm, distance_mm, left_speed, right_speed, - circle_outer_mm, circle_middle_mm, circle_inner_mm - ) - ) + log.debug( + "%s: arc %s, radius %s, distance %s, left-speed %s, right-speed %s, circle_outer_mm %s, circle_middle_mm %s, circle_inner_mm %s" + % (self, "right" if arc_right else "left", radius_mm, distance_mm, left_speed, right_speed, circle_outer_mm, + circle_middle_mm, circle_inner_mm)) # We know we want the middle circle to be of length distance_mm so # calculate the percentage of circle_middle_mm we must travel for the @@ -2540,12 +2532,10 @@ def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): outer_wheel_rotations = float(circle_outer_final_mm / self.wheel.circumference_mm) outer_wheel_degrees = outer_wheel_rotations * 360 - log.debug("%s: arc %s, circle_middle_percentage %s, circle_outer_final_mm %s, outer_wheel_rotations %s, outer_wheel_degrees %s" % - (self, "right" if arc_right else "left", - circle_middle_percentage, circle_outer_final_mm, - outer_wheel_rotations, outer_wheel_degrees - ) - ) + log.debug( + "%s: arc %s, circle_middle_percentage %s, circle_outer_final_mm %s, outer_wheel_rotations %s, outer_wheel_degrees %s" + % (self, "right" if arc_right else "left", circle_middle_percentage, circle_outer_final_mm, + outer_wheel_rotations, outer_wheel_degrees)) MoveTank.on_for_degrees(self, left_speed, right_speed, outer_wheel_degrees, brake, block) @@ -2571,10 +2561,10 @@ def _turn(self, speed, degrees, brake=True, block=True): distance_mm = (abs(degrees) / 360) * self.circumference_mm # The number of rotations to move distance_mm - rotations = distance_mm/self.wheel.circumference_mm + rotations = distance_mm / self.wheel.circumference_mm log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % - (self, degrees, distance_mm, rotations, degrees)) + (self, degrees, distance_mm, rotations, degrees)) # If degrees is positive rotate clockwise if degrees > 0: @@ -2598,12 +2588,9 @@ def turn_left(self, speed, degrees, brake=True, block=True): self._turn(speed, abs(degrees) * -1, brake, block) def odometry_coordinates_log(self): - log.debug("%s: odometry angle %s at (%d, %d)" % - (self, math.degrees(self.theta), self.x_pos_mm, self.y_pos_mm)) + log.debug("%s: odometry angle %s at (%d, %d)" % (self, math.degrees(self.theta), self.x_pos_mm, self.y_pos_mm)) - def odometry_start(self, theta_degrees_start=90.0, - x_pos_start=0.0, y_pos_start=0.0, - sleep_time=0.005): # 5ms + def odometry_start(self, theta_degrees_start=90.0, x_pos_start=0.0, y_pos_start=0.0, sleep_time=0.005): # 5ms """ Ported from: http://seattlerobotics.org/encoder/200610/Article3/IMU%20Odometry,%20by%20David%20Anderson.htm @@ -2611,7 +2598,6 @@ def odometry_start(self, theta_degrees_start=90.0, A thread is started that will run until the user calls odometry_stop() which will set odometry_thread_run to False """ - def _odometry_monitor(): left_previous = 0 right_previous = 0 @@ -2661,7 +2647,7 @@ def _odometry_monitor(): self.theta += (right_mm - left_mm) / self.wheel_distance_mm # and clip the rotation to plus or minus 360 degrees - self.theta -= float(int(self.theta/TWO_PI) * TWO_PI) + self.theta -= float(int(self.theta / TWO_PI) * TWO_PI) # now calculate and accumulate our position in mm self.x_pos_mm += mm * math.cos(self.theta) @@ -2715,7 +2701,7 @@ def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): turn_right = not turn_right log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % - (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) + (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) self.odometry_coordinates_log() if turn_right: @@ -2750,7 +2736,6 @@ class MoveJoystick(MoveTank): """ Used to control a pair of motors via a single joystick vector. """ - def on(self, x, y, radius=100.0): """ Convert x,y joystick coordinates to left/right motor speed percentages @@ -2792,22 +2777,20 @@ def on(self, x, y, radius=100.0): left_speed_percentage = (init_left_speed_percentage * vector_length) / radius right_speed_percentage = (init_right_speed_percentage * vector_length) / radius - # log.debug(""" - # x, y : %s, %s - # radius : %s - # angle : %s - # vector length : %s - # init left_speed_percentage : %s - # init right_speed_percentage : %s - # final left_speed_percentage : %s - # final right_speed_percentage : %s - # """ % (x, y, radius, angle, vector_length, - # init_left_speed_percentage, init_right_speed_percentage, - # left_speed_percentage, right_speed_percentage)) - - MoveTank.on(self, - SpeedPercent(left_speed_percentage), - SpeedPercent(right_speed_percentage)) + # log.debug(""" + # x, y : %s, %s + # radius : %s + # angle : %s + # vector length : %s + # init left_speed_percentage : %s + # init right_speed_percentage : %s + # final left_speed_percentage : %s + # final right_speed_percentage : %s + # """ % (x, y, radius, angle, vector_length, + # init_left_speed_percentage, init_right_speed_percentage, + # left_speed_percentage, right_speed_percentage)) + + MoveTank.on(self, SpeedPercent(left_speed_percentage), SpeedPercent(right_speed_percentage)) @staticmethod def angle_to_speed_percentage(angle): @@ -2868,7 +2851,7 @@ def angle_to_speed_percentage(angle): left_speed_percentage = 1 # right motor transitions from -1 to 0 - right_speed_percentage = -1 + (angle/45.0) + right_speed_percentage = -1 + (angle / 45.0) elif 45 < angle <= 90: @@ -2958,6 +2941,7 @@ def angle_to_speed_percentage(angle): right_speed_percentage = -1 * percentage_from_315_to_360 else: - raise Exception('You created a circle with more than 360 degrees ({})...that is quite the trick'.format(angle)) + raise Exception( + 'You created a circle with more than 360 degrees ({})...that is quite the trick'.format(angle)) return (left_speed_percentage * 100, right_speed_percentage * 100) diff --git a/ev3dev2/port.py b/ev3dev2/port.py index 4bb5f3a..da62967 100644 --- a/ev3dev2/port.py +++ b/ev3dev2/port.py @@ -26,7 +26,7 @@ import sys from . import Device -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') @@ -62,12 +62,12 @@ class LegoPort(Device): SYSTEM_CLASS_NAME = 'lego-port' SYSTEM_DEVICE_NAME_CONVENTION = '*' __slots__ = [ - '_address', - '_driver_name', - '_modes', - '_mode', - '_set_device', - '_status', + '_address', + '_driver_name', + '_modes', + '_mode', + '_set_device', + '_status', ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): diff --git a/ev3dev2/power.py b/ev3dev2/power.py index 6c3e3a8..81d3201 100644 --- a/ev3dev2/power.py +++ b/ev3dev2/power.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') from ev3dev2 import Device @@ -40,12 +40,12 @@ class PowerSupply(Device): SYSTEM_CLASS_NAME = 'power_supply' SYSTEM_DEVICE_NAME_CONVENTION = '*' __slots__ = [ - '_measured_current', - '_measured_voltage', - '_max_voltage', - '_min_voltage', - '_technology', - '_type', + '_measured_current', + '_measured_voltage', + '_max_voltage', + '_min_voltage', + '_technology', + '_type', ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index ffe2b92..5ec113f 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') import numbers @@ -33,7 +33,6 @@ from struct import unpack from ev3dev2 import get_current_platform, Device, list_device_names - # INPUT ports have platform specific values that we must import platform = get_current_platform() @@ -71,20 +70,8 @@ class Sensor(Device): SYSTEM_CLASS_NAME = 'lego-sensor' SYSTEM_DEVICE_NAME_CONVENTION = 'sensor*' __slots__ = [ - '_address', - '_command', - '_commands', - '_decimals', - '_driver_name', - '_mode', - '_modes', - '_num_values', - '_units', - '_value', - '_bin_data_format', - '_bin_data_size', - '_bin_data', - '_mode_scale' + '_address', '_command', '_commands', '_decimals', '_driver_name', '_mode', '_modes', '_num_values', '_units', + '_value', '_bin_data_format', '_bin_data_size', '_bin_data', '_mode_scale' ] def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -102,7 +89,7 @@ def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, nam self._modes = None self._num_values = None self._units = None - self._value = [None,None,None,None,None,None,None,None] + self._value = [None, None, None, None, None, None, None, None] self._bin_data_format = None self._bin_data_size = None @@ -216,7 +203,7 @@ def value(self, n=0): """ n = int(n) - self._value[n], value = self.get_attr_int(self._value[n], 'value'+str(n)) + self._value[n], value = self.get_attr_int(self._value[n], 'value' + str(n)) return value @property @@ -256,17 +243,17 @@ def bin_data(self, fmt=None): if self._bin_data_size == None: self._bin_data_size = { - "u8": 1, - "s8": 1, - "u16": 2, - "s16": 2, - "s16_be": 2, - "s32": 4, - "float": 4 - }.get(self.bin_data_format, 1) * self.num_values + "u8": 1, + "s8": 1, + "u16": 2, + "s16": 2, + "s16_be": 2, + "s32": 4, + "float": 4 + }.get(self.bin_data_format, 1) * self.num_values if None == self._bin_data: - self._bin_data = self._attribute_file_open( 'bin_data' ) + self._bin_data = self._attribute_file_open('bin_data') self._bin_data.seek(0) raw = bytearray(self._bin_data.read(self._bin_data_size)) @@ -274,11 +261,12 @@ def bin_data(self, fmt=None): if fmt is None: return raw return unpack(fmt, raw) - + def _ensure_mode(self, mode): if self.mode != mode: self.mode = mode + def list_sensors(name_pattern=Sensor.SYSTEM_DEVICE_NAME_CONVENTION, **kwargs): """ This is a generator function that enumerates all sensors that match the diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index b5c7301..1394275 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -25,7 +25,7 @@ import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') import time @@ -43,10 +43,14 @@ class TouchSensor(Sensor): #: Button state MODE_TOUCH = 'TOUCH' - MODES = (MODE_TOUCH,) + MODES = (MODE_TOUCH, ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(TouchSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-touch', 'lego-nxt-touch'], **kwargs) + super(TouchSensor, self).__init__(address, + name_pattern, + name_exact, + driver_name=['lego-ev3-touch', 'lego-nxt-touch'], + **kwargs) @property def is_pressed(self): @@ -65,7 +69,7 @@ def _wait(self, wait_for_press, timeout_ms, sleep_ms): tic = time.time() if sleep_ms: - sleep_ms = float(sleep_ms/1000) + sleep_ms = float(sleep_ms / 1000) # The kernel does not supoort POLLPRI or POLLIN for sensors so we have # to drop into a loop and check often @@ -156,23 +160,17 @@ class ColorSensor(Sensor): #: Brown color. COLOR_BROWN = 7 - MODES = ( - MODE_COL_REFLECT, - MODE_COL_AMBIENT, - MODE_COL_COLOR, - MODE_REF_RAW, - MODE_RGB_RAW - ) + MODES = (MODE_COL_REFLECT, MODE_COL_AMBIENT, MODE_COL_COLOR, MODE_REF_RAW, MODE_RGB_RAW) COLORS = ( - 'NoColor', - 'Black', - 'Blue', - 'Green', - 'Yellow', - 'Red', - 'White', - 'Brown', + 'NoColor', + 'Black', + 'Blue', + 'Green', + 'Yellow', + 'Red', + 'White', + 'Brown', ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -265,9 +263,8 @@ def rgb(self): """ (red, green, blue) = self.raw - return (min(int((red * 255) / self.red_max), 255), - min(int((green * 255) / self.green_max), 255), - min(int((blue * 255) / self.blue_max), 255)) + return (min(int((red * 255) / self.red_max), 255), min(int((green * 255) / self.green_max), + 255), min(int((blue * 255) / self.blue_max), 255)) @property def lab(self): @@ -294,8 +291,8 @@ def lab(self): Y = (RGB[0] * 0.2126729) + (RGB[1] * 0.7151522) + (RGB[2] * 0.0721750) Z = (RGB[0] * 0.0193339) + (RGB[1] * 0.1191920) + (RGB[2] * 0.9503041) - XYZ[0] = X / 95.047 # ref_X = 95.047 - XYZ[1] = Y / 100.0 # ref_Y = 100.000 + XYZ[0] = X / 95.047 # ref_X = 95.047 + XYZ[1] = Y / 100.0 # ref_Y = 100.000 XYZ[2] = Z / 108.883 # ref_Z = 108.883 for (num, value) in enumerate(XYZ): @@ -332,19 +329,19 @@ def hsv(self): if minc == maxc: return 0.0, 0.0, v - s = (maxc-minc) / maxc - rc = (maxc-r) / (maxc-minc) - gc = (maxc-g) / (maxc-minc) - bc = (maxc-b) / (maxc-minc) + s = (maxc - minc) / maxc + rc = (maxc - r) / (maxc - minc) + gc = (maxc - g) / (maxc - minc) + bc = (maxc - b) / (maxc - minc) if r == maxc: - h = bc-gc + h = bc - gc elif g == maxc: - h = 2.0+rc-bc + h = 2.0 + rc - bc else: - h = 4.0+gc-rc + h = 4.0 + gc - rc - h = (h/6.0) % 1.0 + h = (h / 6.0) % 1.0 return (h, s, v) @@ -359,31 +356,31 @@ def hls(self): (r, g, b) = self.rgb maxc = max(r, g, b) minc = min(r, g, b) - l = (minc+maxc)/2.0 + l = (minc + maxc) / 2.0 if minc == maxc: return 0.0, l, 0.0 if l <= 0.5: - s = (maxc-minc) / (maxc+minc) + s = (maxc - minc) / (maxc + minc) else: - if 2.0-maxc-minc == 0: + if 2.0 - maxc - minc == 0: s = 0 else: - s = (maxc-minc) / (2.0-maxc-minc) + s = (maxc - minc) / (2.0 - maxc - minc) - rc = (maxc-r) / (maxc-minc) - gc = (maxc-g) / (maxc-minc) - bc = (maxc-b) / (maxc-minc) + rc = (maxc - r) / (maxc - minc) + gc = (maxc - g) / (maxc - minc) + bc = (maxc - b) / (maxc - minc) if r == maxc: - h = bc-gc + h = bc - gc elif g == maxc: - h = 2.0+rc-bc + h = 2.0 + rc - bc else: - h = 4.0+gc-rc + h = 4.0 + gc - rc - h = (h/6.0) % 1.0 + h = (h / 6.0) % 1.0 return (h, l, s) @@ -444,7 +441,11 @@ class UltrasonicSensor(Sensor): ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): - super(UltrasonicSensor, self).__init__(address, name_pattern, name_exact, driver_name=['lego-ev3-us', 'lego-nxt-us'], **kwargs) + super(UltrasonicSensor, self).__init__(address, + name_pattern, + name_exact, + driver_name=['lego-ev3-us', 'lego-nxt-us'], + **kwargs) @property def distance_centimeters_continuous(self): @@ -684,32 +685,26 @@ class InfraredSensor(Sensor, ButtonBase): #: Calibration ??? MODE_IR_CAL = 'IR-CAL' - MODES = ( - MODE_IR_PROX, - MODE_IR_SEEK, - MODE_IR_REMOTE, - MODE_IR_REM_A, - MODE_IR_CAL - ) + MODES = (MODE_IR_PROX, MODE_IR_SEEK, MODE_IR_REMOTE, MODE_IR_REM_A, MODE_IR_CAL) # The following are all of the various combinations of button presses for # the remote control. The key/index is the number that will be written in # the attribute file to indicate what combination of buttons are currently # pressed. _BUTTON_VALUES = { - 0: [], - 1: ['top_left'], - 2: ['bottom_left'], - 3: ['top_right'], - 4: ['bottom_right'], - 5: ['top_left', 'top_right'], - 6: ['top_left', 'bottom_right'], - 7: ['bottom_left', 'top_right'], - 8: ['bottom_left', 'bottom_right'], - 9: ['beacon'], - 10: ['top_left', 'bottom_left'], - 11: ['top_right', 'bottom_right'] - } + 0: [], + 1: ['top_left'], + 2: ['bottom_left'], + 3: ['top_right'], + 4: ['bottom_right'], + 5: ['top_left', 'top_right'], + 6: ['top_left', 'bottom_right'], + 7: ['bottom_left', 'top_right'], + 8: ['bottom_left', 'bottom_right'], + 9: ['beacon'], + 10: ['top_left', 'bottom_left'], + 11: ['top_right', 'bottom_right'] + } _BUTTONS = ('top_left', 'bottom_left', 'top_right', 'bottom_right', 'beacon') @@ -882,7 +877,7 @@ def bottom_right_channel_4_action(state): new_state = [] state_diff = [] - for channel in range(1,5): + for channel in range(1, 5): for button in self.buttons_pressed(channel): new_state.append((button, channel)) @@ -900,7 +895,7 @@ def bottom_right_channel_4_action(state): self._state = new_state for (button, channel) in state_diff: - handler = getattr(self, 'on_channel' + str(channel) + '_' + button ) + handler = getattr(self, 'on_channel' + str(channel) + '_' + button) if handler is not None: handler((button, channel) in new_state) @@ -924,8 +919,8 @@ class SoundSensor(Sensor): MODE_DBA = 'DBA' MODES = ( - MODE_DB, - MODE_DBA, + MODE_DB, + MODE_DBA, ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): @@ -965,8 +960,8 @@ class LightSensor(Sensor): MODE_AMBIENT = 'AMBIENT' MODES = ( - MODE_REFLECT, - MODE_AMBIENT, + MODE_REFLECT, + MODE_AMBIENT, ) def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 32e7780..533751a 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -48,7 +48,6 @@ def _make_scales(notes): return res - def get_command_processes(command): """ :param string command: a string of command(s) to run that may include pipes @@ -105,15 +104,11 @@ class Sound(object): channel = None # play_types - PLAY_WAIT_FOR_COMPLETE = 0 #: Play the sound and block until it is complete - PLAY_NO_WAIT_FOR_COMPLETE = 1 #: Start playing the sound but return immediately - PLAY_LOOP = 2 #: Never return; start the sound immediately after it completes, until the program is killed + PLAY_WAIT_FOR_COMPLETE = 0 #: Play the sound and block until it is complete + PLAY_NO_WAIT_FOR_COMPLETE = 1 #: Start playing the sound but return immediately + PLAY_LOOP = 2 #: Never return; start the sound immediately after it completes, until the program is killed - PLAY_TYPES = ( - PLAY_WAIT_FOR_COMPLETE, - PLAY_NO_WAIT_FOR_COMPLETE, - PLAY_LOOP - ) + PLAY_TYPES = (PLAY_WAIT_FOR_COMPLETE, PLAY_NO_WAIT_FOR_COMPLETE, PLAY_LOOP) def _validate_play_type(self, play_type): assert play_type in self.PLAY_TYPES, \ @@ -233,9 +228,9 @@ def beep_args(frequency=None, duration=None, delay=None): args = '' if frequency is not None: args += '-f %s ' % frequency - if duration is not None: + if duration is not None: args += '-l %s ' % duration - if delay is not None: + if delay is not None: args += '-D %s ' % delay return args @@ -249,8 +244,7 @@ def beep_args(frequency=None, duration=None, delay=None): else: raise Exception("Unsupported number of parameters in Sound.tone(): expected 1 or 2, got " + str(len(args))) - def play_tone(self, frequency, duration, delay=0.0, volume=100, - play_type=PLAY_WAIT_FOR_COMPLETE): + def play_tone(self, frequency, duration, delay=0.0, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): """ Play a single tone, specified by its frequency, duration, volume and final delay. :param int frequency: the tone frequency, in Hertz @@ -477,7 +471,7 @@ def play_song(self, song, tempo=120, delay=0.05): raise ValueError('invalid delay (%s)' % delay) delay_ms = int(delay * 1000) - meas_duration_ms = 60000 / tempo * 4 # we only support 4/4 bars, hence "* 4" + meas_duration_ms = 60000 / tempo * 4 # we only support 4/4 bars, hence "* 4" for (note, value) in song: value = value.lower() @@ -496,7 +490,7 @@ def play_song(self, song, tempo=120, delay=0.05): elif value.endswith('3'): base = value[:-1] - factor = float(2/3) + factor = float(2 / 3) else: base = value @@ -523,7 +517,7 @@ def play_song(self, song, tempo=120, delay=0.05): ('C0', 16.35), ('C#0/Db0', 17.32), ('D0', 18.35), - ('D#0/Eb0', 19.45), # expanded in one entry per symbol by _make_scales + ('D#0/Eb0', 19.45), # expanded in one entry per symbol by _make_scales ('E0', 20.60), ('F0', 21.83), ('F#0/Gb0', 23.12), @@ -627,8 +621,7 @@ def play_song(self, song, tempo=120, delay=0.05): ('G#8/Ab8', 6644.88), ('A8', 7040.00), ('A#8/Bb8', 7458.62), - ('B8', 7902.13) - )) + ('B8', 7902.13))) #: Common note values. #: @@ -652,8 +645,8 @@ def play_song(self, song, tempo=120, delay=0.05): #: :py:meth:`Sound.play_song` method. _NOTE_VALUES = { 'w': 1., - 'h': 1./2, - 'q': 1./4, - 'e': 1./8, - 's': 1./16, + 'h': 1. / 2, + 'q': 1. / 4, + 'e': 1. / 8, + 's': 1. / 16, } diff --git a/ev3dev2/stopwatch.py b/ev3dev2/stopwatch.py index 35b804b..60fba91 100644 --- a/ev3dev2/stopwatch.py +++ b/ev3dev2/stopwatch.py @@ -9,12 +9,14 @@ else: import datetime as dt + def get_ticks_ms(): if is_micropython(): return utime.ticks_ms() else: return int(dt.datetime.timestamp(dt.datetime.now()) * 1000) + class StopWatchAlreadyStartedException(Exception): """ Exception raised when start() is called on a StopWatch which was already start()ed and not yet @@ -22,6 +24,7 @@ class StopWatchAlreadyStartedException(Exception): """ pass + class StopWatch(object): """ A timer class which lets you start timing and then check the amount of time @@ -70,14 +73,14 @@ def reset(self): """ self._start_time = None self._stopped_total_time = None - + def restart(self): """ Resets and then starts the timer. """ self.reset() self.start() - + @property def is_started(self): """ @@ -136,4 +139,3 @@ def is_elapsed_secs(self, duration_secs): """ return duration_secs is not None and self.value_secs >= duration_secs - diff --git a/ev3dev2/unit.py b/ev3dev2/unit.py index 6d90849..d7bbf0f 100644 --- a/ev3dev2/unit.py +++ b/ev3dev2/unit.py @@ -20,10 +20,9 @@ # THE SOFTWARE. # ----------------------------------------------------------------------------- - import sys -if sys.version_info < (3,4): +if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') CENTIMETER_MM = 10 @@ -54,7 +53,6 @@ class DistanceMillimeters(DistanceValue): """ Distance in millimeters """ - def __init__(self, millimeters): self.millimeters = millimeters @@ -74,7 +72,6 @@ class DistanceCentimeters(DistanceValue): """ Distance in centimeters """ - def __init__(self, centimeters): self.centimeters = centimeters @@ -94,7 +91,6 @@ class DistanceDecimeters(DistanceValue): """ Distance in decimeters """ - def __init__(self, decimeters): self.decimeters = decimeters @@ -114,7 +110,6 @@ class DistanceMeters(DistanceValue): """ Distance in meters """ - def __init__(self, meters): self.meters = meters @@ -134,7 +129,6 @@ class DistanceInches(DistanceValue): """ Distance in inches """ - def __init__(self, inches): self.inches = inches @@ -154,7 +148,6 @@ class DistanceFeet(DistanceValue): """ Distance in feet """ - def __init__(self, feet): self.feet = feet @@ -174,7 +167,6 @@ class DistanceYards(DistanceValue): """ Distance in yards """ - def __init__(self, yards): self.yards = yards @@ -194,7 +186,6 @@ class DistanceStuds(DistanceValue): """ Distance in studs """ - def __init__(self, studs): self.studs = studs diff --git a/ev3dev2/wheel.py b/ev3dev2/wheel.py index 0f01828..16ccf43 100644 --- a/ev3dev2/wheel.py +++ b/ev3dev2/wheel.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - """ Wheel and Rim classes @@ -28,7 +27,6 @@ class Wheel(object): # calculate the number of rotations needed to travel forward 500 mm rotations_for_500mm = 500 / tire.circumference_mm """ - def __init__(self, diameter_mm, width_mm): self.diameter_mm = float(diameter_mm) self.width_mm = float(width_mm) @@ -53,7 +51,6 @@ class EV3Tire(Wheel): part number 44309 comes in set 31313 """ - def __init__(self): Wheel.__init__(self, 43.2, 21) @@ -63,7 +60,6 @@ class EV3EducationSetRim(Wheel): part number 56908 comes in set 45544 """ - def __init__(self): Wheel.__init__(self, 43, 26) @@ -73,6 +69,5 @@ class EV3EducationSetTire(Wheel): part number 41897 comes in set 45544 """ - def __init__(self): Wheel.__init__(self, 56, 28) diff --git a/git_version.py b/git_version.py index 4ca6b7d..0d77547 100644 --- a/git_version.py +++ b/git_version.py @@ -3,6 +3,7 @@ pyver = sys.version_info + #---------------------------------------------------------------------------- # Get version string from git # @@ -14,8 +15,7 @@ #---------------------------------------------------------------------------- def call_git_describe(abbrev=4): try: - p = Popen(['git', 'describe', '--exclude', 'ev3dev-*', '--abbrev=%d' % abbrev], - stdout=PIPE, stderr=PIPE) + p = Popen(['git', 'describe', '--exclude', 'ev3dev-*', '--abbrev=%d' % abbrev], stdout=PIPE, stderr=PIPE) p.stderr.close() line = p.stdout.readlines()[0] return line.strip().decode('utf8') @@ -42,7 +42,7 @@ def pep386adapt(version): # adapt git-describe version to be in line with PEP 386 parts = version.split('-') if len(parts) > 1: - parts[-2] = 'post'+parts[-2] + parts[-2] = 'post' + parts[-2] version = '.'.join(parts[:-1]) return version @@ -77,4 +77,3 @@ def git_version(abbrev=4): # Finally, return the current version. return version - diff --git a/setup.py b/setup.py index 9c697d6..6decb29 100644 --- a/setup.py +++ b/setup.py @@ -6,22 +6,16 @@ with open(path.join(this_directory, 'README.rst'), encoding='utf-8') as f: long_description = f.read() -setup( - name='python-ev3dev2', - version=git_version(), - description='v2.x Python language bindings for ev3dev', - author='ev3dev Python team', - author_email='python-team@ev3dev.org', - license='MIT', - url='https://github.com/ev3dev/ev3dev-lang-python', - include_package_data=True, - long_description=long_description, - long_description_content_type='text/x-rst', - packages=['ev3dev2', - 'ev3dev2.fonts', - 'ev3dev2.sensor', - 'ev3dev2.control', - 'ev3dev2._platform'], - package_data={'': ['*.pil', '*.pbm']}, - install_requires=['Pillow'] - ) +setup(name='python-ev3dev2', + version=git_version(), + description='v2.x Python language bindings for ev3dev', + author='ev3dev Python team', + author_email='python-team@ev3dev.org', + license='MIT', + url='https://github.com/ev3dev/ev3dev-lang-python', + include_package_data=True, + long_description=long_description, + long_description_content_type='text/x-rst', + packages=['ev3dev2', 'ev3dev2.fonts', 'ev3dev2.sensor', 'ev3dev2.control', 'ev3dev2._platform'], + package_data={'': ['*.pil', '*.pbm']}, + install_requires=['Pillow']) diff --git a/tests/api_tests.py b/tests/api_tests.py index 527b226..26b1fff 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -9,7 +9,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from populate_arena import populate_arena -from clean_arena import clean_arena +from clean_arena import clean_arena import ev3dev2 from ev3dev2.sensor.lego import InfraredSensor @@ -19,16 +19,8 @@ MoveTank, MoveSteering, MoveJoystick, \ SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits -from ev3dev2.unit import ( - DistanceMillimeters, - DistanceCentimeters, - DistanceDecimeters, - DistanceMeters, - DistanceInches, - DistanceFeet, - DistanceYards, - DistanceStuds -) +from ev3dev2.unit import (DistanceMillimeters, DistanceCentimeters, DistanceDecimeters, DistanceMeters, DistanceInches, + DistanceFeet, DistanceYards, DistanceStuds) import ev3dev2.stopwatch from ev3dev2.stopwatch import StopWatch, StopWatchAlreadyStartedException @@ -36,6 +28,8 @@ ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') _internal_set_attribute = ev3dev2.Device._set_attribute + + def _set_attribute(self, attribute, name, value): # Follow the text with a newline to separate new content from stuff that # already existed in the buffer. On the real device we're writing to sysfs @@ -44,36 +38,49 @@ def _set_attribute(self, attribute, name, value): attribute = _internal_set_attribute(self, attribute, name, value) attribute.write(b'\n') return attribute + + ev3dev2.Device._set_attribute = _set_attribute _internal_get_attribute = ev3dev2.Device._get_attribute + + def _get_attribute(self, attribute, name): # Split on newline delimiter; see _set_attribute above attribute, value = _internal_get_attribute(self, attribute, name) return attribute, value.split('\n', 1)[0] + + ev3dev2.Device._get_attribute = _get_attribute + def dummy_wait(self, cond, timeout=None): pass + Motor.wait = dummy_wait # for StopWatch mock_ticks_ms = 0 + + def _mock_get_ticks_ms(): return mock_ticks_ms + + ev3dev2.stopwatch.get_ticks_ms = _mock_get_ticks_ms + def set_mock_ticks_ms(value): global mock_ticks_ms mock_ticks_ms = value -class TestAPI(unittest.TestCase): +class TestAPI(unittest.TestCase): def setUp(self): # micropython does not have _testMethodName try: - print("\n\n{}\n{}".format(self._testMethodName, "=" * len(self._testMethodName,))) + print("\n\n{}\n{}".format(self._testMethodName, "=" * len(self._testMethodName, ))) except AttributeError: pass @@ -121,21 +128,22 @@ def dummy(self): self.assertEqual(m.driver_name, 'lego-ev3-m-motor') self.assertEqual(m.driver_name, 'lego-ev3-m-motor') - self.assertEqual(m.count_per_rot, 360) - self.assertEqual(m.commands, ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset']) - self.assertEqual(m.duty_cycle, 0) - self.assertEqual(m.duty_cycle_sp, 42) - self.assertEqual(m.polarity, 'normal') - self.assertEqual(m.address, 'outA') - self.assertEqual(m.position, 42) - self.assertEqual(m.position_sp, 42) - self.assertEqual(m.ramp_down_sp, 0) - self.assertEqual(m.ramp_up_sp, 0) - self.assertEqual(m.speed, 0) - self.assertEqual(m.speed_sp, 0) - self.assertEqual(m.state, ['running']) - self.assertEqual(m.stop_action, 'coast') - self.assertEqual(m.time_sp, 1000) + self.assertEqual(m.count_per_rot, 360) + self.assertEqual( + m.commands, ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset']) + self.assertEqual(m.duty_cycle, 0) + self.assertEqual(m.duty_cycle_sp, 42) + self.assertEqual(m.polarity, 'normal') + self.assertEqual(m.address, 'outA') + self.assertEqual(m.position, 42) + self.assertEqual(m.position_sp, 42) + self.assertEqual(m.ramp_down_sp, 0) + self.assertEqual(m.ramp_up_sp, 0) + self.assertEqual(m.speed, 0) + self.assertEqual(m.speed_sp, 0) + self.assertEqual(m.state, ['running']) + self.assertEqual(m.stop_action, 'coast') + self.assertEqual(m.time_sp, 1000) with self.assertRaises(Exception): c = m.command @@ -146,24 +154,24 @@ def test_infrared_sensor(self): s = InfraredSensor() - self.assertEqual(s.device_index, 0) + self.assertEqual(s.device_index, 0) self.assertEqual(s.bin_data_format, 's8') - self.assertEqual(s.bin_data(' toc - time.time(): toc += self.interval @@ -42,38 +43,39 @@ def join(self, timeout=None): super(LogThread, self).join(timeout) -test = json.loads( open( args.infile ).read() ) +test = json.loads(open(args.infile).read()) + def execute_actions(actions): - for p,c in actions['ports'].items(): + for p, c in actions['ports'].items(): for b in c: - for k,v in b.items(): - setattr( device[p], k, v ) - + for k, v in b.items(): + setattr(device[p], k, v) + + device = {} logs = {} -for p,v in test['meta']['ports'].items(): - device[p] = getattr( ev3, v['device_class'] )( p ) +for p, v in test['meta']['ports'].items(): + device[p] = getattr(ev3, v['device_class'])(p) if test['actions'][0]['time'] < 0: execute_actions(test['actions'][0]) -for p,v in test['meta']['ports'].items(): - device[p] = getattr( ev3, v['device_class'] )( p ) +for p, v in test['meta']['ports'].items(): + device[p] = getattr(ev3, v['device_class'])(p) - logs[p] = LogThread(test['meta']['interval'] * 1e-3, - device[p], - v['log_attributes'] ) + logs[p] = LogThread(test['meta']['interval'] * 1e-3, device[p], v['log_attributes']) logs[p].start() start = time.time() -end = start + test['meta']['max_time'] * 1e-3 +end = start + test['meta']['max_time'] * 1e-3 for a in test['actions']: if a['time'] >= 0: then = start + a['time'] * 1e-3 - while time.time() < then: pass + while time.time() < then: + pass execute_actions(a) while time.time() < end: @@ -81,9 +83,9 @@ def execute_actions(actions): test['data'] = {} -for p,v in test['meta']['ports'].items(): - logs[p].join() - test['data'][p] = logs[p].results +for p, v in test['meta']['ports'].items(): + logs[p].join() + test['data'][p] = logs[p].results # Add a nice JSON formatter here - maybe? -print (json.dumps( test, indent = 4 )) +print(json.dumps(test, indent=4)) diff --git a/tests/motor/motor_info.py b/tests/motor/motor_info.py index 82b69e2..6c9bacc 100644 --- a/tests/motor/motor_info.py +++ b/tests/motor/motor_info.py @@ -1,56 +1,64 @@ motor_info = { - 'lego-ev3-l-motor': {'motion_type': 'rotation', - 'count_per_rot': 360, - 'max_speed': 1050, - 'position_p': 80000, - 'position_i': 0, - 'position_d': 0, - 'polarity': 'normal', - 'speed_p': 1000, - 'speed_i': 60, - 'speed_d': 0 }, - 'lego-ev3-m-motor': {'motion_type': 'rotation', - 'count_per_rot': 360, - 'max_speed': 1560, - 'position_p': 160000, - 'position_i': 0, - 'position_d': 0, - 'polarity': 'normal', - 'speed_p': 1000, - 'speed_i': 60, - 'speed_d': 0 }, - 'lego-nxt-motor': {'motion_type': 'rotation', - 'count_per_rot': 360, - 'max_speed': 1020, - 'position_p': 80000, - 'position_i': 0, - 'position_d': 0, - 'polarity': 'normal', - 'speed_p': 1000, - 'speed_i': 60, - 'speed_d': 0 }, - 'fi-l12-ev3-50': {'motion_type': 'linear', - 'count_per_m': 2000, - 'full_travel_count': 100, - 'max_speed': 24, - 'position_p': 40000, - 'position_i': 0, - 'position_d': 0, - 'polarity': 'normal', - 'speed_p': 1000, - 'speed_i': 60, - 'speed_d': 0, - }, - 'fi-l12-ev3-100': {'motion_type': 'linear', - 'count_per_m': 2000, - 'full_travel_count': 200, - 'max_speed': 24, - 'position_p': 40000, - 'position_i': 0, - 'position_d': 0, - 'polarity': 'normal', - 'speed_p': 1000, - 'speed_i': 60, - 'speed_d': 0, - } + 'lego-ev3-l-motor': { + 'motion_type': 'rotation', + 'count_per_rot': 360, + 'max_speed': 1050, + 'position_p': 80000, + 'position_i': 0, + 'position_d': 0, + 'polarity': 'normal', + 'speed_p': 1000, + 'speed_i': 60, + 'speed_d': 0 + }, + 'lego-ev3-m-motor': { + 'motion_type': 'rotation', + 'count_per_rot': 360, + 'max_speed': 1560, + 'position_p': 160000, + 'position_i': 0, + 'position_d': 0, + 'polarity': 'normal', + 'speed_p': 1000, + 'speed_i': 60, + 'speed_d': 0 + }, + 'lego-nxt-motor': { + 'motion_type': 'rotation', + 'count_per_rot': 360, + 'max_speed': 1020, + 'position_p': 80000, + 'position_i': 0, + 'position_d': 0, + 'polarity': 'normal', + 'speed_p': 1000, + 'speed_i': 60, + 'speed_d': 0 + }, + 'fi-l12-ev3-50': { + 'motion_type': 'linear', + 'count_per_m': 2000, + 'full_travel_count': 100, + 'max_speed': 24, + 'position_p': 40000, + 'position_i': 0, + 'position_d': 0, + 'polarity': 'normal', + 'speed_p': 1000, + 'speed_i': 60, + 'speed_d': 0, + }, + 'fi-l12-ev3-100': { + 'motion_type': 'linear', + 'count_per_m': 2000, + 'full_travel_count': 200, + 'max_speed': 24, + 'position_p': 40000, + 'position_i': 0, + 'position_d': 0, + 'polarity': 'normal', + 'speed_p': 1000, + 'speed_i': 60, + 'speed_d': 0, + } } diff --git a/tests/motor/motor_motion_unittest.py b/tests/motor/motor_motion_unittest.py index 8bfac60..be6b7f8 100644 --- a/tests/motor/motor_motion_unittest.py +++ b/tests/motor/motor_motion_unittest.py @@ -12,8 +12,8 @@ from motor_info import motor_info -class TestMotorMotion(ptc.ParameterizedTestCase): +class TestMotorMotion(ptc.ParameterizedTestCase): @classmethod def setUpClass(cls): pass @@ -25,7 +25,7 @@ def tearDownClass(cls): def initialize_motor(self): self._param['motor'].command = 'reset' - def run_to_positions(self,stop_action,command,speed_sp,positions,tolerance): + def run_to_positions(self, stop_action, command, speed_sp, positions, tolerance): self._param['motor'].stop_action = stop_action self._param['motor'].speed_sp = speed_sp @@ -37,11 +37,11 @@ def run_to_positions(self,stop_action,command,speed_sp,positions,tolerance): target += i else: target = i - print( "PRE position = {0} i = {1} target = {2}".format(self._param['motor'].position, i, target)) + print("PRE position = {0} i = {1} target = {2}".format(self._param['motor'].position, i, target)) self._param['motor'].command = command while 'running' in self._param['motor'].state: pass - print( "POS position = {0} i = {1} target = {2}".format(self._param['motor'].position, i, target)) + print("POS position = {0} i = {1} target = {2}".format(self._param['motor'].position, i, target)) self.assertGreaterEqual(tolerance, abs(self._param['motor'].position - target)) time.sleep(0.2) @@ -51,67 +51,76 @@ def test_stop_brake_no_ramp_med_speed_relative(self): if not self._param['has_brake']: self.skipTest('brake not supported by this motor controller') self.initialize_motor() - self.run_to_positions('brake','run-to-rel-pos',400,[0,90,180,360,720,-720,-360,-180,-90,0],20) + self.run_to_positions('brake', 'run-to-rel-pos', 400, [0, 90, 180, 360, 720, -720, -360, -180, -90, 0], 20) def test_stop_hold_no_ramp_med_speed_relative(self): self.initialize_motor() - self.run_to_positions('hold','run-to-rel-pos',400,[0,90,180,360,720,-720,-360,-180,-90,0],5) + self.run_to_positions('hold', 'run-to-rel-pos', 400, [0, 90, 180, 360, 720, -720, -360, -180, -90, 0], 5) def test_stop_brake_no_ramp_low_speed_relative(self): if not self._param['has_brake']: self.skipTest('brake not supported by this motor controller') self.initialize_motor() - self.run_to_positions('brake','run-to-rel-pos',100,[0,90,180,360,720,-720,-360,-180,-90,0],20) + self.run_to_positions('brake', 'run-to-rel-pos', 100, [0, 90, 180, 360, 720, -720, -360, -180, -90, 0], 20) def test_stop_hold_no_ramp_low_speed_relative(self): self.initialize_motor() - self.run_to_positions('hold','run-to-rel-pos',100,[0,90,180,360,720,-720,-360,-180,-90,0],5) + self.run_to_positions('hold', 'run-to-rel-pos', 100, [0, 90, 180, 360, 720, -720, -360, -180, -90, 0], 5) def test_stop_brake_no_ramp_high_speed_relative(self): if not self._param['has_brake']: self.skipTest('brake not supported by this motor controller') self.initialize_motor() - self.run_to_positions('brake','run-to-rel-pos',900,[0,90,180,360,720,-720,-360,-180,-90,0],50) + self.run_to_positions('brake', 'run-to-rel-pos', 900, [0, 90, 180, 360, 720, -720, -360, -180, -90, 0], 50) def test_stop_hold_no_ramp_high_speed_relative(self): self.initialize_motor() - self.run_to_positions('hold','run-to-rel-pos',100,[0,90,180,360,720,-720,-360,-180,-90,0],5) + self.run_to_positions('hold', 'run-to-rel-pos', 100, [0, 90, 180, 360, 720, -720, -360, -180, -90, 0], 5) def test_stop_brake_no_ramp_med_speed_absolute(self): if not self._param['has_brake']: self.skipTest('brake not supported by this motor controller') self.initialize_motor() - self.run_to_positions('brake','run-to-abs-pos',400,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],20) + self.run_to_positions('brake', 'run-to-abs-pos', 400, + [0, 90, 180, 360, 180, 90, 0, -90, -180, -360, -180, -90, 0], 20) def test_stop_hold_no_ramp_med_speed_absolute(self): self.initialize_motor() - self.run_to_positions('hold','run-to-abs-pos',400,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],5) + self.run_to_positions('hold', 'run-to-abs-pos', 400, + [0, 90, 180, 360, 180, 90, 0, -90, -180, -360, -180, -90, 0], 5) def test_stop_brake_no_ramp_low_speed_absolute(self): if not self._param['has_brake']: self.skipTest('brake not supported by this motor controller') self.initialize_motor() - self.run_to_positions('brake','run-to-abs-pos',100,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],20) + self.run_to_positions('brake', 'run-to-abs-pos', 100, + [0, 90, 180, 360, 180, 90, 0, -90, -180, -360, -180, -90, 0], 20) def test_stop_hold_no_ramp_low_speed_absolute(self): self.initialize_motor() - self.run_to_positions('hold','run-to-abs-pos',100,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],5) + self.run_to_positions('hold', 'run-to-abs-pos', 100, + [0, 90, 180, 360, 180, 90, 0, -90, -180, -360, -180, -90, 0], 5) def test_stop_brake_no_ramp_high_speed_absolute(self): if not self._param['has_brake']: self.skipTest('brake not supported by this motor controller') self.initialize_motor() - self.run_to_positions('brake','run-to-abs-pos',900,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],50) + self.run_to_positions('brake', 'run-to-abs-pos', 900, + [0, 90, 180, 360, 180, 90, 0, -90, -180, -360, -180, -90, 0], 50) def test_stop_hold_no_ramp_high_speed_absolute(self): self.initialize_motor() - self.run_to_positions('hold','run-to-abs-pos',100,[0,90,180,360,180,90,0,-90,-180,-360,-180,-90,0],5) + self.run_to_positions('hold', 'run-to-abs-pos', 100, + [0, 90, 180, 360, 180, 90, 0, -90, -180, -360, -180, -90, 0], 5) + # Add all the tests to the suite - some tests apply only to certain drivers! + def AddTachoMotorMotionTestsToSuite(suite, params): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestMotorMotion, param=params)) + if __name__ == '__main__': ev3_params = { 'motor': ev3.Motor('outA'), @@ -136,4 +145,4 @@ def AddTachoMotorMotionTestsToSuite(suite, params): AddTachoMotorMotionTestsToSuite(suite, ev3_params) - unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) + unittest.TextTestRunner(verbosity=1, buffer=True).run(suite) diff --git a/tests/motor/motor_param_unittest.py b/tests/motor/motor_param_unittest.py index ad2c3b4..d10f84e 100644 --- a/tests/motor/motor_param_unittest.py +++ b/tests/motor/motor_param_unittest.py @@ -13,8 +13,8 @@ from motor_info import motor_info -class TestTachoMotorAddressValue(ptc.ParameterizedTestCase): +class TestTachoMotorAddressValue(ptc.ParameterizedTestCase): def test_address_value(self): self.assertEqual(self._param['motor'].address, self._param['port']) @@ -22,8 +22,8 @@ def test_address_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].address = "ThisShouldNotWork" -class TestTachoMotorCommandsValue(ptc.ParameterizedTestCase): +class TestTachoMotorCommandsValue(ptc.ParameterizedTestCase): def test_commands_value(self): self.assertTrue(self._param['motor'].commands == self._param['commands']) @@ -31,17 +31,18 @@ def test_commands_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].commands = "ThisShouldNotWork" -class TestTachoMotorCountPerRotValue(ptc.ParameterizedTestCase): +class TestTachoMotorCountPerRotValue(ptc.ParameterizedTestCase): def test_count_per_rot_value(self): - self.assertEqual(self._param['motor'].count_per_rot, motor_info[self._param['motor'].driver_name]['count_per_rot']) + self.assertEqual(self._param['motor'].count_per_rot, + motor_info[self._param['motor'].driver_name]['count_per_rot']) def test_count_per_rot_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].count_per_rot = "ThisShouldNotWork" -class TestTachoMotorCountPerMValue(ptc.ParameterizedTestCase): +class TestTachoMotorCountPerMValue(ptc.ParameterizedTestCase): def test_count_per_m_value(self): self.assertEqual(self._param['motor'].count_per_m, motor_info[self._param['motor'].driver_name]['count_per_m']) @@ -49,17 +50,18 @@ def test_count_per_m_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].count_per_m = "ThisShouldNotWork" -class TestTachoMotorFullTravelCountValue(ptc.ParameterizedTestCase): +class TestTachoMotorFullTravelCountValue(ptc.ParameterizedTestCase): def test_full_travel_count_value(self): - self.assertEqual(self._param['motor'].full_travel_count, motor_info[self._param['motor'].driver_name]['full_travel_count']) + self.assertEqual(self._param['motor'].full_travel_count, + motor_info[self._param['motor'].driver_name]['full_travel_count']) def test_full_travel_count_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].count_per_m = "ThisShouldNotWork" -class TestTachoMotorDriverNameValue(ptc.ParameterizedTestCase): +class TestTachoMotorDriverNameValue(ptc.ParameterizedTestCase): def test_driver_name_value(self): self.assertEqual(self._param['motor'].driver_name, self._param['driver_name']) @@ -67,8 +69,8 @@ def test_driver_name_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].driver_name = "ThisShouldNotWork" -class TestTachoMotorDutyCycleValue(ptc.ParameterizedTestCase): +class TestTachoMotorDutyCycleValue(ptc.ParameterizedTestCase): def test_duty_cycle_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].duty_cycle = "ThisShouldNotWork" @@ -77,8 +79,8 @@ def test_duty_cycle_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].duty_cycle, 0) -class TestTachoMotorDutyCycleSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorDutyCycleSpValue(ptc.ParameterizedTestCase): def test_duty_cycle_sp_large_negative(self): with self.assertRaises(IOError): self._param['motor'].duty_cycle_sp = -101 @@ -115,7 +117,6 @@ def test_duty_cycle_sp_after_reset(self): class TestTachoMotorMaxSpeedValue(ptc.ParameterizedTestCase): - def test_max_speed_value(self): self.assertEqual(self._param['motor'].max_speed, motor_info[self._param['motor'].driver_name]['max_speed']) @@ -123,8 +124,8 @@ def test_max_speed_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].max_speed = "ThisShouldNotWork" -class TestTachoMotorPositionPValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionPValue(ptc.ParameterizedTestCase): def test_position_p_negative(self): with self.assertRaises(IOError): self._param['motor'].position_p = -1 @@ -144,11 +145,11 @@ def test_position_p_after_reset(self): if self._param['hold_pid']: expected = self._param['hold_pid']['kP'] else: - expected = motor_info[self._param['motor'].driver_name]['position_p'] + expected = motor_info[self._param['motor'].driver_name]['position_p'] self.assertEqual(self._param['motor'].position_p, expected) -class TestTachoMotorPositionIValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionIValue(ptc.ParameterizedTestCase): def test_position_i_negative(self): with self.assertRaises(IOError): self._param['motor'].position_i = -1 @@ -168,11 +169,11 @@ def test_position_i_after_reset(self): if self._param['hold_pid']: expected = self._param['hold_pid']['kI'] else: - expected = motor_info[self._param['motor'].driver_name]['position_i'] + expected = motor_info[self._param['motor'].driver_name]['position_i'] self.assertEqual(self._param['motor'].position_i, expected) -class TestTachoMotorPositionDValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionDValue(ptc.ParameterizedTestCase): def test_position_d_negative(self): with self.assertRaises(IOError): self._param['motor'].position_d = -1 @@ -192,11 +193,11 @@ def test_position_d_after_reset(self): if self._param['hold_pid']: expected = self._param['hold_pid']['kD'] else: - expected = motor_info[self._param['motor'].driver_name]['position_d'] + expected = motor_info[self._param['motor'].driver_name]['position_d'] self.assertEqual(self._param['motor'].position_d, expected) -class TestTachoMotorPolarityValue(ptc.ParameterizedTestCase): +class TestTachoMotorPolarityValue(ptc.ParameterizedTestCase): def test_polarity_normal_value(self): self._param['motor'].polarity = 'normal' self.assertEqual(self._param['motor'].polarity, 'normal') @@ -221,8 +222,8 @@ def test_polarity_after_reset(self): else: self.assertEqual(self._param['motor'].polarity, 'inversed') -class TestTachoMotorPositionValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionValue(ptc.ParameterizedTestCase): def test_position_large_negative(self): self._param['motor'].position = -1000000 self.assertEqual(self._param['motor'].position, -1000000) @@ -249,8 +250,8 @@ def test_position_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].position, 0) -class TestTachoMotorPositionSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionSpValue(ptc.ParameterizedTestCase): def test_position_sp_large_negative(self): self._param['motor'].position_sp = -1000000 self.assertEqual(self._param['motor'].position_sp, -1000000) @@ -277,8 +278,8 @@ def test_position_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].position_sp, 0) -class TestTachoMotorRampDownSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorRampDownSpValue(ptc.ParameterizedTestCase): def test_ramp_down_sp_negative_value(self): with self.assertRaises(IOError): self._param['motor'].ramp_down_sp = -1 @@ -305,8 +306,8 @@ def test_ramp_down_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_down_sp, 0) -class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): def test_ramp_up_negative_value(self): with self.assertRaises(IOError): self._param['motor'].ramp_up_sp = -1 @@ -333,8 +334,8 @@ def test_ramp_up_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_up_sp, 0) -class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): +class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): def test_speed_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].speed = 1 @@ -343,44 +344,44 @@ def test_speed_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed, 0) -class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): - def test_speed_sp_large_negative(self): +class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): + def test_speed_sp_large_negative(self): with self.assertRaises(IOError): - self._param['motor'].speed_sp = -(motor_info[self._param['motor'].driver_name]['max_speed']+1) + self._param['motor'].speed_sp = -(motor_info[self._param['motor'].driver_name]['max_speed'] + 1) - def test_speed_sp_max_negative(self): + def test_speed_sp_max_negative(self): self._param['motor'].speed_sp = -motor_info[self._param['motor'].driver_name]['max_speed'] self.assertEqual(self._param['motor'].speed_sp, -motor_info[self._param['motor'].driver_name]['max_speed']) - def test_speed_sp_min_negative(self): + def test_speed_sp_min_negative(self): self._param['motor'].speed_sp = -1 self.assertEqual(self._param['motor'].speed_sp, -1) - def test_speed_sp_zero(self): + def test_speed_sp_zero(self): self._param['motor'].speed_sp = 0 self.assertEqual(self._param['motor'].speed_sp, 0) - def test_speed_sp_min_positive(self): + def test_speed_sp_min_positive(self): self._param['motor'].speed_sp = 1 self.assertEqual(self._param['motor'].speed_sp, 1) - def test_speed_sp_max_positive(self): + def test_speed_sp_max_positive(self): self._param['motor'].speed_sp = (motor_info[self._param['motor'].driver_name]['max_speed']) self.assertEqual(self._param['motor'].speed_sp, motor_info[self._param['motor'].driver_name]['max_speed']) - def test_speed_sp_large_positive(self): + def test_speed_sp_large_positive(self): with self.assertRaises(IOError): self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed'] + 1 - def test_speed_sp_after_reset(self): - self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed']/2 - self.assertEqual(self._param['motor'].speed_sp, motor_info[self._param['motor'].driver_name]['max_speed']/2) + def test_speed_sp_after_reset(self): + self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed'] / 2 + self.assertEqual(self._param['motor'].speed_sp, motor_info[self._param['motor'].driver_name]['max_speed'] / 2) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed_sp, 0) -class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): +class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): def test_speed_i_negative(self): with self.assertRaises(IOError): self._param['motor'].speed_p = -1 @@ -400,11 +401,11 @@ def test_speed_p_after_reset(self): if self._param['speed_pid']: expected = self._param['speed_pid']['kP'] else: - expected = motor_info[self._param['motor'].driver_name]['speed_p'] + expected = motor_info[self._param['motor'].driver_name]['speed_p'] self.assertEqual(self._param['motor'].speed_p, expected) -class TestTachoMotorSpeedIValue(ptc.ParameterizedTestCase): +class TestTachoMotorSpeedIValue(ptc.ParameterizedTestCase): def test_speed_i_negative(self): with self.assertRaises(IOError): self._param['motor'].speed_i = -1 @@ -424,11 +425,11 @@ def test_speed_i_after_reset(self): if self._param['speed_pid']: expected = self._param['speed_pid']['kI'] else: - expected = motor_info[self._param['motor'].driver_name]['speed_i'] + expected = motor_info[self._param['motor'].driver_name]['speed_i'] self.assertEqual(self._param['motor'].speed_i, expected) -class TestTachoMotorSpeedDValue(ptc.ParameterizedTestCase): +class TestTachoMotorSpeedDValue(ptc.ParameterizedTestCase): def test_speed_d_negative(self): with self.assertRaises(IOError): self._param['motor'].speed_d = -1 @@ -448,11 +449,11 @@ def test_speed_d_after_reset(self): if self._param['speed_pid']: expected = self._param['speed_pid']['kD'] else: - expected = motor_info[self._param['motor'].driver_name]['speed_d'] + expected = motor_info[self._param['motor'].driver_name]['speed_d'] self.assertEqual(self._param['motor'].speed_d, expected) -class TestTachoMotorStateValue(ptc.ParameterizedTestCase): +class TestTachoMotorStateValue(ptc.ParameterizedTestCase): def test_state_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].state = 'ThisShouldNotWork' @@ -461,8 +462,8 @@ def test_state_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].state, []) -class TestTachoMotorStopActionValue(ptc.ParameterizedTestCase): +class TestTachoMotorStopActionValue(ptc.ParameterizedTestCase): def test_stop_action_illegal(self): with self.assertRaises(IOError): self._param['motor'].stop_action = 'ThisShouldNotWork' @@ -500,8 +501,8 @@ def test_stop_action_after_reset(self): self._param['motor'].action = 'reset' self.assertEqual(self._param['motor'].stop_action, self._param['stop_actions'][0]) -class TestTachoMotorStopActionsValue(ptc.ParameterizedTestCase): +class TestTachoMotorStopActionsValue(ptc.ParameterizedTestCase): def test_stop_actions_value(self): self.assertTrue(self._param['motor'].stop_actions == self._param['stop_actions']) @@ -509,8 +510,8 @@ def test_stop_actions_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].stop_actions = "ThisShouldNotWork" -class TestTachoMotorTimeSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorTimeSpValue(ptc.ParameterizedTestCase): def test_time_sp_negative(self): with self.assertRaises(IOError): self._param['motor'].time_sp = -1 @@ -532,6 +533,7 @@ def test_time_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].time_sp, 0) + ev3_params = { 'motor': ev3.Motor('outA'), 'port': 'outA', @@ -552,8 +554,16 @@ def test_time_sp_after_reset(self): 'driver_name': 'lego-nxt-motor', 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], 'stop_actions': ['coast', 'hold'], - 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, - 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, + 'speed_pid': { + 'kP': 1000, + 'kI': 60, + 'kD': 0 + }, + 'hold_pid': { + 'kP': 20000, + 'kI': 0, + 'kD': 0 + }, } pistorms_params = { 'motor': ev3.Motor('pistorms:BAM1'), @@ -561,8 +571,16 @@ def test_time_sp_after_reset(self): 'driver_name': 'lego-nxt-motor', 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'stop', 'reset'], 'stop_actions': ['coast', 'brake', 'hold'], - 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, - 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, + 'speed_pid': { + 'kP': 1000, + 'kI': 60, + 'kD': 0 + }, + 'hold_pid': { + 'kP': 20000, + 'kI': 0, + 'kD': 0 + }, } paramsA = pistorms_params paramsA['motor'].command = 'reset' @@ -593,6 +611,5 @@ def test_time_sp_after_reset(self): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandsValue, param=paramsA)) suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorTimeSpValue, param=paramsA)) - if __name__ == '__main__': - unittest.main(verbosity=2,buffer=True ).run(suite) + unittest.main(verbosity=2, buffer=True).run(suite) diff --git a/tests/motor/motor_run_direct_unittest.py b/tests/motor/motor_run_direct_unittest.py index c0d63de..e874b64 100755 --- a/tests/motor/motor_run_direct_unittest.py +++ b/tests/motor/motor_run_direct_unittest.py @@ -12,8 +12,8 @@ from motor_info import motor_info -class TestMotorRunDirect(ptc.ParameterizedTestCase): +class TestMotorRunDirect(ptc.ParameterizedTestCase): @classmethod def setUpClass(cls): pass @@ -25,30 +25,33 @@ def tearDownClass(cls): def initialize_motor(self): self._param['motor'].command = 'reset' - def run_direct_duty_cycles(self,stop_action,duty_cycles): + def run_direct_duty_cycles(self, stop_action, duty_cycles): self._param['motor'].stop_action = stop_action self._param['motor'].command = 'run-direct' for i in duty_cycles: - self._param['motor'].duty_cycle_sp = i + self._param['motor'].duty_cycle_sp = i time.sleep(0.5) self._param['motor'].command = 'stop' def test_stop_coast_duty_cycles(self): self.initialize_motor() - self.run_direct_duty_cycles('coast',[0,20,40,60,80,100,66,33,0,-20,-40,-60,-80,-100,-66,-33,0]) + self.run_direct_duty_cycles('coast', [0, 20, 40, 60, 80, 100, 66, 33, 0, -20, -40, -60, -80, -100, -66, -33, 0]) + # Add all the tests to the suite - some tests apply only to certain drivers! -def AddTachoMotorRunDirectTestsToSuite( suite, driver_name, params ): + +def AddTachoMotorRunDirectTestsToSuite(suite, driver_name, params): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestMotorRunDirect, param=params)) + if __name__ == '__main__': - params = { 'motor': ev3.Motor('outA'), 'port': 'outA', 'driver_name': 'lego-ev3-l-motor' } + params = {'motor': ev3.Motor('outA'), 'port': 'outA', 'driver_name': 'lego-ev3-l-motor'} suite = unittest.TestSuite() - AddTachoMotorRunDirectTestsToSuite( suite, 'lego-ev3-l-motor', params ) + AddTachoMotorRunDirectTestsToSuite(suite, 'lego-ev3-l-motor', params) - unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) + unittest.TextTestRunner(verbosity=1, buffer=True).run(suite) diff --git a/tests/motor/motor_unittest.py b/tests/motor/motor_unittest.py index 5933ed3..090f537 100644 --- a/tests/motor/motor_unittest.py +++ b/tests/motor/motor_unittest.py @@ -10,8 +10,8 @@ import parameterizedtestcase as ptc from motor_info import motor_info -class TestTachoMotorAddressValue(ptc.ParameterizedTestCase): +class TestTachoMotorAddressValue(ptc.ParameterizedTestCase): def test_address_value(self): # Use the class variable self.assertEqual(self._param['motor'].address, self._param['port']) @@ -21,8 +21,8 @@ def test_address_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].address = "ThisShouldNotWork" -class TestTachoMotorCommandsValue(ptc.ParameterizedTestCase): +class TestTachoMotorCommandsValue(ptc.ParameterizedTestCase): def test_commands_value(self): self.assertTrue(self._param['motor'].commands == self._param['commands']) @@ -31,8 +31,8 @@ def test_commands_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].commands = "ThisShouldNotWork" -class TestTachoMotorCountPerRotValue(ptc.ParameterizedTestCase): +class TestTachoMotorCountPerRotValue(ptc.ParameterizedTestCase): def test_count_per_rot_value(self): # This is not available for linear motors - move to driver specific tests? self.assertEqual(self._param['motor'].count_per_rot, 360) @@ -42,8 +42,8 @@ def test_count_per_rot_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].count_per_rot = "ThisShouldNotWork" -class TestTachoMotorDriverNameValue(ptc.ParameterizedTestCase): +class TestTachoMotorDriverNameValue(ptc.ParameterizedTestCase): def test_driver_name_value(self): # move to driver specific tests? self.assertEqual(self._param['motor'].driver_name, self._param['driver_name']) @@ -53,8 +53,8 @@ def test_driver_name_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].driver_name = "ThisShouldNotWork" -class TestTachoMotorDutyCycleValue(ptc.ParameterizedTestCase): +class TestTachoMotorDutyCycleValue(ptc.ParameterizedTestCase): def test_duty_cycle_value_is_read_only(self): # Use the class variable with self.assertRaises(AttributeError): @@ -64,8 +64,8 @@ def test_duty_cycle_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].duty_cycle, 0) -class TestTachoMotorDutyCycleSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorDutyCycleSpValue(ptc.ParameterizedTestCase): def test_duty_cycle_sp_large_negative(self): with self.assertRaises(IOError): self._param['motor'].duty_cycle_sp = -101 @@ -102,7 +102,6 @@ def test_duty_cycle_sp_after_reset(self): class TestTachoMotorMaxSpeedValue(ptc.ParameterizedTestCase): - def test_max_speed_value(self): # This is not available for linear motors - move to driver specific tests? self.assertEqual(self._param['motor'].max_speed, motor_info[self._param['motor'].driver_name]['max_speed']) @@ -112,8 +111,8 @@ def test_max_speed_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].max_speed = "ThisShouldNotWork" -class TestTachoMotorPositionPValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionPValue(ptc.ParameterizedTestCase): def test_position_p_negative(self): with self.assertRaises(IOError): self._param['motor'].position_p = -1 @@ -134,11 +133,11 @@ def test_position_p_after_reset(self): if 'hold_pid' in self._param: expected = self._param['hold_pid']['kP'] else: - expected = motor_info[self._param['motor'].driver_name]['position_p'] + expected = motor_info[self._param['motor'].driver_name]['position_p'] self.assertEqual(self._param['motor'].position_p, expected) -class TestTachoMotorPositionIValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionIValue(ptc.ParameterizedTestCase): def test_position_i_negative(self): with self.assertRaises(IOError): self._param['motor'].position_i = -1 @@ -159,11 +158,11 @@ def test_position_i_after_reset(self): if 'hold_pid' in self._param: expected = self._param['hold_pid']['kI'] else: - expected = motor_info[self._param['motor'].driver_name]['position_i'] + expected = motor_info[self._param['motor'].driver_name]['position_i'] self.assertEqual(self._param['motor'].position_i, expected) -class TestTachoMotorPositionDValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionDValue(ptc.ParameterizedTestCase): def test_position_d_negative(self): with self.assertRaises(IOError): self._param['motor'].position_d = -1 @@ -184,11 +183,11 @@ def test_position_d_after_reset(self): if 'hold_pid' in self._param: expected = self._param['hold_pid']['kD'] else: - expected = motor_info[self._param['motor'].driver_name]['position_d'] + expected = motor_info[self._param['motor'].driver_name]['position_d'] self.assertEqual(self._param['motor'].position_d, expected) -class TestTachoMotorPolarityValue(ptc.ParameterizedTestCase): +class TestTachoMotorPolarityValue(ptc.ParameterizedTestCase): def test_polarity_normal_value(self): self._param['motor'].polarity = 'normal' self.assertEqual(self._param['motor'].polarity, 'normal') @@ -214,8 +213,8 @@ def test_polarity_after_reset(self): else: self.assertEqual(self._param['motor'].polarity, 'inversed') -class TestTachoMotorPositionValue(ptc.ParameterizedTestCase): +class TestTachoMotorPositionValue(ptc.ParameterizedTestCase): def test_position_large_negative(self): self._param['motor'].position = -1000000 self.assertEqual(self._param['motor'].position, -1000000) @@ -244,7 +243,6 @@ def test_position_after_reset(self): class TestTachoMotorPositionSpValue(ptc.ParameterizedTestCase): - def test_position_sp_large_negative(self): self._param['motor'].position_sp = -1000000 self.assertEqual(self._param['motor'].position_sp, -1000000) @@ -271,8 +269,8 @@ def test_position_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].position_sp, 0) -class TestTachoMotorRampDownSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorRampDownSpValue(ptc.ParameterizedTestCase): def test_ramp_down_sp_negative_value(self): with self.assertRaises(IOError): self._param['motor'].ramp_down_sp = -1 @@ -298,9 +296,9 @@ def test_ramp_down_sp_after_reset(self): self.assertEqual(self._param['motor'].ramp_down_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_down_sp, 0) - -class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): + +class TestTachoMotorRampUpSpValue(ptc.ParameterizedTestCase): def test_ramp_up_negative_value(self): with self.assertRaises(IOError): self._param['motor'].ramp_up_sp = -1 @@ -326,9 +324,9 @@ def test_ramp_up_sp_after_reset(self): self.assertEqual(self._param['motor'].ramp_up_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].ramp_up_sp, 0) - -class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): + +class TestTachoMotorSpeedValue(ptc.ParameterizedTestCase): def test_speed_value_is_read_only(self): # Use the class variable with self.assertRaises(AttributeError): @@ -337,45 +335,45 @@ def test_speed_value_is_read_only(self): def test_speed_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed, 0) - -class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): - def test_speed_sp_large_negative(self): + +class TestTachoMotorSpeedSpValue(ptc.ParameterizedTestCase): + def test_speed_sp_large_negative(self): with self.assertRaises(IOError): - self._param['motor'].speed_sp = -(motor_info[self._param['motor'].driver_name]['max_speed']+1) + self._param['motor'].speed_sp = -(motor_info[self._param['motor'].driver_name]['max_speed'] + 1) - def test_speed_sp_max_negative(self): + def test_speed_sp_max_negative(self): self._param['motor'].speed_sp = -motor_info[self._param['motor'].driver_name]['max_speed'] self.assertEqual(self._param['motor'].speed_sp, -motor_info[self._param['motor'].driver_name]['max_speed']) - def test_speed_sp_min_negative(self): + def test_speed_sp_min_negative(self): self._param['motor'].speed_sp = -1 self.assertEqual(self._param['motor'].speed_sp, -1) - def test_speed_sp_zero(self): + def test_speed_sp_zero(self): self._param['motor'].speed_sp = 0 self.assertEqual(self._param['motor'].speed_sp, 0) - def test_speed_sp_min_positive(self): + def test_speed_sp_min_positive(self): self._param['motor'].speed_sp = 1 self.assertEqual(self._param['motor'].speed_sp, 1) - def test_speed_sp_max_positive(self): + def test_speed_sp_max_positive(self): self._param['motor'].speed_sp = (motor_info[self._param['motor'].driver_name]['max_speed']) self.assertEqual(self._param['motor'].speed_sp, motor_info[self._param['motor'].driver_name]['max_speed']) - def test_speed_sp_large_positive(self): + def test_speed_sp_large_positive(self): with self.assertRaises(IOError): self._param['motor'].speed_sp = motor_info[self._param['motor'].driver_name]['max_speed'] + 1 - def test_speed_sp_after_reset(self): + def test_speed_sp_after_reset(self): self._param['motor'].speed_sp = 100 self.assertEqual(self._param['motor'].speed_sp, 100) self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].speed_sp, 0) - -class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): + +class TestTachoMotorSpeedPValue(ptc.ParameterizedTestCase): def test_speed_i_negative(self): with self.assertRaises(IOError): self._param['motor'].speed_p = -1 @@ -396,11 +394,11 @@ def test_speed_p_after_reset(self): if 'speed_pid' in self._param: expected = self._param['speed_pid']['kP'] else: - expected = motor_info[self._param['motor'].driver_name]['speed_p'] + expected = motor_info[self._param['motor'].driver_name]['speed_p'] self.assertEqual(self._param['motor'].speed_p, expected) -class TestTachoMotorSpeedIValue(ptc.ParameterizedTestCase): +class TestTachoMotorSpeedIValue(ptc.ParameterizedTestCase): def test_speed_i_negative(self): with self.assertRaises(IOError): self._param['motor'].speed_i = -1 @@ -421,11 +419,11 @@ def test_speed_i_after_reset(self): if 'speed_pid' in self._param: expected = self._param['speed_pid']['kI'] else: - expected = motor_info[self._param['motor'].driver_name]['speed_i'] + expected = motor_info[self._param['motor'].driver_name]['speed_i'] self.assertEqual(self._param['motor'].speed_i, expected) -class TestTachoMotorSpeedDValue(ptc.ParameterizedTestCase): +class TestTachoMotorSpeedDValue(ptc.ParameterizedTestCase): def test_speed_d_negative(self): with self.assertRaises(IOError): self._param['motor'].speed_d = -1 @@ -446,11 +444,11 @@ def test_speed_d_after_reset(self): if 'speed_pid' in self._param: expected = self._param['speed_pid']['kD'] else: - expected = motor_info[self._param['motor'].driver_name]['speed_d'] + expected = motor_info[self._param['motor'].driver_name]['speed_d'] self.assertEqual(self._param['motor'].speed_d, expected) -class TestTachoMotorStateValue(ptc.ParameterizedTestCase): +class TestTachoMotorStateValue(ptc.ParameterizedTestCase): def test_state_value_is_read_only(self): # Use the class variable with self.assertRaises(AttributeError): @@ -460,10 +458,10 @@ def test_state_value_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].state, []) + # def test_stop_action_value(self): # self.assertEqual(self._param['motor'].stop_action, 'coast') class TestTachoMotorStopCommandValue(ptc.ParameterizedTestCase): - def test_stop_action_illegal(self): with self.assertRaises(IOError): self._param['motor'].stop_action = 'ThisShouldNotWork' @@ -501,8 +499,8 @@ def test_stop_action_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].stop_action, self._param['stop_actions'][0]) -class TestTachoMotorStopCommandsValue(ptc.ParameterizedTestCase): +class TestTachoMotorStopCommandsValue(ptc.ParameterizedTestCase): def test_stop_actions_value(self): self.assertTrue(self._param['motor'].stop_actions == self._param['stop_actions']) @@ -511,8 +509,8 @@ def test_stop_actions_value_is_read_only(self): with self.assertRaises(AttributeError): self._param['motor'].stop_actions = "ThisShouldNotWork" -class TestTachoMotorTimeSpValue(ptc.ParameterizedTestCase): +class TestTachoMotorTimeSpValue(ptc.ParameterizedTestCase): def test_time_sp_negative(self): with self.assertRaises(IOError): self._param['motor'].time_sp = -1 @@ -534,6 +532,7 @@ def test_time_sp_after_reset(self): self._param['motor'].command = 'reset' self.assertEqual(self._param['motor'].time_sp, 0) + ev3_params = { 'motor': ev3.Motor('outA'), 'port': 'outA', @@ -554,8 +553,16 @@ def test_time_sp_after_reset(self): 'driver_name': 'lego-nxt-motor', 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'run-direct', 'stop', 'reset'], 'stop_actions': ['coast', 'hold'], - 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, - 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, + 'speed_pid': { + 'kP': 1000, + 'kI': 60, + 'kD': 0 + }, + 'hold_pid': { + 'kP': 20000, + 'kI': 0, + 'kD': 0 + }, } pistorms_params = { 'motor': ev3.Motor('pistorms:BAM1'), @@ -563,8 +570,16 @@ def test_time_sp_after_reset(self): 'driver_name': 'lego-nxt-motor', 'commands': ['run-forever', 'run-to-abs-pos', 'run-to-rel-pos', 'run-timed', 'stop', 'reset'], 'stop_actions': ['coast', 'brake', 'hold'], - 'speed_pid': { 'kP': 1000, 'kI': 60, 'kD': 0 }, - 'hold_pid': { 'kP': 20000, 'kI': 0, 'kD': 0 }, + 'speed_pid': { + 'kP': 1000, + 'kI': 60, + 'kD': 0 + }, + 'hold_pid': { + 'kP': 20000, + 'kI': 0, + 'kD': 0 + }, } paramsA = ev3_params paramsA['motor'].command = 'reset' @@ -595,16 +610,15 @@ def test_time_sp_after_reset(self): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandsValue, param=paramsA)) suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorTimeSpValue, param=paramsA)) - if __name__ == '__main__': - unittest.TextTestRunner(verbosity=1,buffer=True ).run(suite) + unittest.TextTestRunner(verbosity=1, buffer=True).run(suite) exit() # Move these up later -class TestMotorRelativePosition(unittest.TestCase): +class TestMotorRelativePosition(unittest.TestCase): @classmethod def setUpClass(cls): cls._motor = ev3.Motor('outA') @@ -630,7 +644,7 @@ def test_stop_brake(self): self._motor.stop_action = 'brake' self._motor.position = 0 - for i in range(1,5): + for i in range(1, 5): self._motor.command = 'run-to-rel-pos' time.sleep(1) print(self._motor.position) @@ -640,7 +654,7 @@ def test_stop_hold(self): self._motor.stop_action = 'hold' self._motor.position = 0 - for i in range(1,5): + for i in range(1, 5): self._motor.command = 'run-to-rel-pos' time.sleep(1) print(self._motor.position) @@ -648,4 +662,4 @@ def test_stop_hold(self): if __name__ == '__main__': - unittest.main(verbosity=2,buffer=True ).run(suite) + unittest.main(verbosity=2, buffer=True).run(suite) diff --git a/tests/motor/parameterizedtestcase.py b/tests/motor/parameterizedtestcase.py index 3c59cd5..bdca528 100644 --- a/tests/motor/parameterizedtestcase.py +++ b/tests/motor/parameterizedtestcase.py @@ -1,5 +1,6 @@ import unittest + class ParameterizedTestCase(unittest.TestCase): """ TestCase classes that want to be parametrized should inherit from this class. @@ -17,5 +18,5 @@ def parameterize(testcase_class, param=None): testnames = testloader.getTestCaseNames(testcase_class) suite = unittest.TestSuite() for name in testnames: - suite.addTest(testcase_class(name,param=param)) + suite.addTest(testcase_class(name, param=param)) return suite diff --git a/tests/motor/plot_matplotlib.py b/tests/motor/plot_matplotlib.py index b6f7d62..c7a3725 100644 --- a/tests/motor/plot_matplotlib.py +++ b/tests/motor/plot_matplotlib.py @@ -3,8 +3,7 @@ import argparse parser = argparse.ArgumentParser(description='Plot ev3dev datalogs.') -parser.add_argument('infile', - help='the input file to be logged') +parser.add_argument('infile', help='the input file to be logged') args = parser.parse_args() @@ -14,11 +13,10 @@ # http://matplotlib.org/examples/pylab_examples/subplots_demo.html # These are the "Tableau 20" colors as RGB. -tableau20 = [(31, 119, 180), (174, 199, 232), (255, 127, 14), (255, 187, 120), - (44, 160, 44), (152, 223, 138), (214, 39, 40), (255, 152, 150), - (148, 103, 189), (197, 176, 213), (140, 86, 75), (196, 156, 148), - (227, 119, 194), (247, 182, 210), (127, 127, 127), (199, 199, 199), - (188, 189, 34), (219, 219, 141), (23, 190, 207), (158, 218, 229)] +tableau20 = [(31, 119, 180), (174, 199, 232), (255, 127, 14), (255, 187, 120), (44, 160, 44), (152, 223, 138), + (214, 39, 40), (255, 152, 150), (148, 103, 189), (197, 176, 213), (140, 86, 75), (196, 156, 148), + (227, 119, 194), (247, 182, 210), (127, 127, 127), (199, 199, 199), (188, 189, 34), (219, 219, 141), + (23, 190, 207), (158, 218, 229)] # Scale the RGB values to the [0, 1] range, which is the format matplotlib accepts. for i in range(len(tableau20)): @@ -27,46 +25,49 @@ plt.style.use(['dark_background']) -test = json.loads( open( args.infile ).read() ) +test = json.loads(open(args.infile).read()) values = {} # Extract the data from the log in a format that's useful for plotting -for k,d in test['data'].items(): +for k, d in test['data'].items(): values['k'] = {} values['k']['x'] = [row[0] for row in d] values['k']['y'] = [] - for i,a in enumerate(test['meta']['ports'][k]['log_attributes']): - values['k']['y'].append( {'name': a, 'values': [row[1][i] for row in d]}) + for i, a in enumerate(test['meta']['ports'][k]['log_attributes']): + values['k']['y'].append({'name': a, 'values': [row[1][i] for row in d]}) f, axarr = plt.subplots(3, sharex=True) axarr[2].set_xlabel('Time (seconds)') - f.text(.95,0, args.infile, - fontsize=10, - horizontalalignment='left', - verticalalignment='center' ) - - f.text(.5,1, "{0} - {1}".format( test['meta']['title'], k), - fontsize=14, - horizontalalignment='center', - verticalalignment='center' ) - - f.text(.5,.96, "{0}".format( test['meta']['subtitle']), - fontsize=10, - horizontalalignment='center', - verticalalignment='center' ) - - f.text(.92,.5, "{0}".format( test['meta']['notes']), - fontsize=10, - horizontalalignment='left', - verticalalignment='center' ) + f.text(.95, 0, args.infile, fontsize=10, horizontalalignment='left', verticalalignment='center') + + f.text(.5, + 1, + "{0} - {1}".format(test['meta']['title'], k), + fontsize=14, + horizontalalignment='center', + verticalalignment='center') + + f.text(.5, + .96, + "{0}".format(test['meta']['subtitle']), + fontsize=10, + horizontalalignment='center', + verticalalignment='center') + + f.text(.92, + .5, + "{0}".format(test['meta']['notes']), + fontsize=10, + horizontalalignment='left', + verticalalignment='center') # Clean up the chartjunk - for i,ax in enumerate(axarr): + for i, ax in enumerate(axarr): print(i, ax) # Remove the plot frame lines. They are unnecessary chartjunk. ax.spines["top"].set_visible(False) @@ -76,12 +77,14 @@ ax.get_xaxis().tick_bottom() ax.get_yaxis().tick_left() - axarr[i].plot(values['k']['x'],values['k']['y'][i]['values'], lw=1.5, color=tableau20[i] ) - axarr[i].text(.95,1, "{0}".format( values['k']['y'][i]['name'] ), + axarr[i].plot(values['k']['x'], values['k']['y'][i]['values'], lw=1.5, color=tableau20[i]) + axarr[i].text(.95, + 1, + "{0}".format(values['k']['y'][i]['name']), fontsize=14, color=tableau20[i], horizontalalignment='right', verticalalignment='center', - transform = axarr[i].transAxes) + transform=axarr[i].transAxes) - plt.savefig("{0}-{1}.png".format(args.infile,k), bbox_inches="tight") + plt.savefig("{0}-{1}.png".format(args.infile, k), bbox_inches="tight") diff --git a/utils/console_fonts.py b/utils/console_fonts.py index 8bbaae6..ace86b0 100644 --- a/utils/console_fonts.py +++ b/utils/console_fonts.py @@ -3,7 +3,6 @@ from sys import stderr from os import listdir from ev3dev2.console import Console - """ Used to iterate over the system console fonts (in /usr/share/consolefonts) and show the max row/col. @@ -32,7 +31,10 @@ def show_fonts(): console.text_at(font, 1, 1, False, True) console.clear_to_eol() console.text_at("{}, {}".format(console.columns, console.rows), - column=2, row=4, reset_console=False, inverse=False) + column=2, + row=4, + reset_console=False, + inverse=False) print("{}, {}, \"{}\"".format(console.columns, console.rows, font), file=stderr) fonts.append((console.columns, console.rows, font)) @@ -42,12 +44,13 @@ def show_fonts(): for cols, rows, font in fonts: print(cols, rows, font, file=stderr) console.set_font(font, True) - for row in range(1, rows+1): - for col in range(1, cols+1): + for row in range(1, rows + 1): + for col in range(1, cols + 1): console.text_at("{}".format(col % 10), col, row, False, (row % 2 == 0)) console.text_at(font.split(".")[0], 1, 1, False, True) console.clear_to_eol() + # Show the fonts; you may want to adjust the ``startswith`` filter to show other codesets. show_fonts() diff --git a/utils/line-follower-find-kp-ki-kd.py b/utils/line-follower-find-kp-ki-kd.py index 9e705b5..1ce07da 100755 --- a/utils/line-follower-find-kp-ki-kd.py +++ b/utils/line-follower-find-kp-ki-kd.py @@ -1,4 +1,3 @@ - """ This program is used to find the kp, ki, kd PID values for ``MoveTank.follow_line()``. These values vary from robot to robot, the best way @@ -53,7 +52,9 @@ def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): try: if kx_to_tweak == "kp": tank.follow_line( - kp=kx, ki=ki, kd=kd, + kp=kx, + ki=ki, + kd=kd, speed=speed, follow_for=follow_for_ms, ms=10000, @@ -61,7 +62,9 @@ def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): elif kx_to_tweak == "ki": tank.follow_line( - kp=kp, ki=kx, kd=kd, + kp=kp, + ki=kx, + kd=kd, speed=speed, follow_for=follow_for_ms, ms=10000, @@ -69,7 +72,9 @@ def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): elif kx_to_tweak == "kd": tank.follow_line( - kp=kp, ki=ki, kd=kx, + kp=kp, + ki=ki, + kd=kx, speed=speed, follow_for=follow_for_ms, ms=10000, @@ -102,8 +107,7 @@ def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): if __name__ == "__main__": # logging - logging.basicConfig(level=logging.DEBUG, - format="%(asctime)s %(levelname)5s: %(message)s") + logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)5s: %(message)s") log = logging.getLogger(__name__) tank = MoveTank(OUTPUT_A, OUTPUT_B) diff --git a/utils/move_differential.py b/utils/move_differential.py index 0466a30..5878e48 100755 --- a/utils/move_differential.py +++ b/utils/move_differential.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - """ Used to experiment with the MoveDifferential class """ @@ -12,8 +11,7 @@ import sys # logging -logging.basicConfig(level=logging.DEBUG, - format="%(asctime)s %(levelname)5s: %(message)s") +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)5s: %(message)s") log = logging.getLogger(__name__) STUD_MM = 8 @@ -73,7 +71,6 @@ #mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) #mdiff.turn_to_angle(SpeedRPM(40), 90) - # Use odometry to drive to specific coordinates #mdiff.on_to_coordinates(SpeedRPM(40), 600, 300) @@ -81,6 +78,5 @@ #mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) #mdiff.turn_to_angle(SpeedRPM(40), 90) - #mdiff.odometry_coordinates_log() #mdiff.odometry_stop() diff --git a/utils/move_motor.py b/utils/move_motor.py index 4c0aee8..e51cd7d 100755 --- a/utils/move_motor.py +++ b/utils/move_motor.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - """ Used to adjust the position of a motor in an already assembled robot where you can"t move the motor by hand. @@ -18,8 +17,7 @@ args = parser.parse_args() # logging -logging.basicConfig(level=logging.INFO, - format="%(asctime)s %(levelname)5s: %(message)s") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)5s: %(message)s") log = logging.getLogger(__name__) if args.motor == "A": @@ -36,6 +34,4 @@ if args.degrees: log.info("Motor %s, current position %d, move to position %d, max speed %d" % (args.motor, motor.position, args.degrees, motor.max_speed)) - motor.run_to_rel_pos(speed_sp=args.speed, - position_sp=args.degrees, - stop_action='hold') + motor.run_to_rel_pos(speed_sp=args.speed, position_sp=args.degrees, stop_action='hold') diff --git a/utils/stop_all_motors.py b/utils/stop_all_motors.py index 1f39a27..e040146 100755 --- a/utils/stop_all_motors.py +++ b/utils/stop_all_motors.py @@ -1,5 +1,4 @@ #!/usr/bin/env micropython - """ Stop all motors """ From 995bcb6b55fef493d842e0b1b36820255a19bf0e Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 9 Feb 2020 12:49:02 -0500 Subject: [PATCH 159/172] MoveDifferential: use gyro for better accuracy (#718) --- debian/changelog | 5 +- ev3dev2/__init__.py | 8 ++ ev3dev2/motor.py | 300 ++++++++++++++++++++++++----------------- ev3dev2/sensor/lego.py | 75 ++++++++++- 4 files changed, 261 insertions(+), 127 deletions(-) diff --git a/debian/changelog b/debian/changelog index 29947f4..ecc07ef 100644 --- a/debian/changelog +++ b/debian/changelog @@ -3,6 +3,9 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Daniel Walton] * RPyC update docs and make it easier to use * use double backticks consistently in docstrings + * format code via yapf + * MoveDifferential use gyro for better accuracy. Make MoveTank and + MoveDifferential "turn" APIs more consistent. [Matěj Volf] * LED animation fix duration None @@ -12,7 +15,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium * Add "value_secs" and "is_elapsed_*" methods to StopWatch * Rename "value_hms" to "hms_str". New "value_hms" returns original tuple. * Fix bug in Button.process() on Micropython - + [Nithin Shenoy] * Add Gyro-based driving support to MoveTank diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index a2a20e1..d614b67 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -148,6 +148,14 @@ class DeviceNotFound(Exception): pass +class DeviceNotDefined(Exception): + pass + + +class ThreadNotRunning(Exception): + pass + + # ----------------------------------------------------------------------------- # Define the base class from which all other ev3dev classes are defined. diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 411a0d3..2a478b1 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -39,7 +39,7 @@ from logging import getLogger from os.path import abspath -from ev3dev2 import get_current_platform, Device, list_device_names +from ev3dev2 import get_current_platform, Device, list_device_names, DeviceNotDefined, ThreadNotRunning from ev3dev2.stopwatch import StopWatch log = getLogger(__name__) @@ -1857,6 +1857,8 @@ def __init__(self, left_motor_port, right_motor_port, desc=None, motor_class=Lar self.left_motor = self.motors[left_motor_port] self.right_motor = self.motors[right_motor_port] self.max_speed = self.left_motor.max_speed + self._cs = None + self._gyro = None # color sensor used by follow_line() @property @@ -1918,17 +1920,6 @@ def on_for_degrees(self, left_speed, right_speed, degrees, brake=True, block=Tru self.right_motor._set_rel_position_degrees_and_speed_sp(right_degrees, right_speed_native_units) self.right_motor._set_brake(brake) - log.debug("{}: on_for_degrees {}".format(self, degrees)) - - # These debugs involve disk I/O to pull position and position_sp so only uncomment - # if you need to troubleshoot in more detail. - # log.debug("{}: left_speed {}, left_speed_native_units {}, left_degrees {}, left-position {}->{}".format( - # self, left_speed, left_speed_native_units, left_degrees, - # self.left_motor.position, self.left_motor.position_sp)) - # log.debug("{}: right_speed {}, right_speed_native_units {}, right_degrees {}, right-position {}->{}".format( - # self, right_speed, right_speed_native_units, right_degrees, - # self.right_motor.position, self.right_motor.position_sp)) - # Start the motors self.left_motor.run_to_rel_pos() self.right_motor.run_to_rel_pos() @@ -1989,11 +1980,6 @@ def on(self, left_speed, right_speed): self.left_motor.speed_sp = int(round(left_speed_native_units)) self.right_motor.speed_sp = int(round(right_speed_native_units)) - # This debug involves disk I/O to pull speed_sp so only uncomment - # if you need to troubleshoot in more detail. - # log.debug("%s: on at left-speed %s, right-speed %s" % - # (self, self.left_motor.speed_sp, self.right_motor.speed_sp)) - # Start the motors self.left_motor.run_forever() self.right_motor.run_forever() @@ -2065,7 +2051,9 @@ def follow_line(self, tank.stop() raise """ - assert self._cs, "ColorSensor must be defined" + if not self._cs: + raise DeviceNotDefined( + "The 'cs' variable must be defined with a ColorSensor. Example: tank.cs = ColorSensor()") if target_light_intensity is None: target_light_intensity = self._cs.reflected_light_intensity @@ -2118,19 +2106,6 @@ def follow_line(self, self.stop() - def calibrate_gyro(self): - """ - Calibrates the gyro sensor. - - NOTE: This takes 1sec to run - """ - assert self._gyro, "GyroSensor must be defined" - - for x in range(2): - self._gyro.mode = 'GYRO-RATE' - self._gyro.mode = 'GYRO-ANG' - time.sleep(0.5) - def follow_gyro_angle(self, kp, ki, @@ -2177,7 +2152,7 @@ def follow_gyro_angle(self, try: # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.calibrate_gyro() + tank.gyro.calibrate() # Follow the line for 4500ms tank.follow_gyro_angle( @@ -2191,7 +2166,9 @@ def follow_gyro_angle(self, tank.stop() raise """ - assert self._gyro, "GyroSensor must be defined" + if not self._gyro: + raise DeviceNotDefined( + "The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") integral = 0.0 last_error = 0.0 @@ -2229,25 +2206,29 @@ def follow_gyro_angle(self, self.stop() - def turn_to_angle_gyro(self, speed, target_angle=0, wiggle_room=2, sleep_time=0.01): + def turn_degrees(self, speed, target_angle, brake=True, error_margin=2, sleep_time=0.01): """ - Pivot Turn + Use a GyroSensor to rotate in place for ``target_angle`` ``speed`` is the desired speed of the midpoint of the robot - ``target_angle`` is the target angle we want to pivot to + ``target_angle`` is the number of degrees we want to rotate - ``wiggle_room`` is the +/- angle threshold to control how accurate the turn should be + ``brake`` hit the brakes once we reach ``target_angle`` + + ``error_margin`` is the +/- angle threshold to control how accurate the turn should be ``sleep_time`` is how many seconds we sleep on each pass through the loop. This is to give the robot a chance to react to the new motor settings. This should be something small such as 0.01 (10ms). + Rotate in place for ``target_degrees`` at ``speed`` + Example: .. code:: python - + from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent from ev3dev2.sensor.lego import GyroSensor @@ -2258,35 +2239,58 @@ def turn_to_angle_gyro(self, speed, target_angle=0, wiggle_room=2, sleep_time=0. tank.gyro = GyroSensor() # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.calibrate_gyro() + tank.gyro.calibrate() # Pivot 30 degrees - tank.turn_to_angle_gyro( + tank.turn_degrees( speed=SpeedPercent(5), - target_angle(30) + target_angle=30 ) """ - assert self._gyro, "GyroSensor must be defined" + + # MoveTank does not have information on wheel size and distance (that is + # MoveDifferential) so we must use a GyroSensor to control how far we rotate. + if not self._gyro: + raise DeviceNotDefined( + "The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") speed_native_units = speed.to_native_units(self.left_motor) - target_reached = False + target_angle = self._gyro.angle + target_angle - while not target_reached: + while True: current_angle = self._gyro.angle - if abs(current_angle - target_angle) <= wiggle_room: - target_reached = True - self.stop() - elif (current_angle > target_angle): - left_speed = SpeedNativeUnits(-1 * speed_native_units) - right_speed = SpeedNativeUnits(speed_native_units) - else: + delta = abs(target_angle - current_angle) + + if delta <= error_margin: + self.stop(brake=brake) + break + + # we are left of our target, rotate clockwise + if current_angle < target_angle: left_speed = SpeedNativeUnits(speed_native_units) right_speed = SpeedNativeUnits(-1 * speed_native_units) + # we are right of our target, rotate counter-clockwise + else: + left_speed = SpeedNativeUnits(-1 * speed_native_units) + right_speed = SpeedNativeUnits(speed_native_units) + + self.on(left_speed, right_speed) + if sleep_time: time.sleep(sleep_time) - self.on(left_speed, right_speed) + def turn_right(self, speed, degrees, brake=True, error_margin=2, sleep_time=0.01): + """ + Rotate clockwise ``degrees`` in place + """ + self.turn_degrees(speed, abs(degrees), brake, error_margin, sleep_time) + + def turn_left(self, speed, degrees, brake=True, error_margin=2, sleep_time=0.01): + """ + Rotate counter-clockwise ``degrees`` in place + """ + self.turn_degrees(speed, abs(degrees) * -1, brake, error_margin, sleep_time) class MoveSteering(MoveTank): @@ -2476,12 +2480,11 @@ def __init__(self, self.x_pos_mm = 0.0 # robot X position in mm self.y_pos_mm = 0.0 # robot Y position in mm self.odometry_thread_run = False - self.odometry_thread_id = None self.theta = 0.0 def on_for_distance(self, speed, distance_mm, brake=True, block=True): """ - Drive distance_mm + Drive in a straight line for ``distance_mm`` """ rotations = distance_mm / self.wheel.circumference_mm log.debug("%s: on_for_rotations distance_mm %s, rotations %s, speed %s" % (self, distance_mm, rotations, speed)) @@ -2551,11 +2554,43 @@ def on_arc_left(self, speed, radius_mm, distance_mm, brake=True, block=True): """ self._on_arc(speed, radius_mm, distance_mm, brake, block, False) - def _turn(self, speed, degrees, brake=True, block=True): + def turn_degrees(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): """ - Rotate in place 'degrees'. Both wheels must turn at the same speed for us - to rotate in place. + Rotate in place ``degrees``. Both wheels must turn at the same speed for us + to rotate in place. If the following conditions are met the GryoSensor will + be used to improve the accuracy of our turn: + - ``use_gyro``, ``brake`` and ``block`` are all True + - A GyroSensor has been defined via ``self.gyro = GyroSensor()`` """ + def final_angle(init_angle, degrees): + result = init_angle - degrees + + while result <= -360: + result += 360 + + while result >= 360: + result -= 360 + + if result < 0: + result += 360 + + return result + + # use the gyro to check that we turned the correct amount? + use_gyro = bool(use_gyro and block and brake) + if use_gyro and not self._gyro: + raise DeviceNotDefined( + "The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") + + if use_gyro: + angle_init_degrees = self._gyro.circle_angle() + else: + angle_init_degrees = math.degrees(self.theta) + + angle_target_degrees = final_angle(angle_init_degrees, degrees) + + log.info("%s: turn_degrees() %d degrees from %s to %s" % + (self, degrees, angle_init_degrees, angle_target_degrees)) # The distance each wheel needs to travel distance_mm = (abs(degrees) / 360) * self.circumference_mm @@ -2563,29 +2598,92 @@ def _turn(self, speed, degrees, brake=True, block=True): # The number of rotations to move distance_mm rotations = distance_mm / self.wheel.circumference_mm - log.debug("%s: turn() degrees %s, distance_mm %s, rotations %s, degrees %s" % - (self, degrees, distance_mm, rotations, degrees)) - # If degrees is positive rotate clockwise if degrees > 0: MoveTank.on_for_rotations(self, speed, speed * -1, rotations, brake, block) # If degrees is negative rotate counter-clockwise else: - rotations = distance_mm / self.wheel.circumference_mm MoveTank.on_for_rotations(self, speed * -1, speed, rotations, brake, block) - def turn_right(self, speed, degrees, brake=True, block=True): + if use_gyro: + angle_current_degrees = self._gyro.circle_angle() + + # This can happen if we are aiming for 2 degrees and overrotate to 358 degrees + # We need to rotate counter-clockwise + if 90 >= angle_target_degrees >= 0 and 270 <= angle_current_degrees <= 360: + degrees_error = (angle_target_degrees + (360 - angle_current_degrees)) * -1 + + # This can happen if we are aiming for 358 degrees and overrotate to 2 degrees + # We need to rotate clockwise + elif 360 >= angle_target_degrees >= 270 and 0 <= angle_current_degrees <= 90: + degrees_error = angle_current_degrees + (360 - angle_target_degrees) + + # We need to rotate clockwise + elif angle_current_degrees > angle_target_degrees: + degrees_error = angle_current_degrees - angle_target_degrees + + # We need to rotate counter-clockwise + else: + degrees_error = (angle_target_degrees - angle_current_degrees) * -1 + + log.info("%s: turn_degrees() ended up at %s, error %s, error_margin %s" % + (self, angle_current_degrees, degrees_error, error_margin)) + + if abs(degrees_error) > error_margin: + self.turn_degrees(speed, degrees_error, brake, block, error_margin, use_gyro) + + def turn_right(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): + """ + Rotate clockwise ``degrees`` in place + """ + self.turn_degrees(speed, abs(degrees), brake, block, error_margin, use_gyro) + + def turn_left(self, speed, degrees, brake=True, block=True, error_margin=2, use_gyro=False): """ - Rotate clockwise 'degrees' in place + Rotate counter-clockwise ``degrees`` in place """ - self._turn(speed, abs(degrees), brake, block) + self.turn_degrees(speed, abs(degrees) * -1, brake, block, error_margin, use_gyro) - def turn_left(self, speed, degrees, brake=True, block=True): + def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True, error_margin=2, use_gyro=False): """ - Rotate counter-clockwise 'degrees' in place + Rotate in place to ``angle_target_degrees`` at ``speed`` """ - self._turn(speed, abs(degrees) * -1, brake, block) + if not self.odometry_thread_run: + raise ThreadNotRunning("odometry_start() must be called to track robot coordinates") + + # Make both target and current angles positive numbers between 0 and 360 + while angle_target_degrees < 0: + angle_target_degrees += 360 + + angle_current_degrees = math.degrees(self.theta) + + while angle_current_degrees < 0: + angle_current_degrees += 360 + + # Is it shorter to rotate to the right or left + # to reach angle_target_degrees? + if angle_current_degrees > angle_target_degrees: + turn_right = True + angle_delta = angle_current_degrees - angle_target_degrees + else: + turn_right = False + angle_delta = angle_target_degrees - angle_current_degrees + + if angle_delta > 180: + angle_delta = 360 - angle_delta + turn_right = not turn_right + + log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % + (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) + self.odometry_coordinates_log() + + if turn_right: + self.turn_degrees(speed, abs(angle_delta), brake, block, error_margin, use_gyro) + else: + self.turn_degrees(speed, abs(angle_delta) * -1, brake, block, error_margin, use_gyro) + + self.odometry_coordinates_log() def odometry_coordinates_log(self): log.debug("%s: odometry angle %s at (%d, %d)" % (self, math.degrees(self.theta), self.x_pos_mm, self.y_pos_mm)) @@ -2605,6 +2703,7 @@ def _odometry_monitor(): self.x_pos_mm = x_pos_start # robot X position in mm self.y_pos_mm = y_pos_start # robot Y position in mm TWO_PI = 2 * math.pi + self.odometry_thread_run = True while self.odometry_thread_run: @@ -2623,11 +2722,6 @@ def _odometry_monitor(): time.sleep(sleep_time) continue - # log.debug("%s: left_ticks %s (from %s to %s)" % - # (self, left_ticks, left_previous, left_current)) - # log.debug("%s: right_ticks %s (from %s to %s)" % - # (self, right_ticks, right_previous, right_current)) - # update _previous for next time left_previous = left_current right_previous = right_current @@ -2656,66 +2750,26 @@ def _odometry_monitor(): if sleep_time: time.sleep(sleep_time) - self.odometry_thread_id = None + _thread.start_new_thread(_odometry_monitor, ()) - self.odometry_thread_run = True - self.odometry_thread_id = _thread.start_new_thread(_odometry_monitor, ()) + # Block until the thread has started doing work + while not self.odometry_thread_run: + pass def odometry_stop(self): """ - Signal the odometry thread to exit and wait for it to exit + Signal the odometry thread to exit """ - if self.odometry_thread_id: + if self.odometry_thread_run: self.odometry_thread_run = False - while self.odometry_thread_id: - pass - - def turn_to_angle(self, speed, angle_target_degrees, brake=True, block=True): - """ - Rotate in place to ``angle_target_degrees`` at ``speed`` - """ - assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" - - # Make both target and current angles positive numbers between 0 and 360 - if angle_target_degrees < 0: - angle_target_degrees += 360 - - angle_current_degrees = math.degrees(self.theta) - - if angle_current_degrees < 0: - angle_current_degrees += 360 - - # Is it shorter to rotate to the right or left - # to reach angle_target_degrees? - if angle_current_degrees > angle_target_degrees: - turn_right = True - angle_delta = angle_current_degrees - angle_target_degrees - else: - turn_right = False - angle_delta = angle_target_degrees - angle_current_degrees - - if angle_delta > 180: - angle_delta = 360 - angle_delta - turn_right = not turn_right - - log.debug("%s: turn_to_angle %s, current angle %s, delta %s, turn_right %s" % - (self, angle_target_degrees, angle_current_degrees, angle_delta, turn_right)) - self.odometry_coordinates_log() - - if turn_right: - self.turn_right(speed, angle_delta, brake, block) - else: - self.turn_left(speed, angle_delta, brake, block) - - self.odometry_coordinates_log() - def on_to_coordinates(self, speed, x_target_mm, y_target_mm, brake=True, block=True): """ Drive to (``x_target_mm``, ``y_target_mm``) coordinates at ``speed`` """ - assert self.odometry_thread_id, "odometry_start() must be called to track robot coordinates" + if not self.odometry_thread_run: + raise ThreadNotRunning("odometry_start() must be called to track robot coordinates") # stop moving self.off(brake='hold') @@ -2738,7 +2792,7 @@ class MoveJoystick(MoveTank): """ def on(self, x, y, radius=100.0): """ - Convert x,y joystick coordinates to left/right motor speed percentages + Convert ``x``,``y`` joystick coordinates to left/right motor speed percentages and move the motors. This will use a classic "arcade drive" algorithm: a full-forward joystick @@ -2747,11 +2801,11 @@ def on(self, x, y, radius=100.0): Positions in the middle will control how fast the vehicle moves and how sharply it turns. - "x", "y": + ``x``, ``y``: The X and Y coordinates of the joystick's position, with (0,0) representing the center position. X is horizontal and Y is vertical. - radius (default 100): + ``radius`` (default 100): The radius of the joystick, controlling the range of the input (x, y) values. e.g. if "x" and "y" can be between -1 and 1, radius should be set to "1". """ diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 1394275..99a24eb 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -28,10 +28,13 @@ if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') +import logging import time from ev3dev2.button import ButtonBase from ev3dev2.sensor import Sensor +log = logging.getLogger(__name__) + class TouchSensor(Sensor): """ @@ -224,7 +227,7 @@ def color_name(self): def raw(self): """ Red, green, and blue components of the detected color, as a tuple. - + Officially in the range 0-1020 but the values returned will never be that high. We do not yet know why the values returned are low, but pointing the color sensor at a well lit sheet of white paper will return @@ -587,6 +590,7 @@ class GyroSensor(Sensor): def __init__(self, address=None, name_pattern=SYSTEM_DEVICE_NAME_CONVENTION, name_exact=False, **kwargs): super(GyroSensor, self).__init__(address, name_pattern, name_exact, driver_name='lego-ev3-gyro', **kwargs) self._direct = None + self._init_angle = self.angle @property def angle(self): @@ -623,6 +627,15 @@ def tilt_rate(self): self._ensure_mode(self.MODE_TILT_RATE) return self.value(0) + def calibrate(self): + """ + The robot should be perfectly still when you call this + """ + current_mode = self.mode + self._ensure_mode(self.MODE_GYRO_CAL) + time.sleep(2) + self._ensure_mode(current_mode) + def reset(self): """Resets the angle to 0. @@ -633,6 +646,7 @@ def reset(self): """ # 17 comes from inspecting the .vix file of the Gyro sensor block in EV3-G self._direct = self.set_attr_raw(self._direct, 'direct', b'\x11') + self._init_angle = self.angle def wait_until_angle_changed_by(self, delta, direction_sensitive=False): """ @@ -661,6 +675,61 @@ def wait_until_angle_changed_by(self, delta, direction_sensitive=False): while abs(start_angle - self.value(0)) < delta: time.sleep(0.01) + def circle_angle(self): + """ + As the gryo rotates clockwise the angle increases, it will increase + by 360 for each full rotation. As the gyro rotates counter-clockwise + the gyro angle will decrease. + + The angles on a circle have the opposite behavior though, they start + at 0 and increase as you move counter-clockwise around the circle. + + Convert the gyro angle to the angle on a circle. We consider the initial + position of the gyro to be at 90 degrees on the cirlce. + """ + current_angle = self.angle + delta = abs(current_angle - self._init_angle) % 360 + + if delta == 0: + result = 90 + + # the gyro has turned clockwise relative to where we started + elif current_angle > self._init_angle: + + if delta <= 90: + result = 90 - delta + + elif delta <= 180: + result = 360 - (delta - 90) + + elif delta <= 270: + result = 270 - (delta - 180) + + else: + result = 180 - (delta - 270) + + # This can be chatty (but helpful) so save it for a rainy day + # log.info("%s moved clockwise %s degrees to %s" % (self, delta, result)) + + # the gyro has turned counter-clockwise relative to where we started + else: + if delta <= 90: + result = 90 + delta + + elif delta <= 180: + result = 180 + (delta - 90) + + elif delta <= 270: + result = 270 + (delta - 180) + + else: + result = delta - 270 + + # This can be chatty (but helpful) so save it for a rainy day + # log.info("%s moved counter-clockwise %s degrees to %s" % (self, delta, result)) + + return result + class InfraredSensor(Sensor, ButtonBase): """ @@ -856,7 +925,7 @@ def process(self): old state, call the appropriate button event handlers. To use the on_channel1_top_left, etc handlers your program would do something like: - + .. code:: python def top_left_channel_1_action(state): @@ -872,7 +941,7 @@ def bottom_right_channel_4_action(state): while True: ir.process() time.sleep(0.01) - + """ new_state = [] state_diff = [] From 6e03f0a8a0964f3ac57895acb2c7794d795a11d7 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 21 Feb 2020 22:58:20 -0800 Subject: [PATCH 160/172] Fix code formatting in contributing guide --- CONTRIBUTING.rst | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 7c650b1..c9b31e5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -70,34 +70,38 @@ sudo make install To update the module, use the following commands: -```shell -cd ev3dev-lang-python -git pull -sudo make install -``` +.. code-block:: bash + + cd ev3dev-lang-python + git pull + sudo make install + If you are developing micropython support, you can take a shortcut and use the following command to build and deploy the micropython files only: -```shell -cd ev3dev-lang-python -sudo make micropython-install -``` + +.. code-block:: bash + + cd ev3dev-lang-python + sudo make micropython-install To re-install the latest release, use the following command: -```shell -sudo apt-get --reinstall install python3-ev3dev2 -``` + +.. code-block:: bash + + sudo apt-get --reinstall install python3-ev3dev2 Or, to update your current ev3dev2 to the latest release, use the following commands: -```shell -sudo apt update -sudo apt install --only-upgrade micropython-ev3dev2 -``` + +.. code-block:: bash + + sudo apt update + sudo apt install --only-upgrade micropython-ev3dev2 .. _ev3dev: http://ev3dev.org -.. _FAQ: https://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html \ No newline at end of file +.. _FAQ: https://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html From d5db7299ff87830fbd8c78e9721fab6c5bc9cf2c Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sat, 21 Mar 2020 16:08:30 -0400 Subject: [PATCH 161/172] MoveTank turn_degrees convert speed to SpeedValue object (#724) --- debian/changelog | 1 + ev3dev2/motor.py | 137 +++++++++++++++++++++++++---------------------- 2 files changed, 73 insertions(+), 65 deletions(-) diff --git a/debian/changelog b/debian/changelog index ecc07ef..0121feb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -6,6 +6,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium * format code via yapf * MoveDifferential use gyro for better accuracy. Make MoveTank and MoveDifferential "turn" APIs more consistent. + * MoveTank turn_degrees convert speed to SpeedValue object [Matěj Volf] * LED animation fix duration None diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 2a478b1..7a03a5a 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -76,6 +76,10 @@ raise Exception("Unsupported platform '%s'" % platform) +class SpeedInvalid(ValueError): + pass + + class SpeedValue(object): """ A base class for other unit types. Don't use this directly; instead, see @@ -108,14 +112,14 @@ class SpeedPercent(SpeedValue): """ Speed as a percentage of the motor's maximum rated speed. """ - def __init__(self, percent): - assert -100 <= percent <= 100,\ - "{} is an invalid percentage, must be between -100 and 100 (inclusive)".format(percent) - + def __init__(self, percent, desc=None): + if percent < -100 or percent > 100: + raise SpeedInvalid("invalid percentage {}, must be between -100 and 100 (inclusive)".format(percent)) self.percent = percent + self.desc = desc def __str__(self): - return str(self.percent) + "%" + return "{} ".format(self.desc) if self.desc else "" + str(self.percent) + "%" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) @@ -132,11 +136,12 @@ class SpeedNativeUnits(SpeedValue): """ Speed in tacho counts per second. """ - def __init__(self, native_counts): + def __init__(self, native_counts, desc=None): self.native_counts = native_counts + self.desc = desc def __str__(self): - return "{:.2f}".format(self.native_counts) + " counts/sec" + return "{} ".format(self.desc) if self.desc else "" + "{:.2f}".format(self.native_counts) + " counts/sec" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) @@ -146,6 +151,9 @@ def to_native_units(self, motor=None): """ Return this SpeedNativeUnits as a number """ + if self.native_counts > motor.max_speed: + raise SpeedInvalid("invalid native-units: {} max speed {}, {} was requested".format( + motor, motor.max_speed, self.native_counts)) return self.native_counts @@ -153,11 +161,12 @@ class SpeedRPS(SpeedValue): """ Speed in rotations-per-second. """ - def __init__(self, rotations_per_second): + def __init__(self, rotations_per_second, desc=None): self.rotations_per_second = rotations_per_second + self.desc = desc def __str__(self): - return str(self.rotations_per_second) + " rot/sec" + return "{} ".format(self.desc) if self.desc else "" + str(self.rotations_per_second) + " rot/sec" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) @@ -167,9 +176,9 @@ def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired rotations-per-second """ - assert abs(self.rotations_per_second) <= motor.max_rps,\ - "invalid rotations-per-second: {} max RPS is {}, {} was requested".format( - motor, motor.max_rps, self.rotations_per_second) + if abs(self.rotations_per_second) > motor.max_rps: + raise SpeedInvalid("invalid rotations-per-second: {} max RPS is {}, {} was requested".format( + motor, motor.max_rps, self.rotations_per_second)) return self.rotations_per_second / motor.max_rps * motor.max_speed @@ -177,11 +186,12 @@ class SpeedRPM(SpeedValue): """ Speed in rotations-per-minute. """ - def __init__(self, rotations_per_minute): + def __init__(self, rotations_per_minute, desc=None): self.rotations_per_minute = rotations_per_minute + self.desc = desc def __str__(self): - return str(self.rotations_per_minute) + " rot/min" + return "{} ".format(self.desc) if self.desc else "" + str(self.rotations_per_minute) + " rot/min" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) @@ -191,9 +201,9 @@ def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired rotations-per-minute """ - assert abs(self.rotations_per_minute) <= motor.max_rpm,\ - "invalid rotations-per-minute: {} max RPM is {}, {} was requested".format( - motor, motor.max_rpm, self.rotations_per_minute) + if abs(self.rotations_per_minute) > motor.max_rpm: + raise SpeedInvalid("invalid rotations-per-minute: {} max RPM is {}, {} was requested".format( + motor, motor.max_rpm, self.rotations_per_minute)) return self.rotations_per_minute / motor.max_rpm * motor.max_speed @@ -201,11 +211,12 @@ class SpeedDPS(SpeedValue): """ Speed in degrees-per-second. """ - def __init__(self, degrees_per_second): + def __init__(self, degrees_per_second, desc=None): self.degrees_per_second = degrees_per_second + self.desc = desc def __str__(self): - return str(self.degrees_per_second) + " deg/sec" + return "{} ".format(self.desc) if self.desc else "" + str(self.degrees_per_second) + " deg/sec" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) @@ -215,9 +226,9 @@ def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired degrees-per-second """ - assert abs(self.degrees_per_second) <= motor.max_dps,\ - "invalid degrees-per-second: {} max DPS is {}, {} was requested".format( - motor, motor.max_dps, self.degrees_per_second) + if abs(self.degrees_per_second) > motor.max_dps: + raise SpeedInvalid("invalid degrees-per-second: {} max DPS is {}, {} was requested".format( + motor, motor.max_dps, self.degrees_per_second)) return self.degrees_per_second / motor.max_dps * motor.max_speed @@ -225,11 +236,12 @@ class SpeedDPM(SpeedValue): """ Speed in degrees-per-minute. """ - def __init__(self, degrees_per_minute): + def __init__(self, degrees_per_minute, desc=None): self.degrees_per_minute = degrees_per_minute + self.desc = desc def __str__(self): - return str(self.degrees_per_minute) + " deg/min" + return "{} ".format(self.desc) if self.desc else "" + str(self.degrees_per_minute) + " deg/min" def __mul__(self, other): assert isinstance(other, (float, int)), "{} can only be multiplied by an int or float".format(self) @@ -239,12 +251,23 @@ def to_native_units(self, motor): """ Return the native speed measurement required to achieve desired degrees-per-minute """ - assert abs(self.degrees_per_minute) <= motor.max_dpm,\ - "invalid degrees-per-minute: {} max DPM is {}, {} was requested".format( - motor, motor.max_dpm, self.degrees_per_minute) + if abs(self.degrees_per_minute) > motor.max_dpm: + raise SpeedInvalid("invalid degrees-per-minute: {} max DPM is {}, {} was requested".format( + motor, motor.max_dpm, self.degrees_per_minute)) return self.degrees_per_minute / motor.max_dpm * motor.max_speed +def speed_to_speedvalue(speed, desc=None): + """ + If ``speed`` is not a ``SpeedValue`` object, treat it as a percentage. + Returns a ``SpeedValue`` object. + """ + if isinstance(speed, SpeedValue): + return speed + else: + return SpeedPercent(speed, desc) + + class Motor(Device): """ The motor class provides a uniform interface for using motors with @@ -937,13 +960,7 @@ def wait_while(self, s, timeout=None): return self.wait(lambda state: s not in state, timeout) def _speed_native_units(self, speed, label=None): - - # If speed is not a SpeedValue object we treat it as a percentage - if not isinstance(speed, SpeedValue): - assert -100 <= speed <= 100,\ - "{}{} is an invalid speed percentage, must be between -100 and 100 (inclusive)".format("" if label is None else (label + ": ") , speed) - speed = SpeedPercent(speed) - + speed = speed_to_speedvalue(speed, label) return speed.to_native_units(self) def _set_rel_position_degrees_and_speed_sp(self, degrees, speed): @@ -2047,7 +2064,7 @@ def follow_line(self, follow_for=follow_for_ms, ms=4500 ) - except Exception: + except LineFollowErrorTooFast: tank.stop() raise """ @@ -2062,8 +2079,8 @@ def follow_line(self, last_error = 0.0 derivative = 0.0 off_line_count = 0 + speed = speed_to_speedvalue(speed) speed_native_units = speed.to_native_units(self.left_motor) - MAX_SPEED = SpeedNativeUnits(self.max_speed) while follow_for(self, **kwargs): reflected_light_intensity = self._cs.reflected_light_intensity @@ -2079,16 +2096,6 @@ def follow_line(self, left_speed = SpeedNativeUnits(speed_native_units - turn_native_units) right_speed = SpeedNativeUnits(speed_native_units + turn_native_units) - if left_speed > MAX_SPEED: - log.info("%s: left_speed %s is greater than MAX_SPEED %s" % (self, left_speed, MAX_SPEED)) - self.stop() - raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") - - if right_speed > MAX_SPEED: - log.info("%s: right_speed %s is greater than MAX_SPEED %s" % (self, right_speed, MAX_SPEED)) - self.stop() - raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") - # Have we lost the line? if reflected_light_intensity >= white: off_line_count += 1 @@ -2102,7 +2109,12 @@ def follow_line(self, if sleep_time: time.sleep(sleep_time) - self.on(left_speed, right_speed) + try: + self.on(left_speed, right_speed) + except SpeedInvalid as e: + log.exception(e) + self.stop() + raise LineFollowErrorTooFast("The robot is moving too fast to follow the line") self.stop() @@ -2150,15 +2162,16 @@ def follow_gyro_angle(self, # Initialize the tank's gyro sensor tank.gyro = GyroSensor() + # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 + tank.gyro.calibrate() + try: - # Calibrate the gyro to eliminate drift, and to initialize the current angle as 0 - tank.gyro.calibrate() - # Follow the line for 4500ms + # Follow the target_angle for 4500ms tank.follow_gyro_angle( kp=11.3, ki=0.05, kd=3.2, speed=SpeedPercent(30), - target_angle=0 + target_angle=0, follow_for=follow_for_ms, ms=4500 ) @@ -2173,10 +2186,8 @@ def follow_gyro_angle(self, integral = 0.0 last_error = 0.0 derivative = 0.0 + speed = speed_to_speedvalue(speed) speed_native_units = speed.to_native_units(self.left_motor) - MAX_SPEED = SpeedNativeUnits(self.max_speed) - - assert speed_native_units <= MAX_SPEED, "Speed exceeds the max speed of the motors" while follow_for(self, **kwargs): current_angle = self._gyro.angle @@ -2189,20 +2200,15 @@ def follow_gyro_angle(self, left_speed = SpeedNativeUnits(speed_native_units - turn_native_units) right_speed = SpeedNativeUnits(speed_native_units + turn_native_units) - if abs(left_speed) > MAX_SPEED: - log.info("%s: left_speed %s is greater than MAX_SPEED %s" % (self, left_speed, MAX_SPEED)) - self.stop() - raise FollowGyroAngleErrorTooFast("The robot is moving too fast to follow the angle") - - if abs(right_speed) > MAX_SPEED: - log.info("%s: right_speed %s is greater than MAX_SPEED %s" % (self, right_speed, MAX_SPEED)) - self.stop() - raise FollowGyroAngleErrorTooFast("The robot is moving too fast to follow the angle") - if sleep_time: time.sleep(sleep_time) - self.on(left_speed, right_speed) + try: + self.on(left_speed, right_speed) + except SpeedInvalid as e: + log.exception(e) + self.stop() + raise FollowGyroAngleErrorTooFast("The robot is moving too fast to follow the angle") self.stop() @@ -2254,6 +2260,7 @@ def turn_degrees(self, speed, target_angle, brake=True, error_margin=2, sleep_ti raise DeviceNotDefined( "The 'gyro' variable must be defined with a GyroSensor. Example: tank.gyro = GyroSensor()") + speed = speed_to_speedvalue(speed) speed_native_units = speed.to_native_units(self.left_motor) target_angle = self._gyro.angle + target_angle From 223559c8638e9dcb6c161479ca69ecd70be10bb3 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 22 Mar 2020 17:44:49 -0700 Subject: [PATCH 162/172] Add link to BrickPi example in README --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 23af819..9403883 100644 --- a/README.rst +++ b/README.rst @@ -114,6 +114,8 @@ If you'd like to use a sensor on a specific port, specify the port like this: ts = TouchSensor(INPUT_1) +*Heads-up:* If you are using a BrickPi instead of an EV3, you will need to manually configure the sensor. See the example here: https://github.com/ev3dev/ev3dev-lang-python-demo/blob/stretch/platform/brickpi3-motor-and-sensor.py + Running a single motor ~~~~~~~~~~~~~~~~~~~~~~ From a56da3fdcb97e096bd2f108dbb3a0d43a1efac23 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 22 Mar 2020 18:10:12 -0700 Subject: [PATCH 163/172] Add docs links to brickpi3 sensor example --- docs/ports.rst | 3 ++- docs/sensors.rst | 5 +++++ ev3dev2/port.py | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/ports.rst b/docs/ports.rst index 33f5d93..92c5b6a 100644 --- a/docs/ports.rst +++ b/docs/ports.rst @@ -2,7 +2,8 @@ Lego Port ========= The `LegoPort` class is only needed when manually reconfiguring input/output -ports. Most users can ignore this page. +ports. This is necessary on the BrickPi but not other platforms, such as the +EV3. Most users can ignore this page. .. autoclass:: ev3dev2.port.LegoPort :members: \ No newline at end of file diff --git a/docs/sensors.rst b/docs/sensors.rst index 368ee3e..0c86881 100644 --- a/docs/sensors.rst +++ b/docs/sensors.rst @@ -3,6 +3,11 @@ Sensor classes .. contents:: :local: +*Note:* If you are using a BrickPi rather than an EV3, you will need to manually +configure the ports before interacting with your sensors. See the example +`here `_. + + Dedicated sensor classes ------------------------ diff --git a/ev3dev2/port.py b/ev3dev2/port.py index da62967..264e37b 100644 --- a/ev3dev2/port.py +++ b/ev3dev2/port.py @@ -38,6 +38,9 @@ class LegoPort(Device): the LEGO MINDSTORMS EV3 Intelligent Brick, the LEGO WeDo USB hub and various sensor multiplexers from 3rd party manufacturers. + See the following example for using this class to configure sensors: + https://github.com/ev3dev/ev3dev-lang-python-demo/blob/stretch/platform/brickpi3-motor-and-sensor.py + Some types of ports may have multiple modes of operation. For example, the input ports on the EV3 brick can communicate with sensors using UART, I2C or analog validate signals - but not all at the same time. Therefore there From a4ed6b732fd2e41579b6aa69a2056ebbdc6e9ad8 Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Sun, 22 Mar 2020 21:14:13 -0400 Subject: [PATCH 164/172] Flake8 (#723) --- .flake8.cfg | 10 +++ Makefile | 1 + debian/changelog | 1 + docs/conf.py | 106 +++++++++++------------ ev3dev2/__init__.py | 32 ++++--- ev3dev2/button.py | 13 ++- ev3dev2/control/webserver.py | 2 +- ev3dev2/display.py | 7 +- ev3dev2/fonts/__init__.py | 1 - ev3dev2/led.py | 7 +- ev3dev2/motor.py | 64 +++++++------- ev3dev2/power.py | 3 +- ev3dev2/sensor/__init__.py | 32 ++++--- ev3dev2/sensor/lego.py | 46 +++++----- ev3dev2/sound.py | 36 +++++--- git_version.py | 13 +-- tests/api_tests.py | 45 +++++----- tests/motor/motor_motion_unittest.py | 3 - tests/motor/motor_param_unittest.py | 7 +- tests/motor/motor_run_direct_unittest.py | 3 - utils/line-follower-find-kp-ki-kd.py | 4 +- utils/move_differential.py | 65 +++++++------- utils/move_motor.py | 1 - 23 files changed, 249 insertions(+), 253 deletions(-) create mode 100644 .flake8.cfg diff --git a/.flake8.cfg b/.flake8.cfg new file mode 100644 index 0000000..ead0c3d --- /dev/null +++ b/.flake8.cfg @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 120 +max-complexity = 40 +ignore = + W504 +exclude = + .git + ./ev3dev2/auto.py + ./tests/fake-sys/ + ./venv diff --git a/Makefile b/Makefile index 00429e4..f16352c 100644 --- a/Makefile +++ b/Makefile @@ -23,3 +23,4 @@ clean: format: yapf --style .yapf.cfg --in-place --exclude tests/fake-sys/ --recursive . + python3 -m flake8 --config=.flake8.cfg . diff --git a/debian/changelog b/debian/changelog index 0121feb..ac750c2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -7,6 +7,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium * MoveDifferential use gyro for better accuracy. Make MoveTank and MoveDifferential "turn" APIs more consistent. * MoveTank turn_degrees convert speed to SpeedValue object + * correct flake8 errors [Matěj Volf] * LED animation fix duration None diff --git a/docs/conf.py b/docs/conf.py index c43d424..6019681 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,30 +15,28 @@ import sys import os -import shlex import subprocess +import sphinx_bootstrap_theme +from recommonmark.parser import CommonMarkParser +from recommonmark.transform import AutoStructify sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from git_version import git_version +from git_version import git_version # noqa: E402 on_rtd = os.environ.get('READTHEDOCS', None) == 'True' if on_rtd: subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'sphinx_bootstrap_theme', 'recommonmark', 'evdev']) -import sphinx_bootstrap_theme -from recommonmark.parser import CommonMarkParser -from recommonmark.transform import AutoStructify - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -61,7 +59,7 @@ source_suffix = ['.rst', '.md'] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -89,9 +87,9 @@ # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -99,27 +97,27 @@ # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -138,26 +136,26 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -167,62 +165,62 @@ # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'python-ev3devdoc' @@ -231,16 +229,16 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'preamble': '', + # 'preamble': '', # Latex figure (float) alignment - #'figure_align': 'htbp', + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples @@ -252,23 +250,23 @@ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -277,7 +275,7 @@ man_pages = [(master_doc, 'python-ev3dev', 'python-ev3dev Documentation', [author], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -290,16 +288,16 @@ ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False autodoc_member_order = 'bysource' diff --git a/ev3dev2/__init__.py b/ev3dev2/__init__.py index d614b67..5e776fb 100644 --- a/ev3dev2/__init__.py +++ b/ev3dev2/__init__.py @@ -24,6 +24,20 @@ # ----------------------------------------------------------------------------- import sys +import os +import io +import fnmatch +import re +import stat +import errno +from os.path import abspath + +try: + # if we are in a released build, there will be an auto-generated "version" + # module + from .version import __version__ +except ImportError: + __version__ = "" if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') @@ -40,22 +54,6 @@ def chain_exception(exception, cause): raise exception from cause -try: - # if we are in a released build, there will be an auto-generated "version" - # module - from .version import __version__ -except ImportError: - __version__ = "" - -import os -import io -import fnmatch -import re -import stat -import errno -from os.path import abspath - - def get_current_platform(): """ Look in /sys/class/board-info/ to determine the platform type. @@ -125,7 +123,7 @@ def matches(attribute, pattern): try: with io.FileIO(attribute) as f: value = f.read().strip().decode() - except: + except Exception: return False if isinstance(pattern, list): diff --git a/ev3dev2/button.py b/ev3dev2/button.py index c13c8e2..50dd673 100644 --- a/ev3dev2/button.py +++ b/ev3dev2/button.py @@ -24,16 +24,10 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - from ev3dev2.stopwatch import StopWatch from ev3dev2 import get_current_platform, is_micropython, library_load_warning_message from logging import getLogger -log = getLogger(__name__) - # Import the button filenames, this is platform specific platform = get_current_platform() @@ -58,6 +52,11 @@ else: raise Exception("Unsupported platform '%s'" % platform) +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') + +log = getLogger(__name__) + class MissingButton(Exception): pass @@ -199,7 +198,7 @@ def backspace(self): # micropython implementation -if is_micropython(): +if is_micropython(): # noqa: C901 try: # This is a linux-specific module. diff --git a/ev3dev2/control/webserver.py b/ev3dev2/control/webserver.py index 2fa6bfc..d148af4 100644 --- a/ev3dev2/control/webserver.py +++ b/ev3dev2/control/webserver.py @@ -222,7 +222,7 @@ def do_GET(self): elif action == 'log': msg = ''.join(path[3:]) - re_msg = re.search('^(.*)\?', msg) + re_msg = re.search(r'^(.*)\?', msg) if re_msg: msg = re_msg.group(1) diff --git a/ev3dev2/display.py b/ev3dev2/display.py index 9a2303b..de875f2 100644 --- a/ev3dev2/display.py +++ b/ev3dev2/display.py @@ -24,10 +24,6 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - import os import mmap import ctypes @@ -37,6 +33,9 @@ from . import get_current_platform, library_load_warning_message from struct import pack +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') + log = logging.getLogger(__name__) try: diff --git a/ev3dev2/fonts/__init__.py b/ev3dev2/fonts/__init__.py index 64b1cce..95b4804 100644 --- a/ev3dev2/fonts/__init__.py +++ b/ev3dev2/fonts/__init__.py @@ -21,7 +21,6 @@ def load(name): try: font_dir = os.path.dirname(__file__) pil_file = os.path.join(font_dir, '{}.pil'.format(name)) - pbm_file = os.path.join(font_dir, '{}.pbm'.format(name)) return ImageFont.load(pil_file) except FileNotFoundError: raise Exception('Failed to load font "{}". '.format(name) + diff --git a/ev3dev2/led.py b/ev3dev2/led.py index 369a199..d4f21a8 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -24,10 +24,6 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - import os import stat import time @@ -37,6 +33,9 @@ from ev3dev2.stopwatch import StopWatch from time import sleep +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') + # Import the LED settings, this is platform specific platform = get_current_platform() diff --git a/ev3dev2/motor.py b/ev3dev2/motor.py index 7a03a5a..6e61ecf 100644 --- a/ev3dev2/motor.py +++ b/ev3dev2/motor.py @@ -21,10 +21,6 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - import math import select import time @@ -42,39 +38,41 @@ from ev3dev2 import get_current_platform, Device, list_device_names, DeviceNotDefined, ThreadNotRunning from ev3dev2.stopwatch import StopWatch -log = getLogger(__name__) - -# The number of milliseconds we wait for the state of a motor to -# update to 'running' in the "on_for_XYZ" methods of the Motor class -WAIT_RUNNING_TIMEOUT = 100 - # OUTPUT ports have platform specific values that we must import platform = get_current_platform() if platform == 'ev3': - from ev3dev2._platform.ev3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.ev3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D # noqa: F401 elif platform == 'evb': - from ev3dev2._platform.evb import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.evb import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D # noqa: F401 elif platform == 'pistorms': - from ev3dev2._platform.pistorms import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.pistorms import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D # noqa: F401 elif platform == 'brickpi': - from ev3dev2._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.brickpi import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D # noqa: F401 elif platform == 'brickpi3': - from ev3dev2._platform.brickpi3 import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, \ - OUTPUT_E, OUTPUT_F, OUTPUT_G, OUTPUT_H, \ - OUTPUT_I, OUTPUT_J, OUTPUT_K, OUTPUT_L, \ - OUTPUT_M, OUTPUT_N, OUTPUT_O, OUTPUT_P + from ev3dev2._platform.brickpi3 import ( # noqa: F401 + OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, OUTPUT_E, OUTPUT_F, OUTPUT_G, OUTPUT_H, OUTPUT_I, OUTPUT_J, OUTPUT_K, + OUTPUT_L, OUTPUT_M, OUTPUT_N, OUTPUT_O, OUTPUT_P) elif platform == 'fake': - from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D + from ev3dev2._platform.fake import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D # noqa: F401 else: raise Exception("Unsupported platform '%s'" % platform) +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') + +log = getLogger(__name__) + +# The number of milliseconds we wait for the state of a motor to +# update to 'running' in the "on_for_XYZ" methods of the Motor class +WAIT_RUNNING_TIMEOUT = 100 + class SpeedInvalid(ValueError): pass @@ -1140,7 +1138,8 @@ class ActuonixL1250Motor(Motor): """ Actuonix L12 50 linear servo motor. - Same as :class:`Motor`, except it will only successfully initialize if it finds an Actuonix L12 50 linear servo motor + Same as :class:`Motor`, except it will only successfully initialize if it finds an + Actuonix L12 50 linear servo motor """ SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME @@ -1160,7 +1159,8 @@ class ActuonixL12100Motor(Motor): """ Actuonix L12 100 linear servo motor. - Same as :class:`Motor`, except it will only successfully initialize if it finds an Actuonix L12 100 linear servo motor + Same as :class:`Motor`, except it will only successfully initialize if it finds an + Actuonix L12 100linear servo motor """ SYSTEM_CLASS_NAME = Motor.SYSTEM_CLASS_NAME @@ -1673,7 +1673,7 @@ def set_args(self, **kwargs): try: setattr(motor, key, kwargs[key]) except AttributeError as e: - #log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) + # log.error("%s %s cannot set %s to %s" % (self, motor, key, kwargs[key])) raise e def set_polarity(self, polarity, motors=None): @@ -1692,12 +1692,12 @@ def _run_command(self, **kwargs): for motor in motors: for key in kwargs: if key not in ('motors', 'commands'): - #log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) + # log.debug("%s: %s set %s to %s" % (self, motor, key, kwargs[key])) setattr(motor, key, kwargs[key]) for motor in motors: motor.command = kwargs['command'] - #log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) + # log.debug("%s: %s command %s" % (self, motor, kwargs['command'])) def run_forever(self, **kwargs): kwargs['command'] = LargeMotor.COMMAND_RUN_FOREVER @@ -2525,10 +2525,10 @@ def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): right_speed = speed left_speed = float(circle_inner_mm / circle_outer_mm) * right_speed - log.debug( - "%s: arc %s, radius %s, distance %s, left-speed %s, right-speed %s, circle_outer_mm %s, circle_middle_mm %s, circle_inner_mm %s" - % (self, "right" if arc_right else "left", radius_mm, distance_mm, left_speed, right_speed, circle_outer_mm, - circle_middle_mm, circle_inner_mm)) + log.debug("%s: arc %s, radius %s, distance %s, left-speed %s, right-speed %s" % + (self, "right" if arc_right else "left", radius_mm, distance_mm, left_speed, right_speed)) + log.debug("%s: circle_outer_mm %s, circle_middle_mm %s, circle_inner_mm %s" % + (self, circle_outer_mm, circle_middle_mm, circle_inner_mm)) # We know we want the middle circle to be of length distance_mm so # calculate the percentage of circle_middle_mm we must travel for the @@ -2542,10 +2542,10 @@ def _on_arc(self, speed, radius_mm, distance_mm, brake, block, arc_right): outer_wheel_rotations = float(circle_outer_final_mm / self.wheel.circumference_mm) outer_wheel_degrees = outer_wheel_rotations * 360 - log.debug( - "%s: arc %s, circle_middle_percentage %s, circle_outer_final_mm %s, outer_wheel_rotations %s, outer_wheel_degrees %s" - % (self, "right" if arc_right else "left", circle_middle_percentage, circle_outer_final_mm, - outer_wheel_rotations, outer_wheel_degrees)) + log.debug("%s: arc %s, circle_middle_percentage %s, circle_outer_final_mm %s, " % + (self, "right" if arc_right else "left", circle_middle_percentage, circle_outer_final_mm)) + log.debug("%s: outer_wheel_rotations %s, outer_wheel_degrees %s" % + (self, outer_wheel_rotations, outer_wheel_degrees)) MoveTank.on_for_degrees(self, left_speed, right_speed, outer_wheel_degrees, brake, block) diff --git a/ev3dev2/power.py b/ev3dev2/power.py index 81d3201..d33c38f 100644 --- a/ev3dev2/power.py +++ b/ev3dev2/power.py @@ -24,12 +24,11 @@ # ----------------------------------------------------------------------------- import sys +from ev3dev2 import Device if sys.version_info < (3, 4): raise SystemError('Must be using Python 3.4 or higher') -from ev3dev2 import Device - class PowerSupply(Device): """ diff --git a/ev3dev2/sensor/__init__.py b/ev3dev2/sensor/__init__.py index 5ec113f..fc63dca 100644 --- a/ev3dev2/sensor/__init__.py +++ b/ev3dev2/sensor/__init__.py @@ -24,11 +24,6 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - -import numbers from os.path import abspath from struct import unpack from ev3dev2 import get_current_platform, Device, list_device_names @@ -37,29 +32,31 @@ platform = get_current_platform() if platform == 'ev3': - from ev3dev2._platform.ev3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.ev3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4 # noqa: F401 elif platform == 'evb': - from ev3dev2._platform.evb import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.evb import INPUT_1, INPUT_2, INPUT_3, INPUT_4 # noqa: F401 elif platform == 'pistorms': - from ev3dev2._platform.pistorms import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.pistorms import INPUT_1, INPUT_2, INPUT_3, INPUT_4 # noqa: F401 elif platform == 'brickpi': - from ev3dev2._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.brickpi import INPUT_1, INPUT_2, INPUT_3, INPUT_4 # noqa: F401 elif platform == 'brickpi3': - from ev3dev2._platform.brickpi3 import INPUT_1, INPUT_2, INPUT_3, INPUT_4, \ - INPUT_5, INPUT_6, INPUT_7, INPUT_8, \ - INPUT_9, INPUT_10, INPUT_11, INPUT_12, \ - INPUT_13, INPUT_14, INPUT_15, INPUT_16 + from ev3dev2._platform.brickpi3 import ( # noqa: F401 + INPUT_1, INPUT_2, INPUT_3, INPUT_4, INPUT_5, INPUT_6, INPUT_7, INPUT_8, INPUT_9, INPUT_10, INPUT_11, INPUT_12, + INPUT_13, INPUT_14, INPUT_15, INPUT_16) elif platform == 'fake': - from ev3dev2._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 + from ev3dev2._platform.fake import INPUT_1, INPUT_2, INPUT_3, INPUT_4 # noqa: F401 else: raise Exception("Unsupported platform '%s'" % platform) +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') + class Sensor(Device): """ @@ -241,7 +238,7 @@ def bin_data(self, fmt=None): (28,) """ - if self._bin_data_size == None: + if self._bin_data_size is None: self._bin_data_size = { "u8": 1, "s8": 1, @@ -252,13 +249,14 @@ def bin_data(self, fmt=None): "float": 4 }.get(self.bin_data_format, 1) * self.num_values - if None == self._bin_data: + if self._bin_data is None: self._bin_data = self._attribute_file_open('bin_data') self._bin_data.seek(0) raw = bytearray(self._bin_data.read(self._bin_data_size)) - if fmt is None: return raw + if fmt is None: + return raw return unpack(fmt, raw) diff --git a/ev3dev2/sensor/lego.py b/ev3dev2/sensor/lego.py index 99a24eb..281d983 100644 --- a/ev3dev2/sensor/lego.py +++ b/ev3dev2/sensor/lego.py @@ -24,15 +24,14 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - import logging import time from ev3dev2.button import ButtonBase from ev3dev2.sensor import Sensor +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') + log = logging.getLogger(__name__) @@ -356,36 +355,36 @@ def hls(self): L: color lightness S: color saturation """ - (r, g, b) = self.rgb - maxc = max(r, g, b) - minc = min(r, g, b) - l = (minc + maxc) / 2.0 + (red, green, blue) = self.rgb + maxc = max(red, green, blue) + minc = min(red, green, blue) + luminance = (minc + maxc) / 2.0 if minc == maxc: - return 0.0, l, 0.0 + return 0.0, luminance, 0.0 - if l <= 0.5: - s = (maxc - minc) / (maxc + minc) + if luminance <= 0.5: + saturation = (maxc - minc) / (maxc + minc) else: if 2.0 - maxc - minc == 0: - s = 0 + saturation = 0 else: - s = (maxc - minc) / (2.0 - maxc - minc) + saturation = (maxc - minc) / (2.0 - maxc - minc) - rc = (maxc - r) / (maxc - minc) - gc = (maxc - g) / (maxc - minc) - bc = (maxc - b) / (maxc - minc) + rc = (maxc - red) / (maxc - minc) + gc = (maxc - green) / (maxc - minc) + bc = (maxc - blue) / (maxc - minc) - if r == maxc: - h = bc - gc - elif g == maxc: - h = 2.0 + rc - bc + if red == maxc: + hue = bc - gc + elif green == maxc: + hue = 2.0 + rc - bc else: - h = 4.0 + gc - rc + hue = 4.0 + gc - rc - h = (h / 6.0) % 1.0 + hue = (hue / 6.0) % 1.0 - return (h, l, s) + return (hue, luminance, saturation) @property def red(self): @@ -960,7 +959,6 @@ def bottom_right_channel_4_action(state): if (button, channel) not in new_state and (button, channel) in self._state: state_diff.append((button, channel)) - old_state = self._state self._state = new_state for (button, channel) in state_diff: diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 533751a..7b38ae5 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -24,14 +24,13 @@ # ----------------------------------------------------------------------------- import sys - -if sys.version_info < (3, 4): - raise SystemError('Must be using Python 3.4 or higher') - -from ev3dev2 import is_micropython import os import re from time import sleep +from ev3dev2 import is_micropython + +if sys.version_info < (3, 4): + raise SystemError('Must be using Python 3.4 or higher') if not is_micropython(): import shlex @@ -133,7 +132,7 @@ def _audio_command(self, command, play_type): return None else: - with open(os.devnull, 'w') as n: + with open(os.devnull, 'w'): if play_type == Sound.PLAY_WAIT_FOR_COMPLETE: processes = get_command_processes(command) @@ -164,7 +163,8 @@ def beep(self, args='', play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``beep`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the + spawn subprocess from ``subprocess.Popen``; ``None`` otherwise .. _`beep man page`: https://linux.die.net/man/1/beep .. _`linux beep music`: https://www.google.com/search?q=linux+beep+music @@ -204,12 +204,15 @@ def tone(self, *args, play_type=PLAY_WAIT_FOR_COMPLETE): Have also a look at :py:meth:`play_song` for a more musician-friendly way of doing, which uses the conventional notation for notes and durations. - :param list[tuple(float,float,float)] tone_sequence: The sequence of tones to play. The first number of each tuple is frequency in Hz, the second is duration in milliseconds, and the third is delay in milliseconds between this and the next tone in the sequence. + :param list[tuple(float,float,float)] tone_sequence: The sequence of tones to play. The first + number of each tuple is frequency in Hz, the second is duration in milliseconds, and the + third is delay in milliseconds between this and the next tone in the sequence. :param play_type: The behavior of ``tone`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the + spawn subprocess from ``subprocess.Popen``; ``None`` otherwise .. rubric:: tone(frequency, duration) @@ -221,7 +224,8 @@ def tone(self, *args, play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``tone`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the + spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ def play_tone_sequence(tone_sequence): def beep_args(frequency=None, duration=None, delay=None): @@ -255,7 +259,8 @@ def play_tone(self, frequency, duration, delay=0.0, volume=100, play_type=PLAY_W :param play_type: The behavior of ``play_tone`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns + the PID of the underlying beep command; ``None`` otherwise :raises ValueError: if invalid parameter """ @@ -285,7 +290,8 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE :param play_type: The behavior of ``play_note`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the PID of the underlying beep command; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns + the PID of the underlying beep command; ``None`` otherwise :raises ValueError: is invalid parameter (note, duration,...) """ @@ -311,7 +317,8 @@ def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): :param play_type: The behavior of ``play_file`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the + spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ if not 0 < volume <= 100: raise ValueError('invalid volume (%s)' % volume) @@ -338,7 +345,8 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA :param play_type: The behavior of ``speak`` once playback has been initiated :type play_type: ``Sound.PLAY_WAIT_FOR_COMPLETE``, ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` or ``Sound.PLAY_LOOP`` - :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the spawn subprocess from ``subprocess.Popen``; ``None`` otherwise + :return: When python3 is used and ``Sound.PLAY_NO_WAIT_FOR_COMPLETE`` is specified, returns the + spawn subprocess from ``subprocess.Popen``; ``None`` otherwise """ self._validate_play_type(play_type) self.set_volume(volume) diff --git a/git_version.py b/git_version.py index 0d77547..2744309 100644 --- a/git_version.py +++ b/git_version.py @@ -1,10 +1,11 @@ from subprocess import Popen, PIPE -import os, sys +import os +import sys pyver = sys.version_info -#---------------------------------------------------------------------------- +# ---------------------------------------------------------------------------- # Get version string from git # # Author: Douglas Creager @@ -12,7 +13,7 @@ # # PEP 386 adaptation from # https://gist.github.com/ilogue/2567778/f6661ea2c12c070851b2dfb4da8840a6641914bc -#---------------------------------------------------------------------------- +# ---------------------------------------------------------------------------- def call_git_describe(abbrev=4): try: p = Popen(['git', 'describe', '--exclude', 'ev3dev-*', '--abbrev=%d' % abbrev], stdout=PIPE, stderr=PIPE) @@ -20,7 +21,7 @@ def call_git_describe(abbrev=4): line = p.stdout.readlines()[0] return line.strip().decode('utf8') - except: + except Exception: return None @@ -29,7 +30,7 @@ def read_release_version(): with open('{}/RELEASE-VERSION'.format(os.path.dirname(__file__)), 'r') as f: version = f.readlines()[0] return version.strip() - except: + except Exception: return None @@ -59,7 +60,7 @@ def git_version(abbrev=4): if version is None: version = release_version else: - #adapt to PEP 386 compatible versioning scheme + # adapt to PEP 386 compatible versioning scheme version = pep386adapt(version) # If we still don't have anything, that's an error. diff --git a/tests/api_tests.py b/tests/api_tests.py index 26b1fff..7ff4533 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -import unittest, sys, os.path +import unittest +import sys +import os.path import os FAKE_SYS = os.path.join(os.path.dirname(__file__), 'fake-sys') @@ -8,22 +10,21 @@ sys.path.append(FAKE_SYS) sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from populate_arena import populate_arena -from clean_arena import clean_arena +from populate_arena import populate_arena # noqa: E402 +from clean_arena import clean_arena # noqa: E402 -import ev3dev2 -from ev3dev2.sensor.lego import InfraredSensor +import ev3dev2 # noqa: E402 +import ev3dev2.stopwatch # noqa: E402 from ev3dev2.motor import \ OUTPUT_A, OUTPUT_B, \ Motor, MediumMotor, LargeMotor, \ MoveTank, MoveSteering, MoveJoystick, \ - SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits - -from ev3dev2.unit import (DistanceMillimeters, DistanceCentimeters, DistanceDecimeters, DistanceMeters, DistanceInches, - DistanceFeet, DistanceYards, DistanceStuds) - -import ev3dev2.stopwatch -from ev3dev2.stopwatch import StopWatch, StopWatchAlreadyStartedException + SpeedPercent, SpeedDPM, SpeedDPS, SpeedRPM, SpeedRPS, SpeedNativeUnits # noqa: E402 +from ev3dev2.sensor.lego import InfraredSensor # noqa: E402 +from ev3dev2.stopwatch import StopWatch, StopWatchAlreadyStartedException # noqa: E402 +from ev3dev2.unit import ( # noqa: E402 + DistanceMillimeters, DistanceCentimeters, DistanceDecimeters, DistanceMeters, DistanceInches, DistanceFeet, + DistanceYards, DistanceStuds) ev3dev2.Device.DEVICE_ROOT_PATH = os.path.join(FAKE_SYS, 'arena') @@ -91,24 +92,24 @@ def test_device(self): clean_arena() populate_arena([('medium_motor', 0, 'outA'), ('infrared_sensor', 0, 'in1')]) - d = ev3dev2.Device('tacho-motor', 'motor*') + ev3dev2.Device('tacho-motor', 'motor*') - d = ev3dev2.Device('tacho-motor', 'motor0') + ev3dev2.Device('tacho-motor', 'motor0') - d = ev3dev2.Device('tacho-motor', 'motor*', driver_name='lego-ev3-m-motor') + ev3dev2.Device('tacho-motor', 'motor*', driver_name='lego-ev3-m-motor') - d = ev3dev2.Device('tacho-motor', 'motor*', address='outA') + ev3dev2.Device('tacho-motor', 'motor*', address='outA') with self.assertRaises(ev3dev2.DeviceNotFound): - d = ev3dev2.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') + ev3dev2.Device('tacho-motor', 'motor*', address='outA', driver_name='not-valid') with self.assertRaises(ev3dev2.DeviceNotFound): - d = ev3dev2.Device('tacho-motor', 'motor*', address='this-does-not-exist') + ev3dev2.Device('tacho-motor', 'motor*', address='this-does-not-exist') - d = ev3dev2.Device('lego-sensor', 'sensor*') + ev3dev2.Device('lego-sensor', 'sensor*') with self.assertRaises(ev3dev2.DeviceNotFound): - d = ev3dev2.Device('this-does-not-exist') + ev3dev2.Device('this-does-not-exist') def test_medium_motor(self): def dummy(self): @@ -146,7 +147,7 @@ def dummy(self): self.assertEqual(m.time_sp, 1000) with self.assertRaises(Exception): - c = m.command + m.command def test_infrared_sensor(self): clean_arena() @@ -424,7 +425,7 @@ def test_stopwatch(self): self.assertEqual(sw.is_elapsed_ms(3000), True) self.assertEqual(sw.is_elapsed_secs(3), True) - set_mock_ticks_ms(1000 * 60 * 75.5) #75.5 minutes + set_mock_ticks_ms(1000 * 60 * 75.5) # 75.5 minutes self.assertEqual(sw.is_started, True) self.assertEqual(sw.value_ms, 1000 * 60 * 75.5) self.assertEqual(sw.value_secs, 60 * 75.5) diff --git a/tests/motor/motor_motion_unittest.py b/tests/motor/motor_motion_unittest.py index be6b7f8..9acece4 100644 --- a/tests/motor/motor_motion_unittest.py +++ b/tests/motor/motor_motion_unittest.py @@ -7,11 +7,8 @@ import unittest import time import ev3dev.ev3 as ev3 - import parameterizedtestcase as ptc -from motor_info import motor_info - class TestMotorMotion(ptc.ParameterizedTestCase): @classmethod diff --git a/tests/motor/motor_param_unittest.py b/tests/motor/motor_param_unittest.py index d10f84e..1c721b9 100644 --- a/tests/motor/motor_param_unittest.py +++ b/tests/motor/motor_param_unittest.py @@ -5,10 +5,7 @@ # http://eli.thegreenplace.net/2011/08/02/python-unit-testing-parametrized-test-cases import unittest -import time -import sys import ev3dev.ev3 as ev3 - import parameterizedtestcase as ptc from motor_info import motor_info @@ -607,8 +604,8 @@ def test_time_sp_after_reset(self): suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedIValue, param=paramsA)) suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorSpeedDValue, param=paramsA)) suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStateValue, param=paramsA)) -suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandValue, param=paramsA)) -suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopCommandsValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopActionValue, param=paramsA)) +suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorStopActionsValue, param=paramsA)) suite.addTest(ptc.ParameterizedTestCase.parameterize(TestTachoMotorTimeSpValue, param=paramsA)) if __name__ == '__main__': diff --git a/tests/motor/motor_run_direct_unittest.py b/tests/motor/motor_run_direct_unittest.py index e874b64..4ae42a9 100755 --- a/tests/motor/motor_run_direct_unittest.py +++ b/tests/motor/motor_run_direct_unittest.py @@ -7,11 +7,8 @@ import unittest import time import ev3dev.ev3 as ev3 - import parameterizedtestcase as ptc -from motor_info import motor_info - class TestMotorRunDirect(ptc.ParameterizedTestCase): @classmethod diff --git a/utils/line-follower-find-kp-ki-kd.py b/utils/line-follower-find-kp-ki-kd.py index 1ce07da..bf97d7f 100755 --- a/utils/line-follower-find-kp-ki-kd.py +++ b/utils/line-follower-find-kp-ki-kd.py @@ -15,9 +15,7 @@ from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveTank, SpeedPercent, LineFollowError, follow_for_ms from ev3dev2.sensor.lego import ColorSensor -from time import sleep import logging -import sys def frange(start, end, increment): @@ -86,7 +84,7 @@ def find_kp_ki_kd(tank, start, end, increment, speed, kx_to_tweak, kp, ki, kd): except LineFollowError: continue - except: + except Exception: tank.stop() raise diff --git a/utils/move_differential.py b/utils/move_differential.py index 5878e48..34d406d 100755 --- a/utils/move_differential.py +++ b/utils/move_differential.py @@ -5,10 +5,8 @@ from ev3dev2.motor import OUTPUT_A, OUTPUT_B, MoveDifferential, SpeedRPM from ev3dev2.wheel import EV3Tire -from ev3dev2.unit import DistanceFeet from math import pi import logging -import sys # logging logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)5s: %(message)s") @@ -32,51 +30,52 @@ mdiff = MoveDifferential(OUTPUT_A, OUTPUT_B, EV3Tire, 16.3 * STUD_MM) # This goes crazy on brickpi3, does it do the same on ev3? -#mdiff.on_for_distance(SpeedRPM(-40), 720, brake=False) -#mdiff.on_for_distance(SpeedRPM(40), 720, brake=False) +# mdiff.on_for_distance(SpeedRPM(-40), 720, brake=False) +# mdiff.on_for_distance(SpeedRPM(40), 720, brake=False) # Test arc left/right turns -#mdiff.on_arc_right(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM / 4) +# mdiff.on_arc_right(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM / 4) mdiff.on_arc_left(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM) # Test turning in place -#mdiff.turn_right(SpeedRPM(40), 180) -#mdiff.turn_left(SpeedRPM(40), 180) +# mdiff.turn_right(SpeedRPM(40), 180) +# mdiff.turn_left(SpeedRPM(40), 180) # Test odometry -#mdiff.odometry_start() -#mdiff.odometry_coordinates_log() +# mdiff.odometry_start() +# mdiff.odometry_coordinates_log() -#mdiff.turn_to_angle(SpeedRPM(40), 0) -#mdiff.on_for_distance(SpeedRPM(40), DistanceFeet(2).mm) -#mdiff.turn_right(SpeedRPM(40), 180) -#mdiff.turn_left(SpeedRPM(30), 90) -#mdiff.on_arc_left(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM) +# from ev3dev2.unit import DistanceFeet +# mdiff.turn_to_angle(SpeedRPM(40), 0) +# mdiff.on_for_distance(SpeedRPM(40), DistanceFeet(2).mm) +# mdiff.turn_right(SpeedRPM(40), 180) +# mdiff.turn_left(SpeedRPM(30), 90) +# mdiff.on_arc_left(SpeedRPM(80), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM) # Drive in a quarter arc to the right then go back to where you started -#log.info("turn on arc to the right") -#mdiff.on_arc_right(SpeedRPM(40), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM / 4) -#mdiff.odometry_coordinates_log() -#log.info("\n\n\n\n") -#log.info("go back to (0, 0)") -#mdiff.odometry_coordinates_log() -#mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) -#mdiff.turn_to_angle(SpeedRPM(40), 90) +# log.info("turn on arc to the right") +# mdiff.on_arc_right(SpeedRPM(40), ONE_FOOT_CICLE_RADIUS_MM, ONE_FOOT_CICLE_CIRCUMFERENCE_MM / 4) +# mdiff.odometry_coordinates_log() +# log.info("\n\n\n\n") +# log.info("go back to (0, 0)") +# mdiff.odometry_coordinates_log() +# mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) +# mdiff.turn_to_angle(SpeedRPM(40), 90) # Drive in a rectangle -#mdiff.turn_to_angle(SpeedRPM(40), 120) -#mdiff.on_to_coordinates(SpeedRPM(40), 0, DistanceFeet(1).mm) -#mdiff.on_to_coordinates(SpeedRPM(40), DistanceFeet(2).mm, DistanceFeet(1).mm) -#mdiff.on_to_coordinates(SpeedRPM(40), DistanceFeet(2).mm, 0) -#mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) -#mdiff.turn_to_angle(SpeedRPM(40), 90) +# mdiff.turn_to_angle(SpeedRPM(40), 120) +# mdiff.on_to_coordinates(SpeedRPM(40), 0, DistanceFeet(1).mm) +# mdiff.on_to_coordinates(SpeedRPM(40), DistanceFeet(2).mm, DistanceFeet(1).mm) +# mdiff.on_to_coordinates(SpeedRPM(40), DistanceFeet(2).mm, 0) +# mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) +# mdiff.turn_to_angle(SpeedRPM(40), 90) # Use odometry to drive to specific coordinates -#mdiff.on_to_coordinates(SpeedRPM(40), 600, 300) +# mdiff.on_to_coordinates(SpeedRPM(40), 600, 300) # Now go back to where we started and rotate in place to 90 degrees -#mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) -#mdiff.turn_to_angle(SpeedRPM(40), 90) +# mdiff.on_to_coordinates(SpeedRPM(40), 0, 0) +# mdiff.turn_to_angle(SpeedRPM(40), 90) -#mdiff.odometry_coordinates_log() -#mdiff.odometry_stop() +# mdiff.odometry_coordinates_log() +# mdiff.odometry_stop() diff --git a/utils/move_motor.py b/utils/move_motor.py index e51cd7d..bfafd45 100755 --- a/utils/move_motor.py +++ b/utils/move_motor.py @@ -7,7 +7,6 @@ from ev3dev2.motor import OUTPUT_A, OUTPUT_B, OUTPUT_C, OUTPUT_D, Motor import argparse import logging -import sys # command line args parser = argparse.ArgumentParser(description="Used to adjust the position of a motor in an already assembled robot") From ccbcbdb9cfdf1758f93d01924725f565b7542c9d Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 22 Mar 2020 19:18:24 -0700 Subject: [PATCH 165/172] Update changelog for 2.1.0 release --- debian/changelog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index ac750c2..a88a243 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium +python-ev3dev2 (2.1.0) stretch; urgency=medium [Daniel Walton] * RPyC update docs and make it easier to use @@ -21,7 +21,7 @@ python-ev3dev2 (2.1.0) UNRELEASED; urgency=medium [Nithin Shenoy] * Add Gyro-based driving support to MoveTank - -- UNRELEASED + -- Kaelin Laundry Sun, 22 Mar 2020 19:14:35 -0700 python-ev3dev2 (2.0.0) stretch; urgency=medium From 57ab7f09b86d1eeb4a5c61437ff9406ca858998d Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 22 Mar 2020 19:34:45 -0700 Subject: [PATCH 166/172] Upgrade to python3.8 on Travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7329875..21bd887 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ env: - USE_MICROPYTHON=false - USE_MICROPYTHON=true python: -- 3.4 +- 3.8 cache: pip: true directories: From 2dfd95a5a9a2ad3470a465625c06074d903f2458 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sun, 22 Mar 2020 20:24:45 -0700 Subject: [PATCH 167/172] Use new .readthedocs.yml rather than manual pip invocation (#729) It seems that RTD is no longer seeing the installed modules with the way we used to do it. --- .readthedocs.yml | 12 ++++++++++++ docs/conf.py | 5 ----- docs/requirements.txt | 5 ++++- 3 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .readthedocs.yml diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..75c0d10 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,12 @@ +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +formats: all + +python: + version: 3.7 + install: + - requirements: docs/requirements.txt \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 6019681..b9e9a8b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,11 +23,6 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from git_version import git_version # noqa: E402 -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' - -if on_rtd: - subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'sphinx_bootstrap_theme', 'recommonmark', 'evdev']) - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. diff --git a/docs/requirements.txt b/docs/requirements.txt index 341fd90..1d58f15 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,4 @@ -docutils==0.14 \ No newline at end of file +docutils==0.14 +sphinx_bootstrap_theme +recommonmark +evdev \ No newline at end of file From af12545d1622067ddf55f4bd70a4f24e58d0eabb Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Mon, 23 Mar 2020 20:49:12 -0700 Subject: [PATCH 168/172] Add release instructions to contributing guide (#730) --- CONTRIBUTING.rst | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c9b31e5..bad507f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -103,5 +103,38 @@ following commands: sudo apt update sudo apt install --only-upgrade micropython-ev3dev2 +Publishing releases +------------------- + +#. Update the changelog, including a correct release date. Use ``date -R`` to get correctly-formatted date. +#. Commit the changelog. By convention, use message like ``Update changelog for 2.1.0 release``. +#. Build/update pbuilder base images: ``OS=debian DIST=stretch ARCH=amd64 pbuilder-ev3dev base`` and ``OS=raspbian DIST=stretch ARCH=armhf pbuilder-ev3dev base``. +#. ``./debian/release.sh`` and enter passwords/key passphrases as needed +#. ``git push`` +#. ``git push --tags`` +#. ``git tag -d stable`` +#. ``git tag stable`` +#. ``git push --tags --force`` +#. ``git tag -a 2.1.0 -m "python-ev3dev2 PyPi release 2.1.0"`` +#. ``git push --tags`` + +Note that push order is important; the CI server will get confused if you push +other tags pointing to the same commit after you push the PyPi release tag. This +doesn't actually cause release issues, but does mark the CI builds as "failed" +because it tried to publish the same release again. + +**Check all of the following after release is complete:** + +- Emails from package server don't include any errors +- All Travis CI builds succeeded +- New release is available on PyPi +- Release tags are up on GitHub +- ReadTheDocs is updated + + - ReadTheDocs "stable" version points to latest release + - There is an explicit version tag for the last-released version (exeption: ``2.1.0``) + - There is an explicit version tag for this version (you will likely need to manually activate it) + - All ReadTheDocs builds succeeded + .. _ev3dev: http://ev3dev.org .. _FAQ: https://python-ev3dev.readthedocs.io/en/ev3dev-stretch/faq.html From b9b5bc78425f9ec3da08067106381a53015e913d Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Tue, 14 Apr 2020 16:18:12 -0700 Subject: [PATCH 169/172] Pin Sphinx to version 2.4.4 on Travis builds (#735) Sphinx 3 seems to break our docs build. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 21bd887..87ed261 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ sudo: false git: depth: 100 install: -- if [ $USE_MICROPYTHON = false ]; then pip install Pillow Sphinx sphinx_bootstrap_theme recommonmark evdev==1.0.0 && pip install -r ./docs/requirements.txt; fi +- if [ $USE_MICROPYTHON = false ]; then pip install Pillow Sphinx==2.4.4 sphinx_bootstrap_theme recommonmark evdev==1.0.0 && pip install -r ./docs/requirements.txt; fi - if [ $USE_MICROPYTHON = true ]; then ./.travis/install-micropython.sh && MICROPYTHON=~/micropython/ports/unix/micropython; fi script: - chmod -R g+rw ./tests/fake-sys/devices/**/* From 5364b40ffd7d208c52b1772f11c5871a43a61511 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Fri, 10 Jul 2020 20:40:23 -0700 Subject: [PATCH 170/172] Fix spelling in LED example: cyle -> cycle --- ev3dev2/led.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ev3dev2/led.py b/ev3dev2/led.py index d4f21a8..48e0409 100644 --- a/ev3dev2/led.py +++ b/ev3dev2/led.py @@ -491,7 +491,7 @@ def animate_cycle(self, colors, groups=('LEFT', 'RIGHT'), sleeptime=0.5, duratio from ev3dev2.led import Leds leds = Leds() - leds.animate_cyle(('RED', 'GREEN', 'AMBER')) + leds.animate_cycle(('RED', 'GREEN', 'AMBER')) """ def _animate_cycle(): index = 0 From 1471dc0c9b233dde62a9f8a7699864fd7aabb917 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 1 Aug 2020 15:22:53 -0500 Subject: [PATCH 171/172] Add sleep to infinite loop example in README (#755) add sleep in `while True` loop to avoid using 100% CPU See #754 --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 9403883..a1c414a 100644 --- a/README.rst +++ b/README.rst @@ -50,6 +50,9 @@ to get started: .. code-block:: python #!/usr/bin/env python3 + + from time import sleep + from ev3dev2.motor import LargeMotor, OUTPUT_A, OUTPUT_B, SpeedPercent, MoveTank from ev3dev2.sensor import INPUT_1 from ev3dev2.sensor.lego import TouchSensor @@ -107,6 +110,8 @@ before trying this out. else: leds.set_color("LEFT", "RED") leds.set_color("RIGHT", "RED") + # don't let this loop use 100% CPU + sleep(0.01) If you'd like to use a sensor on a specific port, specify the port like this: From f84152ca9b952a7a47a3f477542f878f3b69b824 Mon Sep 17 00:00:00 2001 From: Kaelin Laundry Date: Sat, 22 Aug 2020 21:49:26 -0700 Subject: [PATCH 172/172] Clarify ideal WAV audio format #760 --- ev3dev2/sound.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ev3dev2/sound.py b/ev3dev2/sound.py index 7b38ae5..350d82f 100644 --- a/ev3dev2/sound.py +++ b/ev3dev2/sound.py @@ -309,7 +309,8 @@ def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE return self.play_tone(freq, duration=duration, volume=volume, play_type=play_type) def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE): - """ Play a sound file (wav format) at a given volume. + """ Play a sound file (wav format) at a given volume. The EV3 audio subsystem will work best if + the file is encoded as 16-bit, mono, 22050Hz. :param string wav_file: The sound file path :param int volume: The play volume, in percent of maximum volume

+s4A=?Z#O}h%nfrth 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 cK7?+;K7rUB;L3YxEADWY!>-JQP;ZRA~)VOQ5?b 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 ri#p+M2*mE(Nl*Y5C3yalT8Vd9 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 AhyVZp diff --git a/demo/R3PTAR/snake-hiss.wav b/demo/R3PTAR/snake-hiss.wav deleted file mode 100644 index 22765813c6e4f8a0304a5ebf668aef7ef818fbb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77868 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 z 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∨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