├── debian ├── compat ├── source │ ├── format │ └── options ├── copyright ├── openrobertalab.postinst ├── openrobertalab.service ├── openrobertalab.conf ├── control ├── rules └── changelog ├── roberta ├── __init__.py ├── ter-u12n_unicode.pbm ├── ter-u12n_unicode.pil ├── ter-u14n_unicode.pbm ├── ter-u14n_unicode.pil ├── ter-u18n_unicode.pbm ├── ter-u18n_unicode.pil ├── test.py ├── test_ev3.py ├── test_lab.py ├── lab.py └── ev3.py ├── docs ├── MenuMain.png ├── architecture.png ├── protocol_old.png ├── protocol_fix1.png ├── RobertaLabConnected.png ├── RobertaLabConnecting.png ├── RobertaLabDisconnected.png ├── protocol_old.msc ├── protocol_fix1.msc └── architecture.svg ├── .gitignore ├── .travis.yml ├── openrobertalab ├── setup.py ├── README.md └── LICENSE.txt /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /roberta/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /debian/source/options: -------------------------------------------------------------------------------- 1 | extend-diff-ignore="\.egg-info" 2 | 3 | -------------------------------------------------------------------------------- /docs/MenuMain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/MenuMain.png -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/architecture.png -------------------------------------------------------------------------------- /docs/protocol_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/protocol_old.png -------------------------------------------------------------------------------- /docs/protocol_fix1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/protocol_fix1.png -------------------------------------------------------------------------------- /docs/RobertaLabConnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/RobertaLabConnected.png -------------------------------------------------------------------------------- /docs/RobertaLabConnecting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/RobertaLabConnecting.png -------------------------------------------------------------------------------- /roberta/ter-u12n_unicode.pbm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/roberta/ter-u12n_unicode.pbm -------------------------------------------------------------------------------- /roberta/ter-u12n_unicode.pil: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/roberta/ter-u12n_unicode.pil -------------------------------------------------------------------------------- /roberta/ter-u14n_unicode.pbm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/roberta/ter-u14n_unicode.pbm -------------------------------------------------------------------------------- /roberta/ter-u14n_unicode.pil: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/roberta/ter-u14n_unicode.pil -------------------------------------------------------------------------------- /roberta/ter-u18n_unicode.pbm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/roberta/ter-u18n_unicode.pbm -------------------------------------------------------------------------------- /roberta/ter-u18n_unicode.pil: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/roberta/ter-u18n_unicode.pil -------------------------------------------------------------------------------- /docs/RobertaLabDisconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenRoberta/robertalab-ev3dev/HEAD/docs/RobertaLabDisconnected.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .classpath 3 | .project 4 | .pydevproject 5 | .settings 6 | target 7 | dist 8 | MANIFEST 9 | roberta/__version__.py 10 | *.egg-info 11 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: openroberta 3 | Upstream-Contact: Openroberta developers 4 | Source: http://open-roberta.org 5 | 6 | Files: * 7 | Copyright: 2015-2017 Stefan Sauer 8 | License: Apache 9 | /usr/share/common-licenses/Apache-2.0 10 | -------------------------------------------------------------------------------- /debian/openrobertalab.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # postinst script for openrobertalab 3 | 4 | set -e 5 | 6 | case "$1" in 7 | configure) 8 | # Ask the bus to reload the config file 9 | if [ -x "/etc/init.d/dbus" ]; then 10 | invoke-rc.d dbus force-reload || true 11 | fi 12 | ;; 13 | abort-upgrade|abort-remove|abort-deconfigure) 14 | ;; 15 | *) 16 | echo "postinst called with unknown argument \`$1'" >&2 17 | exit 1 18 | ;; 19 | esac 20 | 21 | #DEBHELPER# 22 | -------------------------------------------------------------------------------- /debian/openrobertalab.service: -------------------------------------------------------------------------------- 1 | # /lib/systemd/system/openrobertalab.service 2 | 3 | [Unit] 4 | Description=OpenRoberta Lab connector 5 | Conflicts=getty@tty2.service 6 | After=systemd-user-sessions.service getty@tty2.service 7 | 8 | [Service] 9 | ExecStartPre=/bin/chown robot: /dev/tty2 10 | ExecStart=/usr/bin/openrobertalab 11 | ExecStopPost=/bin/chvt 1 12 | Restart=always 13 | User=robot 14 | StandardInput=tty 15 | TTYPath=/dev/tty2 16 | StandardOutput=journal 17 | StandardError=journal 18 | 19 | [Install] 20 | WantedBy=brickman.service 21 | -------------------------------------------------------------------------------- /debian/openrobertalab.conf: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/protocol_old.msc: -------------------------------------------------------------------------------- 1 | # communication flow between ui and service 2 | # 3 | # mscgen -T png -F sans -i protocol_old.msc 4 | msc { 5 | hscale = "2"; 6 | 7 | ui [label="brickmann ui"], 8 | srv [label="robertalab service"], 9 | srvt [label="connector thread"]; 10 | 11 | ui rbox ui [label="press connect"]; 12 | ui => srv [label="dbus::connect(url)"]; 13 | srv rbox srv [label="generate code"]; 14 | srv => srvt [label="new Connector(url)"]; 15 | srv => ui [label="return code"], srvt rbox srvt [label="connect to server"]; 16 | ui rbox ui [label="show code"]; 17 | } 18 | -------------------------------------------------------------------------------- /docs/protocol_fix1.msc: -------------------------------------------------------------------------------- 1 | # communication flow between ui and service 2 | # 3 | # mscgen -T png -F sans -i protocol_fix1.msc 4 | msc { 5 | hscale = "2"; 6 | 7 | ui [label="brickmann ui"], 8 | srv [label="robertalab service"], 9 | srvt [label="connector thread"]; 10 | 11 | ui rbox ui [label="press connect"]; 12 | ui => srv [label="dbus::connect(url)"]; 13 | srv rbox srv [label="generate code"]; 14 | srv => srvt [label="new Connector(url)"]; 15 | srvt rbox srvt [label="connect to server"]; 16 | srvt => srv [label="signal connected"]; 17 | srv => ui [label="return code"]; 18 | ui rbox ui [label="show code"]; 19 | } 20 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: openrobertalab 2 | Maintainer: Stefan Sauer 3 | Section: python 4 | Priority: optional 5 | Build-Depends: python3-setuptools (>= 0.6b3), python3-all (>= 3.4.2-2), 6 | debhelper (>= 9), dh-systemd (>= 1.14), dh-python, python3-httpretty, 7 | python3-dbus 8 | Standards-Version: 3.9.5 9 | Homepage: http://lab.open-roberta.org/ 10 | Vcs-Browser: https://github.com/OpenRoberta/robertalab-ev3dev 11 | Vcs-Git: git://github.com/OpenRoberta/robertalab-ev3dev.git 12 | 13 | Package: openrobertalab 14 | Architecture: all 15 | Depends: ${misc:Depends}, ${python3:Depends}, systemd, python3-bluez, 16 | python3-dbus, python3-ev3dev, python3-gi 17 | Enhances: brickman 18 | Description: lab.open-roberta.org connector for ev3dev.org 19 | Open-roberta.org is a web platform for learning how to program robots. This 20 | package is a service for the LEGO MINDSTORMS EV3 running ev3dev.org to connect 21 | to the cloud IDE. 22 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | #export DH_VERBOSE = 1 4 | export PYBUILD_NAME = openrobertalab 5 | export PYBUILD_SYSTEM = distutils 6 | # the tests hang at the *end* when run under pbuilder-dist 7 | export PYBUILD_DISABLE_python3.4=test 8 | 9 | # expects changelog version in the format + 10 | # where matches the menuversion on the lab.open-roberta.org 11 | # server and can be any valid debian package version string. 12 | export VERSION = $(shell dpkg-parsechangelog | grep ^Version: | sed -e 's/Version: //' -e 's/+.*//') 13 | 14 | %: 15 | dh $@ --with=python3,systemd --buildsystem=pybuild 16 | 17 | override_dh_auto_install: 18 | dh_auto_install 19 | install -D -o root -g root -m 644 debian/openrobertalab.conf debian/tmp/etc/dbus-1/system.d/openrobertalab.conf 20 | 21 | override_dh_install: 22 | dh_install etc/dbus-1/system.d/openrobertalab.conf 23 | 24 | override_dh_installinit: 25 | # don't do anything, silences lintian warnings 26 | # see https://github.com/ev3dev/brickman/blob/ev3dev-jessie/debian/rules 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.4 4 | 5 | before_install: 6 | - sudo apt-get update -qq 7 | - sudo apt-get install -y python3-dbus python3-gi 8 | - pidof dbus-daemon || dbus-daemon --system --fork 9 | 10 | # pip cannot install python-dbus: https://bugs.freedesktop.org/show_bug.cgi?id=55439 11 | # pip cannot install PyGObject: "Building PyGObject using distutils is only supported on windows." 12 | # coverage<4: https://github.com/travis-ci/travis-ci/issues/4866 13 | install: pip3 install httpretty pycodestyle pyflakes codecov 'coverage<4' Pillow 14 | 15 | # this only works with 2.7 and 3.2 on precise and 2.7 and 3.4 on trusty 16 | # we must use this though, since python3-dbus won't work in a virtualenv 17 | virtualenv: 18 | system_site_packages: true 19 | 20 | script: 21 | - pycodestyle --max-line-length=120 --exclude=StaticData.py . openrobertalab 22 | - pyflakes . openrobertalab 23 | - ./setup.py build 24 | - nosetests --with-coverage 25 | 26 | after_success: 27 | - bash <(curl -s https://codecov.io/bash) 28 | 29 | notifications: 30 | irc: "chat.freenode.net#open-roberta" 31 | 32 | -------------------------------------------------------------------------------- /openrobertalab: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import atexit 3 | import logging 4 | import os 5 | import sys 6 | 7 | from gi.repository import GLib 8 | from dbus.mainloop.glib import DBusGMainLoop 9 | 10 | # prefer the module updated from the server 11 | # the regular place where pip3 would install then would be 12 | # ~/.local/lib/python3.7/site-packages 13 | local_pkg_path = os.path.expanduser('~/.local/lib/python') 14 | os.makedirs(local_pkg_path, exist_ok=True) 15 | sys.path.insert(0, local_pkg_path) 16 | from roberta.lab import Service 17 | 18 | logging.basicConfig(level=logging.INFO) 19 | logger = logging.getLogger('roberta') 20 | 21 | service = None 22 | 23 | 24 | def cleanup(): 25 | global service 26 | 27 | if service: 28 | service.hal.clearDisplay() 29 | service.hal.stopAllMotors() 30 | logger.info('--- done ---') 31 | logging.shutdown() 32 | 33 | 34 | def main(): 35 | global service 36 | 37 | logger.info('--- starting ---') 38 | 39 | atexit.register(cleanup) 40 | 41 | DBusGMainLoop(set_as_default=True) 42 | loop = GLib.MainLoop() 43 | service = Service('/org/openroberta/Lab1') 44 | logger.debug('loop running') 45 | loop.run() 46 | 47 | 48 | if __name__ == "__main__": 49 | main() 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import codecs 4 | import os 5 | from distutils.core import setup 6 | 7 | version = '?' 8 | root = os.path.dirname(os.path.abspath(__file__)) 9 | # Path to __version__ module 10 | version_file = os.path.join(root, 'roberta', '__version__.py') 11 | # Check if this is a source distribution. 12 | # If not create the __version__ module containing the version 13 | if not os.path.exists(os.path.join(root, 'PKG-INFO')): 14 | fd = codecs.open(version_file, 'w', 'utf-8') 15 | fd.write('version = %r\n' % os.getenv('VERSION', '?')) 16 | fd.close() 17 | # Load version 18 | exec(open(version_file).read()) 19 | 20 | # TODO: convert README.md to long_desc 21 | # https://gist.github.com/aubricus/9184003#file-setup_snippet-py 22 | 23 | setup(name='openrobertalab', 24 | version=version, 25 | description='lab.open-roberta.org connector for ev3dev.org', 26 | author='Stefan Sauer', 27 | author_email='ensonic@google.com', 28 | url='https://www.open-roberta.org/', 29 | scripts=['openrobertalab'], 30 | packages=['roberta'], 31 | package_data={'roberta': ['ter-*.p??']}, 32 | # other deps: apt-get-install python3-bluez python3-dbus python3-ev3dev python3-gi 33 | # install_requires=['python3-ev3dev'] 34 | ) 35 | -------------------------------------------------------------------------------- /roberta/test.py: -------------------------------------------------------------------------------- 1 | # Hal and Ev3dev class to satisfy testing 2 | 3 | from PIL import Image, ImageDraw 4 | 5 | 6 | class Hal(object): 7 | 8 | def __init__(self, brickConfiguration, usedSensors=None): 9 | self.cfg = brickConfiguration 10 | 11 | def clearDisplay(self): 12 | pass 13 | 14 | def playFile(self, systemSound): 15 | pass 16 | 17 | 18 | class Ev3dev(object): 19 | OUTPUT_A = 'outA' 20 | OUTPUT_B = 'outB' 21 | OUTPUT_C = 'outC' 22 | OUTPUT_D = 'outD' 23 | 24 | INPUT_1 = 'in1' 25 | INPUT_2 = 'in2' 26 | INPUT_3 = 'in3' 27 | INPUT_4 = 'in4' 28 | 29 | class Leds(object): 30 | BLACK = 0 31 | GREEN = 1 32 | RED = 2 33 | ORANGE = 3 34 | LEFT = 4 35 | RIGHT = 5 36 | 37 | Sound = None 38 | 39 | def Button(): 40 | return None 41 | 42 | class PowerSupply(object): 43 | measured_volts = 0.0 44 | 45 | class Screen(object): 46 | draw = None 47 | 48 | def __init__(self): 49 | im = Image.new('1', (178, 128), (0)) 50 | self.draw = ImageDraw.Draw(im) 51 | 52 | class LargeMotor(object): 53 | 54 | def __init__(self, port): 55 | self.port = port 56 | self.position = 0 57 | self.state = False 58 | self.max_speed = 100 59 | self.count_per_rot = 360 60 | 61 | def run_to_rel_pos(self, **kwargs): 62 | self.__dict__.update(kwargs) 63 | 64 | def run_direct(self, **kwargs): 65 | self.__dict__.update(kwargs) 66 | # TODO: the calling code waits for the positions to be reached, we 67 | # only know the directions :/ 68 | if self.duty_cycle_sp >= 0: 69 | self.position = 10000 70 | else: 71 | self.position = -10000 72 | 73 | def run_forever(self, **kwargs): 74 | self.__dict__.update(kwargs) 75 | 76 | def stop(self): 77 | pass 78 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | openrobertalab (1.7.4+1.0.0) oldstable; urgency=medium 2 | 3 | * avoid div/0 for the curve block 4 | * add more parameters to the say block 5 | * expand test coverage 6 | 7 | -- Stefan Sauer Fri, 19 Jan 2018 10:01:07 +0100 8 | 9 | openrobertalab (1.7.3+1.0.0) oldstable; urgency=medium 10 | 11 | * support the nxt sound sensor 12 | * fix joining text with numbers 13 | * make bluetooth communication interruptible 14 | * add setLanguage for upcomin say blocks 15 | * fix timers 16 | * use a blink thread instead of the broken timer feature 17 | 18 | -- Stefan Sauer Fri, 01 Dec 2017 11:53:10 +0100 19 | 20 | openrobertalab (1.7.2+1.0.0) stable; urgency=medium 21 | 22 | * fix state handling when canceling the pin-code 23 | * improve hard/sort resets 24 | * only set gyro mode when the value changed (to avoid resets) 25 | * make sound commands interruptible 26 | * stop motors when stalled 27 | 28 | -- Stefan Sauer Mon, 29 May 2017 11:02:24 +0200 29 | 30 | openrobertalab (1.7.1+1.0.0) stable; urgency=medium 31 | 32 | * log the python path 33 | * fix more motor attribute names 34 | * avoid storing custom properties on monot instances 35 | 36 | -- Stefan Sauer Thu, 02 Feb 2017 16:15:28 +0100 37 | 38 | openrobertalab (1.7.0+1.0.0) stable; urgency=medium 39 | 40 | * handle more network errors 41 | * support new image api (allows to pass images as part of the code) 42 | * use new python sound api 43 | 44 | -- Stefan Sauer Wed, 25 Jan 2017 13:53:04 +0100 45 | 46 | openrobertalab (1.6.0+1.0.0) stable; urgency=medium 47 | 48 | * rewrite the she-bang since we now use python3 49 | * add screenshots for readme/wiki 50 | * fix speed scaling of motors 51 | * remove use of deprecated speed regullation attribute 52 | 53 | -- Stefan Sauer Fri, 25 Nov 2016 20:36:46 +0100 54 | 55 | openrobertalab (1.5.0+1.0.0) stable; urgency=medium 56 | 57 | * port to python3 58 | 59 | -- Stefan Sauer Mon, 07 Nov 2016 09:29:33 +0100 60 | 61 | openrobertalab (1.4.1+1.0.0) stable; urgency=medium 62 | 63 | * add soft abort feature (press and hold enter+down keys) 64 | * more list operation supported 65 | * new drive-in-curve block supported in hal 66 | * two motor drive command are better synced 67 | * proper gfx<->txt mode switching 68 | * fix tgb-color sensor values 69 | 70 | -- Stefan Sauer Mon, 26 Sep 2016 11:00:54 +0200 71 | 72 | openrobertalab (1.4.0+1.0.0) stable; urgency=medium 73 | 74 | * code generator fixes 75 | * https support 76 | * more Blocklymethods implemented 77 | 78 | -- Stefan Sauer Mon, 25 Apr 2016 16:04:58 +0200 79 | 80 | openrobertalab (1.3.2+3.0.0) stable; urgency=medium 81 | 82 | * code generator fixes 83 | * led blinks are async again 84 | * better exception reporting 85 | * handle exceptions when instatiating sensors that are not plugged 86 | * handle speed update for running motors 87 | * properly scale sensor values 88 | 89 | -- Stefan Sauer Fri, 11 Mar 2016 16:08:10 +0100 90 | 91 | openrobertalab (1.3.2+2.0.0) stable; urgency=medium 92 | 93 | * send 'disconnected' when receiving an error 94 | * drop the 'random' module 95 | * code style cleanups 96 | * refactor session code 97 | * Avoid '01' and 'IO' in the tokens 98 | * add factory methods for sensor creation 99 | * Use more portable form of imports. 100 | * Use the powersupply class from ev3dev-python 101 | * leds replace flash() api with a manual loop 102 | 103 | -- Stefan Sauer (ensonic) Tue, 16 Feb 2016 11:34:54 +0100 104 | 105 | openrobertalab (1.3.2+1.0.0) stable; urgency=medium 106 | 107 | [David Lechner] 108 | * Fix programs not running when started from NEPO. 109 | * Don't flood the logs 110 | * Use native package format 111 | * Drop .py extension from openrobertalab 112 | 113 | [Stefan Sauer] 114 | * Add automatic tests. 115 | * Store user programs in home and make them executable. 116 | * Support release and develop server paths. 117 | * Switch to the new python-ev3dev api. 118 | 119 | -- David Lechner Tue, 29 Dec 2015 22:55:26 -0600 120 | 121 | openrobertalab (1.3.2-1) stable; urgency=low 122 | 123 | * update to version 1.3.2 124 | 125 | -- Stefan Sauer (ensonic) Tue, 20 Oct 2015 10:11:18 +0200 126 | 127 | openrobertalab (1.3.0-1) stable; urgency=low 128 | 129 | * initial package 130 | 131 | -- Stefan Sauer (ensonic) Mon, 21 Sep 2015 14:25:33 +0200 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # intro # 2 | A connector to use a LEGO Mindstorms EV3 running the ev3dev firmware 3 | (http://www.ev3dev.org) from the Open Roberta lab (http://lab.open-roberta.org). 4 | This is now included by default with ev3dev images (Thanks @dlech). 5 | 6 | Step-by-step instruction to help you get ev3dev up and running can be found here 7 | (https://www.ev3dev.org/docs/getting-started/). 8 | 9 | Important note for Step 1 of the manual: You have to download the ev3dev 10 | operating system for your EV3 robot via the following link: 11 | [ev3dev - Github releases](https://github.com/ev3dev/ev3dev/releases/tag/ev3dev-jessie-2017-09-14). 12 | Select there the file ev3dev-jessie-ev3-generic-2017-09-14.zip. 13 | 14 | As soon as everything is installed, the connector is not enabled by default 15 | (to save memory), so you do have to enable it once: 16 | 17 | 1. Connect to the LEGO brick using SSH: (Read this for the [default password](http://www.ev3dev.org/docs/tutorials/connecting-to-ev3dev-with-ssh/)) 18 | ```bash 19 | ssh robot@ev3dev.local 20 | ``` 21 | 2. On the brick run: 22 | ```bash 23 | sudo systemctl unmask openrobertalab 24 | sudo systemctl start openrobertalab 25 | ``` 26 | 27 | After running the commands above, it will start automatically after a reboot. 28 | You can turn it back off by running: 29 | 30 | ```bash 31 | sudo systemctl stop openrobertalab.service 32 | sudo systemctl mask openrobertalab.service 33 | ``` 34 | 35 | If the ``openrobertalab`` package is installed and the service is running, the 36 | ``Open Roberta Lab`` menu item in brickman will allow you to connect to an Open 37 | Roberta server. This is how the menu will look like: 38 | 39 | ![Main Menu](/docs/MenuMain.png?raw=true "Main Menu"). 40 | 41 | Once you selected the ``Open Roberta Lab`` menu item you'll get to this screen: 42 | 43 | ![Open Roberta Lab](/docs/RobertaLabDisconnected.png?raw=true "Open Roberta Lab"). 44 | 45 | This offers to connect to the public server as the first item, or to a custom 46 | server as a 2nd item. The 2nd choice is mostly for developers or for using a 47 | local server. When clicking connect, the screen will show a pairing code: 48 | 49 | ![Pairing Code](/docs/RobertaLabConnecting.png?raw=true "Pairing Code"). 50 | 51 | This code will have to be entered on the web-ui to establish the link. Once that 52 | has been done a beep-sequence on the EV3 confirms the link and this screen is 53 | shown: 54 | 55 | ![Connected](/docs/RobertaLabConnected.png?raw=true "Connected"). 56 | 57 | When a program contains an infinite loop, it can be ``killed`` by pressing 58 | the ``enter`` and ``down`` buttons on the ev3 simultaneously. If this is not 59 | enough to terminate the program, holding the ``back`` button for one second 60 | will kill it, but together with it the connector. The connector will restart 61 | automatically, but one needs to reconnect to the Open Roberta server again. 62 | 63 | # build status # 64 | 65 | [![Build Status](https://travis-ci.org/OpenRoberta/robertalab-ev3dev.svg?branch=develop)](https://travis-ci.org/OpenRoberta/robertalab-ev3dev/builds) 66 | [![Test Coverage](https://codecov.io/gh/OpenRoberta/robertalab-ev3dev/branch/develop/graph/badge.svg)](https://codecov.io/gh/OpenRoberta/robertalab-ev3dev) 67 | 68 | 69 | # development # 70 | 71 | The package consist of two parts: 72 | 73 | 1. [roberta/lab.py](https://github.com/OpenRoberta/robertalab-ev3dev/blob/develop/roberta/lab.py): the connector to the open roberta lab 74 | * this is started as a systemd service at startup 75 | * it provides a dbus interface 76 | * the [brickman ui](https://github.com/ev3dev/brickman) uses dbus for the `Open Roberta` menu 77 | 2. [roberta/ev3.py](https://github.com/OpenRoberta/robertalab-ev3dev/blob/develop/roberta/ev3.py): a hardware abstraction library 78 | * provides the implementation for the NEPO blocks in the program 79 | 80 | The connector talks with two main components, the server and the local brickman UI: 81 | 82 | ![Architecture](/docs/architecture.png?raw=true "Architecture"). 83 | 84 | ## prerequisites ## 85 | python3-ev3dev 86 | python3-bluez 87 | python3-dbus 88 | python3-gi 89 | 90 | ## dist ## 91 | 92 | VERSION="1.3.2" python setup.py sdist 93 | 94 | Resulting file is under ./dist/openrobertalab-${VERSION}.tar.gz 95 | 96 | Now you can also build a debian package using ``debuild`` or 97 | ``debuild -us -us``. The new package will be in the parent folder. 98 | 99 | To build a release for the openroberta server run 100 | 101 | rm roberta/*~ 102 | zip -r roberta.zip roberta -x roberta/test*.py -x *__pycache__* 103 | 104 | ## upload to ev3 ## 105 | The easiest is to upload the debian package and install it. 106 | 107 | scp ../openrobertalab_1.3.2-1_all.deb maker@ev3dev.local: 108 | ssh -t maker@ev3dev.local "sudo dpkg --install openrobertalab_1.3.2-1_all.deb;" 109 | 110 | Alternatively after changing single files you can do: 111 | 112 | scp roberta/ev3.py robot@ev3dev.local: 113 | ssh -t robot@ev3dev.local "sudo mv ev3.py /usr/lib/python3/dist-packages/roberta/; sudo systemctl restart openrobertalab" 114 | 115 | Finally you can also upload through a local open roberta server (assuming 116 | your git checkout of the openroberta-lab is at the same parent dir): 117 | 118 | mkdir -p ../openroberta-lab/RobotEV3/updateResources/ 119 | cp roberta.zip ../openroberta-lab/RobotEV3/updateResources/ev3dev/ 120 | 121 | ## configuration ## 122 | The brickman ui will store configuration data under /etc/openroberta.conf. All 123 | configuration can be edited from the UI. If there is a need to manually change 124 | the configuration, it is advised to stop brickman. 125 | 126 | ## Testing ## 127 | ``python3 -m unittest discover roberta`` or ``nosetests``. 128 | The test require ``python3-httpretty``, but run without ``python3-ev3dev``. 129 | 130 | ## Logging ## 131 | The service writes status to the system journal. 132 | 133 | sudo journalctl -f -b0 -u openrobertalab 134 | -------------------------------------------------------------------------------- /roberta/test_ev3.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .ev3 import Hal 4 | from .test import Ev3dev as ev3dev 5 | 6 | 7 | class TestHal(unittest.TestCase): 8 | def test__init__no_cfg(self): 9 | hal = Hal(None) 10 | self.assertNotEqual(0, hal.font_w) 11 | self.assertNotEqual(0, hal.font_h) 12 | 13 | def test__init__simple_cfg(self): 14 | brickConfiguration = { 15 | 'wheel-diameter': 5.6, 16 | 'track-width': 18.0, 17 | 'actors': { 18 | }, 19 | 'sensors': { 20 | }, 21 | } 22 | hal = Hal(brickConfiguration) 23 | self.assertIn('wheel-diameter', hal.cfg) 24 | 25 | def test__init__with_actor(self): 26 | brickConfiguration = { 27 | 'wheel-diameter': 5.6, 28 | 'track-width': 18.0, 29 | 'actors': { 30 | 'B': Hal.makeLargeMotor(ev3dev.OUTPUT_B, 'on', 'forward'), 31 | }, 32 | 'sensors': { 33 | }, 34 | } 35 | hal = Hal(brickConfiguration) 36 | self.assertIsNotNone(hal.cfg['actors']['B']) 37 | 38 | def _getStdHal(self): 39 | brickConfiguration = { 40 | 'wheel-diameter': 5.6, 41 | 'track-width': 18.0, 42 | 'actors': { 43 | 'B': Hal.makeLargeMotor(ev3dev.OUTPUT_B, 'on', 'forward'), 44 | 'C': Hal.makeLargeMotor(ev3dev.OUTPUT_C, 'on', 'forward'), 45 | }, 46 | 'sensors': { 47 | }, 48 | } 49 | return Hal(brickConfiguration) 50 | 51 | # rotateRegulatedMotor 52 | def test_rotateRegulatedMotor_Degree(self): 53 | hal = self._getStdHal() 54 | hal.rotateRegulatedMotor('B', 100, 'degree', 90.0) 55 | self.assertEqual(hal.cfg['actors']['B'].speed_sp, 100) 56 | 57 | def test_rotateRegulatedMotor_Rotations(self): 58 | hal = self._getStdHal() 59 | hal.rotateRegulatedMotor('B', 100, 'rotations', 2) 60 | self.assertEqual(hal.cfg['actors']['B'].position_sp, 720) 61 | 62 | def test_rotateRegulatedMotor_SpeedIsClipped(self): 63 | hal = self._getStdHal() 64 | hal.rotateRegulatedMotor('B', 500, 'degree', 90.0) 65 | self.assertEqual(hal.cfg['actors']['B'].speed_sp, 100) 66 | 67 | # rotateUnregulatedMotor 68 | def test_rotateUnregulatedMotor_Forward(self): 69 | hal = self._getStdHal() 70 | hal.rotateUnregulatedMotor('B', 100, 'power', 100) 71 | self.assertGreater(hal.cfg['actors']['B'].position, 0) 72 | 73 | def test_rotateUnregulatedMotor_Backward(self): 74 | hal = self._getStdHal() 75 | hal.rotateUnregulatedMotor('B', -100, 'power', 100) 76 | self.assertLess(hal.cfg['actors']['B'].position, 0) 77 | 78 | def test_rotateUnregulatedMotor_SpeedIsClipped(self): 79 | hal = self._getStdHal() 80 | hal.rotateUnregulatedMotor('B', 500, 'power', 100) 81 | self.assertEqual(hal.cfg['actors']['B'].duty_cycle_sp, 100) 82 | 83 | # turnOnRegulatedMotor 84 | def test_turnOnRegulatedMotor_SpeedIsClipped(self): 85 | hal = self._getStdHal() 86 | hal.turnOnRegulatedMotor('B', 500) 87 | self.assertEqual(hal.cfg['actors']['B'].speed_sp, 100) 88 | 89 | # turnOnUnregulatedMotor 90 | def test_turnOnUnregulatedMotor_SpeedIsClipped(self): 91 | hal = self._getStdHal() 92 | hal.turnOnUnregulatedMotor('B', 500) 93 | self.assertEqual(hal.cfg['actors']['B'].duty_cycle_sp, 100) 94 | 95 | # stopMotor 96 | def test_stopMotor_defaultMode(self): 97 | hal = self._getStdHal() 98 | hal.stopMotor('B') 99 | self.assertEqual(hal.cfg['actors']['B'].stop_action, 'coast') 100 | 101 | # stopMotors 102 | def test_stopMotors(self): 103 | hal = self._getStdHal() 104 | hal.stopMotors('B', 'C') 105 | self.assertEqual(hal.cfg['actors']['B'].stop_action, 'coast') 106 | self.assertEqual(hal.cfg['actors']['C'].stop_action, 'coast') 107 | 108 | # regulatedDrive 109 | def test_regulatedDrive(self): 110 | hal = self._getStdHal() 111 | hal.regulatedDrive('B', 'C', False, 'forward', 100) 112 | actors = hal.cfg['actors'] 113 | self.assertEqual(actors['B'].speed_sp, actors['C'].speed_sp) 114 | 115 | # driveDistance 116 | def test_driveDistance(self): 117 | hal = self._getStdHal() 118 | hal.driveDistance('B', 'C', False, 'forward', 100, 10) 119 | actors = hal.cfg['actors'] 120 | self.assertEqual(actors['B'].speed_sp, actors['C'].speed_sp) 121 | self.assertEqual(actors['B'].position_sp, actors['C'].position_sp) 122 | 123 | # rotateDirectionRegulated 124 | def test_rotateDirectionRegulated(self): 125 | hal = self._getStdHal() 126 | hal.rotateDirectionRegulated('B', 'C', False, 'right', 100) 127 | actors = hal.cfg['actors'] 128 | self.assertEqual(actors['B'].speed_sp, -actors['C'].speed_sp) 129 | 130 | # rotateDirectionAngle 131 | def test_rotateDirectionAngle(self): 132 | hal = self._getStdHal() 133 | hal.rotateDirectionAngle('B', 'C', False, 'right', 100, 90.0) 134 | actors = hal.cfg['actors'] 135 | self.assertEqual(actors['B'].speed_sp, actors['C'].speed_sp) 136 | self.assertEqual(actors['B'].position_sp, -actors['C'].position_sp) 137 | 138 | # driveInCurve 139 | def test_driveInCurve_noDist(self): 140 | hal = self._getStdHal() 141 | hal.driveInCurve('forward', 'B', 10, 'C', 20) 142 | actors = hal.cfg['actors'] 143 | self.assertEqual(actors['B'].speed_sp * 2, actors['C'].speed_sp) 144 | 145 | def test_driveInCurve_Dist(self): 146 | hal = self._getStdHal() 147 | hal.driveInCurve('forward', 'B', 10, 'C', 20, 100) 148 | actors = hal.cfg['actors'] 149 | self.assertEqual(actors['B'].speed_sp * 2, actors['C'].speed_sp) 150 | self.assertGreater(actors['B'].position_sp, 0) 151 | self.assertGreater(actors['C'].position_sp, 0) 152 | 153 | def test_driveInCurve_DistBackward(self): 154 | hal = self._getStdHal() 155 | hal.driveInCurve('backward', 'B', 10, 'C', 20, 100) 156 | actors = hal.cfg['actors'] 157 | self.assertEqual(actors['B'].speed_sp * 2, actors['C'].speed_sp) 158 | self.assertLess(actors['B'].position_sp, 0) 159 | self.assertLess(actors['C'].position_sp, 0) 160 | 161 | def test_driveInCurve_ZeroSpeed(self): 162 | hal = self._getStdHal() 163 | actors = hal.cfg['actors'] 164 | hal.driveInCurve('forward', 'B', 0, 'C', 0, 100) 165 | self.assertEqual(actors['B'].speed_sp, 0) 166 | self.assertEqual(actors['C'].speed_sp, 0) 167 | 168 | def test_driveInCurve_OppositeSpeeds(self): 169 | hal = self._getStdHal() 170 | actors = hal.cfg['actors'] 171 | hal.driveInCurve('forward', 'B', 10, 'C', -10, 100) 172 | self.assertEqual(actors['B'].speed_sp, 10) 173 | self.assertEqual(actors['C'].speed_sp, -10) 174 | -------------------------------------------------------------------------------- /roberta/test_lab.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import httpretty 3 | import _thread 4 | import threading 5 | import time 6 | import unittest 7 | 8 | from roberta import lab 9 | from roberta.lab import Connector, Service, TOKEN_PER_SESSION 10 | 11 | from .test import Hal 12 | from .__version__ import version 13 | 14 | logging.basicConfig(level=logging.DEBUG) 15 | 16 | URL = 'https://lab.open-roberta.org' 17 | JSON = 'application/json' 18 | CMD_REPEAT = '{"cmd": "repeat"}' 19 | 20 | 21 | class DummyAbortHandler(threading.Thread): 22 | def __init__(self, to_sleep=0.0): 23 | threading.Thread.__init__(self) 24 | self.running = True 25 | self.to_sleep = to_sleep 26 | 27 | def run(self): 28 | if self.to_sleep: 29 | time.sleep(self.to_sleep) 30 | _thread.interrupt_main() 31 | 32 | def __enter__(self): 33 | self.start() 34 | 35 | def __exit__(self, type, value, traceback): 36 | if type is not None: # an exception has occurred 37 | return False # reraise the exception 38 | 39 | 40 | class DummyService(object): 41 | def __init__(self): 42 | self.hal = Hal(None) 43 | self.params = { 44 | 'macaddr': '00:00:00:00:00:00', 45 | 'firmwarename': 'ev3dev', 46 | 'menuversion': version.split('-')[0], 47 | } 48 | self.last_status = None 49 | 50 | def status(self, status): 51 | self.last_status = status 52 | 53 | 54 | class TestGetHwAddr(unittest.TestCase): 55 | def test_get_hw_addr(self): 56 | self.assertRegex(lab.getHwAddr(b'eth0'), '^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$') 57 | 58 | 59 | class TestGenerateToken(unittest.TestCase): 60 | def test_generate_token(self): 61 | self.assertRegex(lab.generateToken(), '^[0-9A-Z]{8}$') 62 | 63 | 64 | class TestGetBatteryVoltage(unittest.TestCase): 65 | def test_get_battery_voltage(self): 66 | self.assertGreaterEqual(float(lab.getBatteryVoltage()), 0.0) 67 | 68 | 69 | class TestService(unittest.TestCase): 70 | def test___init__(self): 71 | service = Service(None) 72 | self.assertNotEqual('00:00:00:00:00:00', service.params['macaddr']) 73 | 74 | def test_updateConfiguration(self): 75 | if TOKEN_PER_SESSION: 76 | return 77 | service = Service(None) 78 | token = service.params['token'] 79 | service.updateConfiguration() 80 | self.assertNotEqual(token, service.params['token']) 81 | 82 | 83 | """ 84 | def test_connect(self): 85 | # service = Service(path) 86 | # self.assertEqual(expected, service.connect(address)) 87 | assert False # TODO: implement your test here 88 | 89 | def test_disconnect(self): 90 | # service = Service(path) 91 | # self.assertEqual(expected, service.disconnect()) 92 | assert False # TODO: implement your test here 93 | 94 | def test_status(self): 95 | # service = Service(path) 96 | # self.assertEqual(expected, service.status(status)) 97 | assert False # TODO: implement your test here 98 | 99 | class TestAbortHandler(unittest.TestCase): 100 | def test___init__(self): 101 | abort_handler = AbortHandler(null) 102 | assert False # TODO: implement your test here 103 | 104 | def test_run(self): 105 | # abort_handler = AbortHandler(service) 106 | # self.assertEqual(expected, abort_handler.run()) 107 | assert False # TODO: implement your test here 108 | """ 109 | 110 | 111 | class TestConnector(unittest.TestCase): 112 | GOOD_CODE = ( 113 | 'if __name__ == "__main__":\n' 114 | ' pass\n' 115 | ) 116 | BAD_CODE = ( 117 | '{ this is not python, right?\n' 118 | ) 119 | GOOD_CODE_WITH_RESULT = ( 120 | 'if __name__ == "__main__":\n' 121 | ' result = 42\n' 122 | ) 123 | INFINITE_LOOP = ( 124 | 'import time\n' 125 | 'result=0\n' 126 | 'while True:\n' 127 | ' time.sleep(0.1)\n' 128 | ' result += 1\n' 129 | ) 130 | 131 | def test___init__(self): 132 | connector = Connector(URL, None) 133 | self.assertTrue(connector.running) 134 | 135 | @httpretty.activate 136 | def test_terminate_on_error(self): 137 | httpretty.register_uri(httpretty.POST, "%s/pushcmd" % URL, 138 | body=CMD_REPEAT, status=403, content_type=JSON) 139 | 140 | connector = Connector(URL, None) 141 | connector.run() # catch error and return 142 | 143 | # TODO: error_code is not set and the history of requests is only exposed in 144 | # newer version 145 | # @httpretty.activate 146 | # def test_retry_on_internal_server_error(self): 147 | # httpretty.register_uri(httpretty.POST, "%s/pushcmd" % URL, 148 | # body=CMD_REPEAT, status=500, content_type=JSON) 149 | # httpretty.register_uri(httpretty.POST, "%s/pushcmd" % URL, 150 | # body=CMD_REPEAT, status=403, content_type=JSON) 151 | # 152 | # connector = Connector(URL, None) 153 | # connector.run() # catch error and return 154 | # req = httpretty.last_request() 155 | # self.assertEqual(req.error_code, 403) 156 | 157 | @httpretty.activate 158 | def test_retires_rest_prefix(self): 159 | httpretty.register_uri(httpretty.POST, "%s/pushcmd" % URL, 160 | body=CMD_REPEAT, status=404, content_type=JSON) 161 | httpretty.register_uri(httpretty.POST, "%s/rest/pushcmd" % URL, 162 | body=CMD_REPEAT, status=403, content_type=JSON) 163 | 164 | connector = Connector(URL, None) 165 | connector.run() # catch error and return 166 | req = httpretty.last_request() 167 | self.assertEqual(req.path, '/rest/pushcmd') 168 | 169 | @httpretty.activate 170 | def test_sends_json_with_register(self): 171 | httpretty.register_uri(httpretty.POST, "%s/pushcmd" % URL, 172 | body=CMD_REPEAT, status=403, content_type=JSON) 173 | 174 | connector = Connector(URL, None) 175 | connector.run() 176 | req = httpretty.last_request() 177 | self.assertEqual(req.headers['Content-Type'], JSON) 178 | body = httpretty.last_request().parsed_body 179 | self.assertEqual(body['cmd'], 'register') 180 | self.assertIn('token', body) 181 | self.assertIn('brickname', body) 182 | 183 | @httpretty.activate 184 | def test_register(self): 185 | responses = [ 186 | httpretty.Response(body=CMD_REPEAT, status=200, content_type=JSON), 187 | httpretty.Response(body=CMD_REPEAT, status=403, content_type=JSON), 188 | ] 189 | httpretty.register_uri(httpretty.POST, "%s/pushcmd" % URL, responses=responses) 190 | 191 | connector = Connector(URL, DummyService()) 192 | connector.run() 193 | body = httpretty.last_request().parsed_body 194 | self.assertEqual(body['cmd'], 'push') 195 | self.assertIn('token', body) 196 | self.assertIn('brickname', body) 197 | 198 | def test_exec_good_code(self): 199 | connector = Connector(URL, None) 200 | res = connector._exec_code("test.py", TestConnector.GOOD_CODE, DummyAbortHandler()) 201 | self.assertEqual(res, 0) 202 | 203 | def test_exec_bad_code(self): 204 | connector = Connector(URL, None) 205 | res = connector._exec_code("test.py", TestConnector.BAD_CODE, DummyAbortHandler()) 206 | self.assertEqual(res, 1) 207 | 208 | def test_exec_code_with_result(self): 209 | connector = Connector(URL, None) 210 | res = connector._exec_code("test.py", TestConnector.GOOD_CODE_WITH_RESULT, DummyAbortHandler()) 211 | self.assertEqual(res, 42) 212 | 213 | def test_exec_code_with_infinite_loop(self): 214 | connector = Connector(URL, None) 215 | with self.assertRaises(KeyboardInterrupt): 216 | connector._exec_code("test.py", TestConnector.INFINITE_LOOP, DummyAbortHandler(to_sleep=0.3)) 217 | 218 | 219 | """ 220 | class TestCleanup(unittest.TestCase): 221 | def test_cleanup(self): 222 | # self.assertEqual(expected, cleanup()) 223 | assert False # TODO: implement your test here 224 | 225 | class TestMain(unittest.TestCase): 226 | def test_main(self): 227 | # self.assertEqual(expected, main()) 228 | assert False # TODO: implement your test here 229 | """ 230 | 231 | if __name__ == '__main__': 232 | unittest.main() 233 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | -------------------------------------------------------------------------------- /docs/architecture.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 27 | 32 | 33 | 41 | 46 | 47 | 55 | 60 | 61 | 69 | 74 | 75 | 83 | 88 | 89 | 97 | 102 | 103 | 111 | 116 | 117 | 125 | 130 | 131 | 132 | 153 | 156 | 160 | 161 | 163 | 164 | 166 | image/svg+xml 167 | 169 | 170 | 171 | 172 | 173 | 177 | 180 | 191 | Cloud 204 | 215 | 226 | ev3devbrickman 244 | 252 | DBus 265 | 273 | Https 286 | Ev3 299 | 300 | 305 | 314 | robertalabev3dev 330 | 331 | 336 | 345 | open-robertaserver 361 | 362 | 363 | 364 | -------------------------------------------------------------------------------- /roberta/lab.py: -------------------------------------------------------------------------------- 1 | from .__version__ import version 2 | import ctypes 3 | import dbus 4 | import dbus.service 5 | from fcntl import ioctl 6 | import json 7 | import logging 8 | import os 9 | import socket 10 | import stat 11 | import struct 12 | import time 13 | import _thread 14 | import threading 15 | import urllib.request 16 | import urllib.error 17 | import urllib.parse 18 | import sys 19 | 20 | local_pkg_path = os.path.expanduser('~/.local/lib/python') 21 | # ignore failure to make this testable outside of the target platform 22 | try: 23 | from ev3dev import auto as ev3dev 24 | from .ev3 import Hal 25 | except ImportError: 26 | from .test import Ev3dev as ev3dev 27 | from .test import Hal 28 | 29 | logger = logging.getLogger('roberta.lab') 30 | 31 | # configuration 32 | 33 | # TRUE: use a new token per reconnect 34 | # FALSE: try keep using the token for as long as possible 35 | # (needs robertalab > 1.4 or develop branch) 36 | TOKEN_PER_SESSION = True 37 | 38 | 39 | # helpers 40 | def getHwAddr(ifname): 41 | # SIOCGIFHWADDR = 0x8927 42 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: 43 | info = ioctl(s.fileno(), 0x8927, struct.pack('256s', ifname[:15])) 44 | return ':'.join(['%02x' % char for char in info[18:24]]) 45 | 46 | 47 | def generateToken(): 48 | # note: we intentionally leave '01' and 'IO' out since they can be confused 49 | # when entering the code 50 | chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' 51 | # note: we don't use the random module since it is large 52 | b = os.urandom(8) 53 | return ''.join(chars[b[i] % len(chars)] for i in range(8)) 54 | 55 | 56 | def getBatteryVoltage(): 57 | return "{0:.3f}".format(ev3dev.PowerSupply().measured_volts) 58 | 59 | 60 | class Service(dbus.service.Object): 61 | """OpenRobertab-Lab dbus service 62 | 63 | The status state machine is as follows: 64 | 65 | +-> disconnected 66 | | | 67 | | v 68 | +- connected 69 | | | 70 | | v 71 | +- registered 72 | | ^ 73 | | v 74 | +- executing 75 | 76 | """ 77 | 78 | def __init__(self, path): 79 | logger.info('version: %s', version) 80 | logger.info('python path: %s', (':'.join(sys.path))) 81 | # passing None for path is only for testing 82 | if path: 83 | # needs /etc/dbus-1/system.d/openroberta.conf 84 | bus_name = dbus.service.BusName('org.openroberta.lab', bus=dbus.SystemBus()) 85 | dbus.service.Object.__init__(self, bus_name, path) 86 | logger.debug('object registered') 87 | self.status('disconnected') 88 | self.hal = Hal(None) 89 | self.hal.clearDisplay() 90 | self.thread = None 91 | self.params = { 92 | 'macaddr': '00:00:00:00:00:00', 93 | 'firmwarename': 'ev3dev', 94 | 'menuversion': version.split('-')[0], 95 | } 96 | self.updateConfiguration() 97 | 98 | def updateConfiguration(self): 99 | # or /etc/os-release 100 | with open('/proc/version', 'r') as ver: 101 | self.params['firmwareversion'] = ver.read() 102 | 103 | for iface in ['wlan', 'usb', 'eth']: 104 | for ix in range(10): 105 | try: 106 | ifname = bytes(iface + str(ix), 'ascii') 107 | self.params['macaddr'] = getHwAddr(ifname) 108 | break 109 | except IOError: 110 | pass 111 | # reusing token is nice for developers, but the server started to reject 112 | # them 113 | if not TOKEN_PER_SESSION: 114 | self.params['token'] = generateToken() 115 | 116 | @dbus.service.method('org.openroberta.lab', in_signature='s', out_signature='s') 117 | def connect(self, address): 118 | logger.debug('connect(%s)', address) 119 | if self.thread: 120 | logger.debug('disconnect() old thread') 121 | # make sure we don't change to disconnected when the thread 122 | # eventually terminates after the http timeout 123 | self.thread.service = None 124 | self.thread.running = False 125 | # start thread, connecting to address 126 | self.thread = Connector(address, self) 127 | self.thread.daemon = True 128 | self.thread.start() 129 | # TODO: we have to 'wait' until the connection has been established and 130 | # we got the token 131 | # - we could defer the "connected" signal and add another method to get 132 | # the code 133 | self.status('connected') 134 | return self.thread.params['token'] 135 | 136 | @dbus.service.method('org.openroberta.lab') 137 | def disconnect(self): 138 | logger.debug('disconnect()') 139 | self.thread.running = False 140 | self.status('disconnected') 141 | # end thread, can take up to 15 seconds (the timeout to return) 142 | # hence we don't join(), when connecting again we create a new thread 143 | # anyway 144 | # self.thread.join() 145 | # self.status('disconnected') 146 | self.thread = None 147 | 148 | @dbus.service.signal('org.openroberta.lab', signature='s') 149 | def status(self, status): 150 | logger.info('status changed: %s', status) 151 | 152 | 153 | class GfxMode(object): 154 | 155 | def __init__(self): 156 | self.tty_name = os.ttyname(sys.stdin.fileno()) 157 | 158 | def __enter__(self): 159 | logger.info('running on tty: %s', self.tty_name) 160 | with open(self.tty_name, 'r') as tty: 161 | # KDSETMODE = 0x4B3A, GRAPHICS = 0x01 162 | ioctl(tty, 0x4B3A, 0x01) 163 | 164 | def __exit__(self, type, value, traceback): 165 | with open(self.tty_name, 'w') as tty: 166 | # KDSETMODE = 0x4B3A, TEXT = 0x00 167 | ioctl(tty, 0x4B3A, 0x00) 168 | # send Ctrl-L to tty to clear 169 | tty.write('\033c') 170 | 171 | 172 | class AbortHandler(threading.Thread): 173 | """ Key press handler to abort running programms. 174 | Tests for a center+down press to soft-kill the programm or a 1 sec back 175 | key press and terminate the whole process""" 176 | 177 | def __init__(self, service, runner): 178 | threading.Thread.__init__(self) 179 | self.service = service 180 | self.running = True 181 | self.runner = runner 182 | 183 | def run(self): 184 | long_press = 0 185 | hal = self.service.hal 186 | while self.running: 187 | if hal.isKeyPressed('back'): 188 | logger.debug('back: %d', long_press) 189 | # if pressed for one sec, hard exit 190 | if long_press > 10: 191 | logger.info('--- hard abort ---') 192 | _thread.interrupt_main() # throws KeyboardInterrupt 193 | self.running = False 194 | # something is eating the KeyboardInterrupt, this is a bit 195 | # brute force, but works 196 | os._exit(1) 197 | else: 198 | long_press += 1 199 | elif hal.isKeyPressed('enter') and hal.isKeyPressed('down'): 200 | logger.debug('--- soft-abort ---') 201 | self.running = False 202 | self.ctype_async_raise(SystemExit) 203 | else: 204 | long_press = 0 205 | time.sleep(0.1) 206 | 207 | def __enter__(self): 208 | self.start() 209 | 210 | def __exit__(self, type, value, traceback): 211 | self.running = False 212 | if type is not None: # an exception has occurred 213 | logger.debug('Reraising exception: %s', str(type)) 214 | return False # reraise the exception 215 | else: 216 | logger.debug('No exception') 217 | return True 218 | 219 | def ctype_async_raise(self, exception): 220 | # adapted from https://gist.github.com/liuw/2407154 221 | found = False 222 | target_tid = 0 223 | for tid, tobj in list(threading._active.items()): 224 | if tobj is self.runner: 225 | found = True 226 | target_tid = tid 227 | break 228 | if not found: 229 | raise ValueError("Invalid thread object") 230 | 231 | ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(target_tid), ctypes.py_object(exception)) 232 | # ref: http://docs.python.org/c-api/init.html#PyThreadState_SetAsyncExc 233 | if ret == 0: 234 | raise ValueError("Invalid thread ID") 235 | elif ret > 1: 236 | # Huh? Why would we notify more than one threads? 237 | # Because we punch a hole into C level interpreter. 238 | # So it is better to clean up the mess. 239 | ctypes.pythonapi.PyThreadState_SetAsyncExc(target_tid, 0) 240 | raise SystemError("PyThreadState_SetAsyncExc failed") 241 | logger.debug("Successfully set asynchronized exception for %d", target_tid) 242 | 243 | 244 | class Connector(threading.Thread): 245 | """OpenRobertab-Lab network IO thread""" 246 | 247 | def __init__(self, address, service): 248 | threading.Thread.__init__(self) 249 | self.address = address.split('://', 1)[-1] # stip protocol part 250 | self.service = service 251 | self.home = os.path.expanduser("~") 252 | if service: 253 | self.params = service.params 254 | else: 255 | self.params = {} 256 | if TOKEN_PER_SESSION: 257 | self.params['token'] = generateToken() 258 | 259 | self.registered = False 260 | self.running = True # Used to cancel this through self.thread.running 261 | logger.debug('thread created') 262 | 263 | def _store_code(self, filename, code): 264 | # TODO: what can we do if the file can't be overwritten 265 | # https://github.com/OpenRoberta/robertalab-ev3dev/issues/26 266 | # - there is no point in catching if we only log it 267 | # - once we can report error details to the server, we can reconsider 268 | # https://github.com/OpenRoberta/robertalab-ev3dev/issues/20 269 | with open(filename, 'w') as prog: 270 | # Apply hotfixes needed until server update 271 | # - the server generated code is python2 still 272 | code = code.replace('from __future__ import absolute_import\n', '') 273 | code = code.replace('in xrange(', 'in range(') 274 | code = code.replace('#!/usr/bin/python\n', '#!/usr/bin/python3\n') 275 | prog.write(code) 276 | os.chmod(filename, stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR) 277 | return code 278 | 279 | def _exec_code(self, filename, code, abort_handler): 280 | result = 0 281 | # using a new process would be using this, but is slower (4s vs <1s): 282 | # result = subprocess.call(["python", filename], env={"PYTHONPATH":"$PYTONPATH:."}) 283 | # logger.info('execution result: %d' % result) 284 | # 285 | # NOTE: we don't have to keep pinging the server while running 286 | # the code - robot is busy until we send push request again 287 | # it would be nice though if we could cancel the running program 288 | try: 289 | compiled_code = compile(code, filename, 'exec') 290 | with abort_handler: 291 | scope = { 292 | '__name__': '__main__', 293 | 'result': 0, 294 | } 295 | exec(compiled_code, scope) 296 | result = scope['result'] 297 | logger.info('execution finished: result = %d', result) 298 | except KeyboardInterrupt: 299 | logger.info("reraise hard kill") 300 | raise 301 | except SystemExit: 302 | result = 143 303 | logger.info("soft kill") 304 | except: # noqa: E722 305 | result = 1 306 | # TODO: return exception details as a string and put into a 307 | # 'nepoexitdetails' field, so that we can show this in the UI 308 | logger.exception("Ooops:") 309 | return result 310 | 311 | def _request(self, cmd, headers, timeout, send_params=True): 312 | protocol = 'https' 313 | url = '%s://%s/%s' % (protocol, self.address, cmd) 314 | while True: 315 | try: 316 | logger.debug('sending request to: %s', url) 317 | req = urllib.request.Request(url, headers=headers) 318 | data = None 319 | if send_params: 320 | data = json.dumps(self.params).encode('utf8') 321 | logger.debug(' with params: %s', data) 322 | return urllib.request.urlopen(req, data, timeout=timeout) 323 | except urllib.error.HTTPError as e: 324 | if e.code == 404 and '/rest/' not in url: 325 | logger.warning("HTTPError(%s): %s, retrying with '/rest'", e.code, e.reason) 326 | # upstream changed the server path 327 | url = '%s://%s/rest/%s' % (protocol, self.address, cmd) 328 | elif e.code == 405 and protocol == 'https': 329 | # TODO(ensonic): this only works for http->https 330 | logger.warning("HTTPError(%s): %s, retrying with 'http://'", e.code, e.reason) 331 | protocol = 'http' 332 | url = "http" + url[5:] 333 | else: 334 | logger.warning("HTTPError(%s): %s, unhandled!'", e.code, e.reason) 335 | raise e 336 | except urllib.error.URLError as e: 337 | # [SSL: UNKNOWN_PROTOCOL] unknown protocol 338 | logger.warning("URLError(%s): %s, retrying with 'http://'", e.errno, e.reason) 339 | protocol = 'http' 340 | url = "http" + url[5:] 341 | return None 342 | 343 | def run(self): 344 | logger.debug('network thread started') 345 | # network related locals 346 | # TODO: change the user agent: 347 | # https://docs.python.org/2/library/urllib2.html#urllib2.Request 348 | # default is "Python-urllib/" 349 | headers = { 350 | 'Content-Type': 'application/json' 351 | } 352 | timeout = 15 # seconds 353 | 354 | logger.debug('target: %s', self.address) 355 | while self.running: 356 | if self.registered: 357 | self.params['cmd'] = 'push' 358 | timeout = 15 359 | else: 360 | self.params['cmd'] = 'register' 361 | timeout = 330 362 | self.params['brickname'] = socket.gethostname() 363 | self.params['battery'] = getBatteryVoltage() 364 | 365 | try: 366 | # TODO: according to https://tools.ietf.org/html/rfc6202 367 | # we should use keep alive 368 | # http://stackoverflow.com/questions/1037406/python-urllib2-with-keep-alive 369 | # http://stackoverflow.com/questions/13881196/remove-http-connection-header-python-urllib2 370 | # https://github.com/jcgregorio/httplib2 371 | response = self._request("pushcmd", headers, timeout) 372 | reply = json.loads(response.read().decode('utf8')) 373 | logger.debug('response: %s', json.dumps(reply)) 374 | cmd = reply['cmd'] 375 | if cmd == 'repeat': 376 | if not self.registered: 377 | self.service.status('registered') 378 | self.service.hal.playFile(2) 379 | self.registered = True 380 | self.params['nepoexitvalue'] = 0 381 | elif cmd == 'abort': 382 | # if service is None, the user canceled 383 | if not self.registered and self.service: 384 | logger.info('token collision, retrying') 385 | self.params['token'] = generateToken() 386 | # make sure we don't DOS the server 387 | time.sleep(1.0) 388 | else: 389 | break 390 | elif cmd == 'download': 391 | # TODO: url is not part of reply :/ 392 | # TODO: we should receive a digest for the download (md5sum) so that 393 | # we can verify the download 394 | logger.debug('download code: %s/download', self.address) 395 | response = self._request('download', headers, timeout) 396 | hdr = response.getheader('Content-Disposition') 397 | # save to $HOME/ 398 | filename = os.path.join(self.home, hdr.split('=')[1] if hdr else 'unknown') 399 | code = self._store_code(filename, response.read().decode('utf-8')) 400 | logger.info('code downloaded to: %s', filename) 401 | # use a long-press of backspace to terminate 402 | abort_handler = AbortHandler(self.service, self) 403 | abort_handler.daemon = True 404 | # This will make brickman switch vt 405 | self.service.status('executing') 406 | with GfxMode(): 407 | self.service.hal.clearDisplay() 408 | self.params['nepoexitvalue'] = self._exec_code(filename, code, abort_handler) 409 | # if the user did wait for a key press, wait for the key for be released 410 | # before handing control back (to e.g. brickman) 411 | while self.service.hal.isKeyPressed('any'): 412 | time.sleep(0.1) 413 | self.service.hal.resetState() 414 | self.service.status('registered') 415 | elif cmd == 'update': 416 | # import them here, since we don't use them otherwise 417 | from io import BytesIO 418 | import shutil 419 | import zipfile 420 | 421 | logger.info('download update: %s/update/ev3dev/runtime', self.address) 422 | # fetch roberta.zip 423 | response = self._request('update/ev3dev/runtime', headers, timeout, send_params=False) 424 | zip_buf = BytesIO(response.read()) 425 | # remove the 'roberta' dir first and then recreate to make sure we don't accumulate files 426 | shutil.rmtree(os.path.join(local_pkg_path, 'roberta'), ignore_errors=True) 427 | # and unpack update 428 | with zipfile.ZipFile(zip_buf, 'r') as zip_ref: 429 | zip_ref.extractall(local_pkg_path) 430 | logger.info('firmware updated') 431 | # then restart: 432 | # TODO: maybe we can reuse the token (pass as arg)? 433 | os.execl(sys.executable, sys.executable, *sys.argv) 434 | else: 435 | logger.warning('unhandled command: %s', cmd) 436 | except urllib.error.HTTPError as e: 437 | # e.g. [Errno 404] 438 | retry = False 439 | 440 | # various server errors where we should just retry 441 | if 500 <= e.code <= 510: 442 | retry = True 443 | 444 | if not retry: 445 | logger.error("HTTPError(%s): %s", e.code, e.reason) 446 | break 447 | else: 448 | logger.error("HTTPError(%s): %s (retrying)", e.code, e.reason) 449 | except urllib.error.URLError as e: 450 | # e.g. [Errno 111] Connection refused 451 | # The handshake operation timed out 452 | # errors can be nested 453 | nested_e = None 454 | if len(e.args) > 0: 455 | nested_e = e.args[0] 456 | elif e.__cause__: 457 | nested_e = e.__cause__ 458 | retry = False 459 | if nested_e: 460 | # this happens if packets were lost 461 | if isinstance(nested_e, socket.timeout): 462 | retry = True 463 | # this happens if we loose network 464 | if isinstance(nested_e, socket.gaierror): 465 | retry = True 466 | if isinstance(nested_e, socket.herror): 467 | retry = True 468 | if isinstance(nested_e, socket.error): 469 | retry = True 470 | else: 471 | retry = True 472 | 473 | if not retry: 474 | logger.error("URLError: %s: %s", self.address, e.reason) 475 | logger.debug("URLError: %s", repr(e)) 476 | if nested_e: 477 | logger.debug("Nested Exception: %s", repr(nested_e)) 478 | break 479 | else: 480 | logger.info("URLError: %s: %s (retrying)", self.address, e.reason) 481 | except (socket.timeout, socket.gaierror, socket.herror, socket.error): 482 | pass 483 | except: # noqa: E722 484 | logger.exception("Ooops:") 485 | logger.info('network thread stopped') 486 | if self.service: 487 | self.service.status('disconnected') 488 | # don't play if we we just canceled a registration 489 | if self.registered: 490 | self.service.hal.playFile(3) 491 | -------------------------------------------------------------------------------- /roberta/ev3.py: -------------------------------------------------------------------------------- 1 | 2 | from PIL import Image, ImageFont 3 | import dbus # only for waitForConnection() bluetooth 4 | import glob # only for stopAllMotors() 5 | import logging 6 | import math 7 | import os 8 | import threading # only for ledOn() animations 9 | import time 10 | 11 | # ignore failure to make this testable outside of the target platform 12 | try: 13 | # this has not be release to debian 14 | # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=787850 15 | import bluetooth 16 | from bluetooth import BluetoothSocket 17 | except ImportError: 18 | pass 19 | 20 | try: 21 | from ev3dev import auto as ev3dev 22 | except ImportError: 23 | from .test import Ev3dev as ev3dev 24 | 25 | logger = logging.getLogger('roberta.ev3') 26 | 27 | 28 | def clamp(v, mi, ma): 29 | return mi if v < mi else ma if v > ma else v 30 | 31 | 32 | class Hal(object): 33 | # class global, so that the front-end can cleanup on forced termination 34 | # popen objects 35 | cmds = [] 36 | # led blinker 37 | led_blink_thread = None 38 | led_blink_running = False 39 | 40 | GYRO_MODES = { 41 | 'angle': 'GYRO-ANG', 42 | 'rate': 'GYRO-RATE', 43 | } 44 | 45 | LED_COLORS = { 46 | 'green': ev3dev.Leds.GREEN + ev3dev.Leds.GREEN, 47 | 'red': ev3dev.Leds.RED + ev3dev.Leds.RED, 48 | 'orange': ev3dev.Leds.ORANGE + ev3dev.Leds.ORANGE, 49 | 'black': ev3dev.Leds.BLACK + ev3dev.Leds.BLACK, 50 | } 51 | 52 | LED_ALL = ev3dev.Leds.LEFT + ev3dev.Leds.RIGHT 53 | 54 | def __init__(self, brickConfiguration): 55 | self.cfg = brickConfiguration 56 | dir = os.path.dirname(__file__) 57 | # char size: 6 x 12 -> num-chars: 29.666667 x 10.666667 58 | self.font_s = ImageFont.load(os.path.join(dir, 'ter-u12n_unicode.pil')) 59 | # char size: 10 x 18 -> num-chars: 17.800000 x 7.111111 60 | # self.font_s = ImageFont.load(os.path.join(dir, 'ter-u18n_unicode.pil')) 61 | self.lcd = ev3dev.Screen() 62 | self.led = ev3dev.Leds 63 | self.keys = ev3dev.Button() 64 | self.sound = ev3dev.Sound 65 | (self.font_w, self.font_h) = self.lcd.draw.textsize('X', font=self.font_s) 66 | # logger.info('char size: %d x %d -> num-chars: %f x %f', 67 | # self.font_w, self.font_h, 178 / self.font_w, 128 / self.font_h) 68 | self.timers = {} 69 | self.sys_bus = None 70 | self.bt_server = None 71 | self.bt_connections = [] 72 | self.lang = 'de' 73 | 74 | # factory methods 75 | @staticmethod 76 | # TODO(ensonic): 'regulated' is unused, it is passed to the motor-functions 77 | # directly, consider making all params after port 'kwargs' 78 | def makeLargeMotor(port, regulated, direction): 79 | try: 80 | m = ev3dev.LargeMotor(port) 81 | if direction == 'backward': 82 | m.polarity = 'inversed' 83 | else: 84 | m.polarity = 'normal' 85 | except (AttributeError, OSError): 86 | logger.info('no large motor connected to port [%s]', port) 87 | logger.exception("HW Config error") 88 | m = None 89 | return m 90 | 91 | @staticmethod 92 | def makeMediumMotor(port, regulated, direction): 93 | try: 94 | m = ev3dev.MediumMotor(port) 95 | if direction == 'backward': 96 | m.polarity = 'inversed' 97 | else: 98 | m.polarity = 'normal' 99 | except (AttributeError, OSError): 100 | logger.info('no medium motor connected to port [%s]', port) 101 | logger.exception("HW Config error") 102 | m = None 103 | return m 104 | 105 | @staticmethod 106 | def makeOtherConsumer(port, regulated, direction): 107 | try: 108 | lp = ev3dev.LegoPort(port) 109 | lp.mode = 'dc-motor' 110 | # https://github.com/ev3dev/ev3dev-lang-python/issues/234 111 | # some time is needed to set the permissions for the new attributes 112 | time.sleep(0.5) 113 | m = ev3dev.DcMotor(address=port) 114 | except (AttributeError, OSError): 115 | logger.info('no other consumer connected to port [%s]', port) 116 | logger.exception("HW Config error") 117 | m = None 118 | return m 119 | 120 | @staticmethod 121 | def makeColorSensor(port): 122 | try: 123 | s = ev3dev.ColorSensor(port) 124 | except (AttributeError, OSError): 125 | logger.info('no color sensor connected to port [%s]', port) 126 | s = None 127 | return s 128 | 129 | @staticmethod 130 | def makeGyroSensor(port): 131 | try: 132 | s = ev3dev.GyroSensor(port) 133 | except (AttributeError, OSError): 134 | logger.info('no gyro sensor connected to port [%s]', port) 135 | s = None 136 | return s 137 | 138 | @staticmethod 139 | def makeI2cSensor(port): 140 | try: 141 | s = ev3dev.I2cSensor(port) 142 | except (AttributeError, OSError): 143 | logger.info('no i2c sensor connected to port [%s]', port) 144 | s = None 145 | return s 146 | 147 | @staticmethod 148 | def makeInfraredSensor(port): 149 | try: 150 | s = ev3dev.InfraredSensor(port) 151 | except (AttributeError, OSError): 152 | logger.info('no infrared sensor connected to port [%s]', port) 153 | s = None 154 | return s 155 | 156 | @staticmethod 157 | def makeLightSensor(port): 158 | try: 159 | p = ev3dev.LegoPort(port) 160 | p.set_device = 'lego-nxt-light' 161 | s = ev3dev.LightSensor(port) 162 | except (AttributeError, OSError): 163 | logger.info('no light sensor connected to port [%s]', port) 164 | s = None 165 | return s 166 | 167 | @staticmethod 168 | def makeSoundSensor(port): 169 | try: 170 | p = ev3dev.LegoPort(port) 171 | p.set_device = 'lego-nxt-sound' 172 | s = ev3dev.SoundSensor(port) 173 | except (AttributeError, OSError): 174 | logger.info('no sound sensor connected to port [%s]', port) 175 | s = None 176 | return s 177 | 178 | @staticmethod 179 | def makeTouchSensor(port): 180 | try: 181 | s = ev3dev.TouchSensor(port) 182 | except (AttributeError, OSError): 183 | logger.info('no touch sensor connected to port [%s]', port) 184 | s = None 185 | return s 186 | 187 | @staticmethod 188 | def makeUltrasonicSensor(port): 189 | try: 190 | s = ev3dev.UltrasonicSensor(port) 191 | except (AttributeError, OSError): 192 | logger.info('no ultrasonic sensor connected to port [%s]', port) 193 | s = None 194 | return s 195 | 196 | @staticmethod 197 | def makeCompassSensor(port): 198 | try: 199 | s = ev3dev.Sensor(address=port, driver_name='ht-nxt-compass') 200 | except (AttributeError, OSError): 201 | logger.info('no compass sensor connected to port [%s]', port) 202 | s = None 203 | return s 204 | 205 | @staticmethod 206 | def makeIRSeekerSensor(port): 207 | try: 208 | s = ev3dev.Sensor(address=port, driver_name='ht-nxt-ir-seek-v2') 209 | except (AttributeError, OSError): 210 | logger.info('no ir seeker v2 sensor connected to port [%s]', port) 211 | s = None 212 | return s 213 | 214 | @staticmethod 215 | def makeHTColorSensorV2(port): 216 | try: 217 | s = ev3dev.Sensor(address=port, driver_name='ht-nxt-color-v2') 218 | except (AttributeError, OSError): 219 | logger.info('no hitechnic color sensor v2 connected to port [%s]', port) 220 | s = None 221 | return s 222 | 223 | # state 224 | def resetState(self): 225 | self.clearDisplay() 226 | self.stopAllMotors() 227 | self.resetAllOutputs() 228 | self.resetLED() 229 | logger.debug("terminate %d commands", len(Hal.cmds)) 230 | for cmd in Hal.cmds: 231 | if cmd: 232 | logger.debug("terminate command: %s", str(cmd)) 233 | cmd.terminate() 234 | cmd.wait() # avoid zombie processes 235 | Hal.cmds = [] 236 | 237 | # control 238 | def waitFor(self, ms): 239 | time.sleep(ms / 1000.0) 240 | 241 | def busyWait(self): 242 | """Used as interruptable busy wait.""" 243 | time.sleep(0.0) 244 | 245 | def waitCmd(self, cmd): 246 | """Wait for a command to finish.""" 247 | Hal.cmds.append(cmd) 248 | # we're not using cmd.wait() since that is not interruptable 249 | while cmd.poll() is None: 250 | self.busyWait() 251 | Hal.cmds.remove(cmd) 252 | 253 | # lcd 254 | def drawText(self, msg, x, y, font=None): 255 | font = font or self.font_s 256 | self.lcd.draw.text((x * self.font_w, y * self.font_h), msg, font=font) 257 | self.lcd.update() 258 | 259 | def drawPicture(self, picture, x, y): 260 | # logger.info('len(picture) = %d', len(picture)) 261 | size = (178, 128) 262 | # One image is supposed to be 178*128/8 = 2848 bytes 263 | # string data is in utf-16 format and padding with extra 0 bytes 264 | data = bytes(picture, 'utf-16')[::2] 265 | pixels = Image.frombytes('1', size, data, 'raw', '1;IR', 0, 1) 266 | self.lcd.image.paste(pixels, (x, y)) 267 | self.lcd.update() 268 | 269 | def clearDisplay(self): 270 | self.lcd.clear() 271 | self.lcd.update() 272 | 273 | # led 274 | 275 | def ledStopAnim(self): 276 | if Hal.led_blink_running: 277 | Hal.led_blink_running = False 278 | Hal.led_blink_thread.join() 279 | Hal.led_blink_thread = None 280 | 281 | def ledOn(self, color, mode): 282 | def ledAnim(anim): 283 | while Hal.led_blink_running: 284 | for step in anim: 285 | self.led.set_color(Hal.LED_ALL, step[1]) 286 | time.sleep(step[0]) 287 | if not Hal.led_blink_running: 288 | break 289 | 290 | self.ledStopAnim() 291 | # color: green, red, orange - LED.COLOR.{RED,GREEN,AMBER} 292 | # mode: on, flash, double_flash 293 | on = Hal.LED_COLORS[color] 294 | off = Hal.LED_COLORS['black'] 295 | if mode == 'on': 296 | self.led.set_color(Hal.LED_ALL, on) 297 | elif mode == 'flash': 298 | Hal.led_blink_thread = threading.Thread( 299 | target=ledAnim, args=([(0.5, on), (0.5, off)],)) 300 | Hal.led_blink_running = True 301 | Hal.led_blink_thread.start() 302 | elif mode == 'double_flash': 303 | Hal.led_blink_thread = threading.Thread( 304 | target=ledAnim, args=([(0.15, on), (0.15, off), (0.15, on), (0.55, off)],)) 305 | Hal.led_blink_running = True 306 | Hal.led_blink_thread.start() 307 | 308 | def ledOff(self): 309 | self.led.all_off() 310 | self.ledStopAnim() 311 | 312 | def resetLED(self): 313 | self.ledOff() 314 | 315 | # key 316 | def isKeyPressed(self, key): 317 | if key in ['any', '*']: 318 | return self.keys.any() 319 | else: 320 | # remap some keys 321 | key_aliases = { 322 | 'escape': 'backspace', 323 | 'back': 'backspace', 324 | } 325 | if key in key_aliases: 326 | key = key_aliases[key] 327 | return key in self.keys.buttons_pressed 328 | 329 | def isKeyPressedAndReleased(self, key): 330 | return False 331 | 332 | # tones 333 | def playTone(self, frequency, duration): 334 | # this is already handled by the sound api (via beep cmd) 335 | # frequency = frequency if frequency >= 100 else 0 336 | self.waitCmd(self.sound.tone(frequency, duration)) 337 | 338 | def playFile(self, systemSound): 339 | # systemSound is a enum for preset beeps: 340 | # http://www.lejos.org/ev3/docs/lejos/hardware/Audio.html#systemSound-int- 341 | # http://sf.net/p/lejos/ev3/code/ci/master/tree/ev3classes/src/lejos/remote/nxt/RemoteNXTAudio.java#l20 342 | C2 = 523 343 | if systemSound == 0: 344 | self.playTone(600, 200) 345 | elif systemSound == 1: 346 | self.sound.tone([(600, 150, 50), (600, 150, 50)]).wait() 347 | elif systemSound == 2: # C major arpeggio 348 | self.sound.tone([(C2 * i / 4, 50, 50) for i in range(4, 7)]).wait() 349 | elif systemSound == 3: 350 | self.sound.tone([(C2 * i / 4, 50, 50) for i in range(7, 4, -1)]).wait() 351 | elif systemSound == 4: 352 | self.playTone(100, 500) 353 | 354 | def setVolume(self, volume): 355 | self.sound.set_volume(volume) 356 | 357 | def getVolume(self): 358 | return self.sound.get_volume() 359 | 360 | def setLanguage(self, lang): 361 | # lang: 2digit ISO_639-1 code 362 | self.lang = lang 363 | 364 | def sayText(self, text, speed=30, pitch=50): 365 | # a: amplitude, 0..200, def=100 366 | # p: pitch, 0..99, def=50 367 | # s: speed, 80..450, def=175 368 | opts = '-a 200 -p %d -s %d -v %s' % ( 369 | int(clamp(pitch, 0, 100) * 0.99), # use range 0 - 99 370 | int(clamp(speed, 0, 100) * 2.5 + 100), # use range 100 - 350 371 | self.lang + "+f1") # female voice 372 | self.waitCmd(self.sound.speak(text, espeak_opts=opts)) 373 | 374 | # actors 375 | # http://www.ev3dev.org/docs/drivers/tacho-motor-class/ 376 | def scaleSpeed(self, m, speed_pct): 377 | return int(speed_pct * m.max_speed / 100.0) 378 | 379 | def rotateRegulatedMotor(self, port, speed_pct, mode, value): 380 | # mode: degree, rotations, distance 381 | m = self.cfg['actors'][port] 382 | speed = self.scaleSpeed(m, clamp(speed_pct, -100, 100)) 383 | if mode == 'degree': 384 | m.run_to_rel_pos(position_sp=value, speed_sp=speed) 385 | while m.state and 'stalled' not in m.state: 386 | self.busyWait() 387 | elif mode == 'rotations': 388 | value *= m.count_per_rot 389 | m.run_to_rel_pos(position_sp=int(value), speed_sp=speed) 390 | while m.state and 'stalled' not in m.state: 391 | self.busyWait() 392 | 393 | def rotateUnregulatedMotor(self, port, speed_pct, mode, value): 394 | speed_pct = clamp(speed_pct, -100, 100) 395 | m = self.cfg['actors'][port] 396 | if mode == 'rotations': 397 | value *= m.count_per_rot 398 | if speed_pct >= 0: 399 | value = m.position + value 400 | m.run_direct(duty_cycle_sp=int(speed_pct)) 401 | while m.position < value and 'stalled' not in m.state: 402 | self.busyWait() 403 | else: 404 | value = m.position - value 405 | m.run_direct(duty_cycle_sp=int(speed_pct)) 406 | while m.position > value and 'stalled' not in m.state: 407 | self.busyWait() 408 | m.stop() 409 | 410 | def turnOnRegulatedMotor(self, port, value): 411 | m = self.cfg['actors'][port] 412 | m.run_forever(speed_sp=self.scaleSpeed(m, clamp(value, -100, 100))) 413 | 414 | def turnOnUnregulatedMotor(self, port, value): 415 | value = clamp(value, -100, 100) 416 | self.cfg['actors'][port].run_direct(duty_cycle_sp=int(value)) 417 | 418 | def setRegulatedMotorSpeed(self, port, value): 419 | m = self.cfg['actors'][port] 420 | # https://github.com/rhempel/ev3dev-lang-python/issues/263 421 | # m.speed_sp = self.scaleSpeed(m, clamp(value, -100, 100)) 422 | m.run_forever(speed_sp=self.scaleSpeed(m, clamp(value, -100, 100))) 423 | 424 | def setUnregulatedMotorSpeed(self, port, value): 425 | value = clamp(value, -100, 100) 426 | self.cfg['actors'][port].duty_cycle_sp = int(value) 427 | 428 | def getRegulatedMotorSpeed(self, port): 429 | m = self.cfg['actors'][port] 430 | return m.speed * 100.0 / m.max_speed 431 | 432 | def getUnregulatedMotorSpeed(self, port): 433 | return self.cfg['actors'][port].duty_cycle 434 | 435 | def stopMotor(self, port, mode='float'): 436 | # mode: float, nonfloat 437 | # stop_actions: ['brake', 'coast', 'hold'] 438 | m = self.cfg['actors'][port] 439 | if mode == 'float': 440 | m.stop_action = 'coast' 441 | elif mode == 'nonfloat': 442 | m.stop_action = 'brake' 443 | m.stop() 444 | 445 | def stopMotors(self, left_port, right_port): 446 | self.stopMotor(left_port) 447 | self.stopMotor(right_port) 448 | 449 | def stopAllMotors(self): 450 | # [m for m in [Motor(port) for port in ['outA', 'outB', 'outC', 'outD']] if m.connected] 451 | for file in glob.glob('/sys/class/tacho-motor/motor*/command'): 452 | with open(file, 'w') as f: 453 | f.write('stop') 454 | for file in glob.glob('/sys/class/dc-motor/motor*/command'): 455 | with open(file, 'w') as f: 456 | f.write('stop') 457 | 458 | def resetAllOutputs(self): 459 | for port in (ev3dev.OUTPUT_A, ev3dev.OUTPUT_B, ev3dev.OUTPUT_C, ev3dev.OUTPUT_D): 460 | lp = ev3dev.LegoPort(port) 461 | lp.mode = 'auto' 462 | 463 | def regulatedDrive(self, left_port, right_port, reverse, direction, speed_pct): 464 | # direction: forward, backward 465 | # reverse: always false for now 466 | speed_pct = clamp(speed_pct, -100, 100) 467 | ml = self.cfg['actors'][left_port] 468 | mr = self.cfg['actors'][right_port] 469 | if direction == 'backward': 470 | speed_pct = -speed_pct 471 | ml.run_forever(speed_sp=self.scaleSpeed(ml, speed_pct)) 472 | mr.run_forever(speed_sp=self.scaleSpeed(mr, speed_pct)) 473 | 474 | def driveDistance(self, left_port, right_port, reverse, direction, speed_pct, distance): 475 | # direction: forward, backward 476 | # reverse: always false for now 477 | speed_pct = clamp(speed_pct, -100, 100) 478 | ml = self.cfg['actors'][left_port] 479 | mr = self.cfg['actors'][right_port] 480 | circ = math.pi * self.cfg['wheel-diameter'] 481 | dc = distance / circ 482 | if direction == 'backward': 483 | dc = -dc 484 | # set all attributes 485 | ml.stop_action = 'brake' 486 | ml.position_sp = int(dc * ml.count_per_rot) 487 | ml.speed_sp = self.scaleSpeed(ml, speed_pct) 488 | mr.stop_action = 'brake' 489 | mr.position_sp = int(dc * mr.count_per_rot) 490 | mr.speed_sp = self.scaleSpeed(mr, speed_pct) 491 | # start motors 492 | ml.run_to_rel_pos() 493 | mr.run_to_rel_pos() 494 | # logger.debug("driving: %s, %s" % (ml.state, mr.state)) 495 | while ml.state or mr.state: 496 | self.busyWait() 497 | 498 | def rotateDirectionRegulated(self, left_port, right_port, reverse, direction, speed_pct): 499 | # direction: left, right 500 | # reverse: always false for now 501 | speed_pct = clamp(speed_pct, -100, 100) 502 | ml = self.cfg['actors'][left_port] 503 | mr = self.cfg['actors'][right_port] 504 | if direction == 'left': 505 | mr.run_forever(speed_sp=self.scaleSpeed(mr, speed_pct)) 506 | ml.run_forever(speed_sp=self.scaleSpeed(ml, -speed_pct)) 507 | else: 508 | ml.run_forever(speed_sp=self.scaleSpeed(ml, speed_pct)) 509 | mr.run_forever(speed_sp=self.scaleSpeed(mr, -speed_pct)) 510 | 511 | def rotateDirectionAngle(self, left_port, right_port, reverse, direction, speed_pct, angle): 512 | # direction: left, right 513 | # reverse: always false for now 514 | speed_pct = clamp(speed_pct, -100, 100) 515 | ml = self.cfg['actors'][left_port] 516 | mr = self.cfg['actors'][right_port] 517 | circ = math.pi * self.cfg['track-width'] 518 | distance = angle * circ / 360.0 519 | circ = math.pi * self.cfg['wheel-diameter'] 520 | dc = distance / circ 521 | logger.debug("doing %lf rotations" % dc) 522 | # set all attributes 523 | ml.stop_action = 'brake' 524 | ml.speed_sp = self.scaleSpeed(ml, speed_pct) 525 | mr.stop_action = 'brake' 526 | mr.speed_sp = self.scaleSpeed(mr, speed_pct) 527 | if direction == 'left': 528 | mr.position_sp = int(dc * mr.count_per_rot) 529 | ml.position_sp = int(-dc * ml.count_per_rot) 530 | else: 531 | ml.position_sp = int(dc * ml.count_per_rot) 532 | mr.position_sp = int(-dc * mr.count_per_rot) 533 | # start motors 534 | ml.run_to_rel_pos() 535 | mr.run_to_rel_pos() 536 | logger.debug("turning: %s, %s" % (ml.state, mr.state)) 537 | while ml.state or mr.state: 538 | self.busyWait() 539 | 540 | def driveInCurve(self, direction, left_port, left_speed_pct, right_port, right_speed_pct, distance=None): 541 | # direction: foreward, backward 542 | ml = self.cfg['actors'][left_port] 543 | mr = self.cfg['actors'][right_port] 544 | left_speed_pct = self.scaleSpeed(ml, clamp(left_speed_pct, -100, 100)) 545 | right_speed_pct = self.scaleSpeed(mr, clamp(right_speed_pct, -100, 100)) 546 | if distance: 547 | left_dc = right_dc = 0.0 548 | speed_pct = (left_speed_pct + right_speed_pct) / 2.0 549 | if speed_pct: 550 | circ = math.pi * self.cfg['wheel-diameter'] 551 | dc = distance / circ 552 | left_dc = dc * left_speed_pct / speed_pct 553 | right_dc = dc * right_speed_pct / speed_pct 554 | # set all attributes 555 | ml.stop_action = 'brake' 556 | ml.speed_sp = int(left_speed_pct) 557 | mr.stop_action = 'brake' 558 | mr.speed_sp = int(right_speed_pct) 559 | if direction == 'backward': 560 | ml.position_sp = int(-left_dc * ml.count_per_rot) 561 | mr.position_sp = int(-right_dc * mr.count_per_rot) 562 | else: 563 | ml.position_sp = int(left_dc * ml.count_per_rot) 564 | mr.position_sp = int(right_dc * mr.count_per_rot) 565 | # start motors 566 | ml.run_to_rel_pos() 567 | mr.run_to_rel_pos() 568 | while (ml.state and left_speed_pct) or (mr.state and right_speed_pct): 569 | self.busyWait() 570 | else: 571 | if direction == 'backward': 572 | ml.run_forever(speed_sp=int(-left_speed_pct)) 573 | mr.run_forever(speed_sp=int(-right_speed_pct)) 574 | else: 575 | ml.run_forever(speed_sp=int(left_speed_pct)) 576 | mr.run_forever(speed_sp=int(right_speed_pct)) 577 | 578 | # sensors 579 | def scaledValue(self, sensor): 580 | return sensor.value() / float(10.0 ** sensor.decimals) 581 | 582 | def scaledValues(self, sensor): 583 | scale = float(10.0 ** sensor.decimals) 584 | return tuple([sensor.value(i) / scale for i in range(sensor.num_values)]) 585 | 586 | # touch sensor 587 | def isPressed(self, port): 588 | return self.scaledValue(self.cfg['sensors'][port]) 589 | 590 | # ultrasonic sensor 591 | def getUltraSonicSensorDistance(self, port): 592 | s = self.cfg['sensors'][port] 593 | if s.mode != 'US-DIST-CM': 594 | s.mode = 'US-DIST-CM' 595 | return self.scaledValue(s) 596 | 597 | def getUltraSonicSensorPresence(self, port): 598 | s = self.cfg['sensors'][port] 599 | if s.mode != 'US-LISTEN': 600 | s.mode = 'US-LISTEN' 601 | return self.scaledValue(s) != 0.0 602 | 603 | # gyro 604 | # http://www.ev3dev.org/docs/sensors/lego-ev3-gyro-sensor/ 605 | def resetGyroSensor(self, port): 606 | # change mode to reset for GYRO-ANG and GYRO-G&A 607 | s = self.cfg['sensors'][port] 608 | s.mode = 'GYRO-RATE' 609 | s.mode = 'GYRO-ANG' 610 | 611 | def getGyroSensorValue(self, port, mode): 612 | s = self.cfg['sensors'][port] 613 | if s.mode != Hal.GYRO_MODES[mode]: 614 | s.mode = Hal.GYRO_MODES[mode] 615 | return self.scaledValue(s) 616 | 617 | # color 618 | # http://www.ev3dev.org/docs/sensors/lego-ev3-color-sensor/ 619 | def getColorSensorAmbient(self, port): 620 | s = self.cfg['sensors'][port] 621 | if s.mode != 'COL-AMBIENT': 622 | s.mode = 'COL-AMBIENT' 623 | return self.scaledValue(s) 624 | 625 | def getColorSensorColour(self, port): 626 | colors = ['none', 'black', 'blue', 'green', 'yellow', 'red', 'white', 'brown'] 627 | s = self.cfg['sensors'][port] 628 | if s.mode != 'COL-COLOR': 629 | s.mode = 'COL-COLOR' 630 | return colors[int(self.scaledValue(s))] 631 | 632 | def getColorSensorRed(self, port): 633 | s = self.cfg['sensors'][port] 634 | if s.mode != 'COL-REFLECT': 635 | s.mode = 'COL-REFLECT' 636 | return self.scaledValue(s) 637 | 638 | def getColorSensorRgb(self, port): 639 | s = self.cfg['sensors'][port] 640 | if s.mode != 'RGB-RAW': 641 | s.mode = 'RGB-RAW' 642 | return self.scaledValues(s) 643 | 644 | # infrared 645 | # http://www.ev3dev.org/docs/sensors/lego-ev3-infrared-sensor/ 646 | def getInfraredSensorSeek(self, port): 647 | s = self.cfg['sensors'][port] 648 | if s.mode != 'IR-SEEK': 649 | s.mode = 'IR-SEEK' 650 | return self.scaledValues(s) 651 | 652 | def getInfraredSensorDistance(self, port): 653 | s = self.cfg['sensors'][port] 654 | if s.mode != 'IR-PROX': 655 | s.mode = 'IR-PROX' 656 | return self.scaledValue(s) 657 | 658 | # timer 659 | def getTimerValue(self, timer): 660 | if timer in self.timers: 661 | return int((time.time() - self.timers[timer]) * 1000.0) 662 | else: 663 | self.timers[timer] = time.time() 664 | return 0 665 | 666 | def resetTimer(self, timer): 667 | self.timers[timer] = time.time() 668 | 669 | # tacho-motor position 670 | def resetMotorTacho(self, actorPort): 671 | m = self.cfg['actors'][actorPort] 672 | m.position = 0 673 | 674 | def getMotorTachoValue(self, actorPort, mode): 675 | m = self.cfg['actors'][actorPort] 676 | tacho_count = m.position 677 | 678 | if mode == 'degree': 679 | return tacho_count * 360.0 / float(m.count_per_rot) 680 | elif mode in ['rotation', 'distance']: 681 | rotations = float(tacho_count) / float(m.count_per_rot) 682 | if mode == 'rotation': 683 | return rotations 684 | else: 685 | distance = round(math.pi * self.cfg['wheel-diameter'] * rotations) 686 | return distance 687 | else: 688 | raise ValueError('incorrect MotorTachoMode: %s' % mode) 689 | 690 | def getSoundLevel(self, port): 691 | # 100 for silent, 692 | # 0 for loud 693 | s = self.cfg['sensors'][port] 694 | if s.mode != 'DB': 695 | s.mode = 'DB' 696 | return round(-self.scaledValue(s) + 100, 2) # map to 0 silent 100 loud 697 | 698 | def getHiTecCompassSensorValue(self, port, mode): 699 | s = self.cfg['sensors'][port] 700 | if s.mode != 'COMPASS': 701 | s.mode = 'COMPASS' # ev3dev currently only supports the compass mode 702 | value = self.scaledValue(s) 703 | if mode == 'angle': 704 | return -(((value + 180) % 360) - 180) # simulate the angle [-180, 180] mode from ev3lejos 705 | else: 706 | return value 707 | 708 | def getHiTecIRSeekerSensorValue(self, port, mode): 709 | s = self.cfg['sensors'][port] 710 | if s.mode != mode: 711 | s.mode = mode 712 | value = self.scaledValue(s) 713 | # remap from [1 - 9] default 0 to [120, -120] default NaN like ev3lejos 714 | return float('nan') if value == 0 else (value - 5) * -30 715 | 716 | def getHiTecColorSensorV2Colour(self, port): 717 | s = self.cfg['sensors'][port] 718 | if s.mode != 'COLOR': 719 | s.mode = 'COLOR' 720 | value = s.value() 721 | return self.mapHiTecColorIdToColor(int(value)) 722 | 723 | def mapHiTecColorIdToColor(self, id): 724 | if id < 0 or id > 17: 725 | return 'none' 726 | colors = { 727 | 0: 'black', 728 | 1: 'red', 729 | 2: 'blue', 730 | 3: 'blue', 731 | 4: 'green', 732 | 5: 'yellow', 733 | 6: 'yellow', 734 | 7: 'red', 735 | 8: 'red', 736 | 9: 'red', 737 | 10: 'red', 738 | 11: 'white', 739 | 12: 'white', 740 | 13: 'white', 741 | 14: 'white', 742 | 15: 'white', 743 | 16: 'white', 744 | 17: 'white', 745 | } 746 | return colors[id] 747 | 748 | def getHiTecColorSensorV2Ambient(self, port): 749 | s = self.cfg['sensors'][port] 750 | if s.mode != 'PASSIVE': 751 | s.mode = 'PASSIVE' 752 | value = abs(s.value(0)) / 380 753 | return min(value, 100) 754 | 755 | def getHiTecColorSensorV2Light(self, port): 756 | s = self.cfg['sensors'][port] 757 | if s.mode != 'NORM': 758 | s.mode = 'NORM' 759 | value = self.scaledValues(s)[3] / 2.55 760 | return value 761 | 762 | def getHiTecColorSensorV2Rgb(self, port): 763 | s = self.cfg['sensors'][port] 764 | if s.mode != 'NORM': 765 | s.mode = 'NORM' 766 | value = self.scaledValues(s) 767 | value = list(value) 768 | del value[0] 769 | return value 770 | 771 | def setHiTecColorSensorV2PowerMainsFrequency(self, port, frequency): 772 | s = self.cfg['sensors'][port] 773 | s.command = frequency 774 | 775 | # communication 776 | def _isTimeOut(self, e): 777 | # BluetoothError seems to be an IOError, which is an OSError 778 | # but all they do is: raise BluetoothError (str (e)) 779 | return str(e) == "timed out" 780 | 781 | def establishConnectionTo(self, host): 782 | # host can also be a name, resolving it is slow though and requires the 783 | # device to be visible 784 | if not bluetooth.is_valid_address(host): 785 | nearby_devices = bluetooth.discover_devices() 786 | for bdaddr in nearby_devices: 787 | if host == bluetooth.lookup_name(bdaddr): 788 | host = bdaddr 789 | break 790 | if bluetooth.is_valid_address(host): 791 | con = BluetoothSocket(bluetooth.RFCOMM) 792 | con.settimeout(0.5) # half second to make IO interruptible 793 | while True: 794 | try: 795 | con.connect((host, 1)) # 0 is channel 796 | self.bt_connections.append(con) 797 | return len(self.bt_connections) - 1 798 | except bluetooth.btcommon.BluetoothError as e: 799 | if not self._isTimeOut(e): 800 | logger.error("unhandled Bluetooth error: %s", repr(e)) 801 | break 802 | else: 803 | return -1 804 | 805 | def waitForConnection(self): 806 | # enable visibility 807 | if not self.sys_bus: 808 | self.sys_bus = dbus.SystemBus() 809 | # do only once (since we turn off the timeout) 810 | # alternatively set DiscoverableTimeout = 0 in /etc/bluetooth/main.conf 811 | # and run hciconfig hci0 piscan, from robertalab initscript 812 | hci0 = self.sys_bus.get_object('org.bluez', '/org/bluez/hci0') 813 | props = dbus.Interface(hci0, 'org.freedesktop.DBus.Properties') 814 | props.Set('org.bluez.Adapter1', 'DiscoverableTimeout', dbus.UInt32(0)) 815 | props.Set('org.bluez.Adapter1', 'Discoverable', True) 816 | 817 | if not self.bt_server: 818 | self.bt_server = BluetoothSocket(bluetooth.RFCOMM) 819 | self.bt_server.settimeout(0.5) # half second to make IO interruptible 820 | self.bt_server.bind(("", bluetooth.PORT_ANY)) 821 | self.bt_server.listen(1) 822 | 823 | while True: 824 | try: 825 | (con, info) = self.bt_server.accept() 826 | con.settimeout(0.5) # half second to make IO interruptible 827 | self.bt_connections.append(con) 828 | return len(self.bt_connections) - 1 829 | except bluetooth.btcommon.BluetoothError as e: 830 | if not self._isTimeOut(e): 831 | logger.error("unhandled Bluetooth error: %s", repr(e)) 832 | break 833 | return -1 834 | 835 | def readMessage(self, con_ix): 836 | message = "NO MESSAGE" 837 | if con_ix < len(self.bt_connections) and self.bt_connections[con_ix]: 838 | con = self.bt_connections[con_ix] 839 | logger.debug('reading msg') 840 | while True: 841 | # TODO(ensonic): how much do we actually expect 842 | # here is the lejos counter part 843 | # https://github.com/OpenRoberta/robertalab-ev3lejos/blob/master/ 844 | # EV3Runtime/src/main/java/de/fhg/iais/roberta/runtime/ev3/BluetoothComImpl.java#L40..L59 845 | try: 846 | message = con.recv(128).decode('utf-8', errors='replace') 847 | logger.debug('received msg [%s]' % message) 848 | break 849 | except bluetooth.btcommon.BluetoothError as e: 850 | if not self._isTimeOut(e): 851 | logger.error("unhandled Bluetooth error: %s", repr(e)) 852 | self.bt_connections[con_ix] = None 853 | break 854 | return message 855 | 856 | def sendMessage(self, con_ix, message): 857 | if con_ix < len(self.bt_connections) and self.bt_connections[con_ix]: 858 | logger.debug('sending msg [%s]' % message) 859 | con = self.bt_connections[con_ix] 860 | while True: 861 | try: 862 | con.send(message) 863 | logger.debug('sent msg') 864 | break 865 | except bluetooth.btcommon.BluetoothError as e: 866 | if not self._isTimeOut(e): 867 | logger.error("unhandled Bluetooth error: %s", repr(e)) 868 | self.bt_connections[con_ix] = None 869 | break 870 | --------------------------------------------------------------------------------