From 7acbcbd874419d3110bc6ae7240feddccde0f4d9 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 10 Feb 2020 18:22:14 -0600 Subject: [PATCH] initial EV3 bluetooth mailbox docs --- .travis.yml | 2 +- doc/api/bluetooth.rst | 27 ++++ doc/api/ev3_mailboxes.rst | 106 +++++++++++++ doc/api/index.rst | 2 + doc/common/conf.py | 28 ++++ pybricks/bluetooth.py | 322 ++++++++++++++++++++++++++++++++++++++ setup.cfg | 2 +- 7 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 doc/api/bluetooth.rst create mode 100644 doc/api/ev3_mailboxes.rst create mode 100644 pybricks/bluetooth.py diff --git a/.travis.yml b/.travis.yml index 2566c4a..358fb05 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,6 @@ install: script: - pipenv run python setup.py build -- make -C doc html SOURCEDIR=api +- pipenv run make -C doc html SOURCEDIR=api - pipenv run flake8 - pipenv run doc8 diff --git a/doc/api/bluetooth.rst b/doc/api/bluetooth.rst new file mode 100644 index 0000000..b2c2bce --- /dev/null +++ b/doc/api/bluetooth.rst @@ -0,0 +1,27 @@ +:mod:`bluetooth ` -- Bluetooth +================================================== + +.. automodule:: pybricks.bluetooth + :no-members: + +.. currentmodule:: pybricks.bluetooth + + +EV3 Mailboxes +------------- + +Pybricks MicroPython provides a mailbox implementation that is compatible with +the standard LEGO firmware. This can be used to communicate between multiple +EV3 bricks running Pybrick MicroPython, the standard LEGO firmware or a +combination of the two. + +.. note:: See :doc:`ev3_mailboxes` for a more general overview. + +.. autoclass:: EV3MailboxClient + +.. autoclass:: EV3MailboxServer + +.. autoclass:: EV3MailboxMixIn + +.. autodata:: ALL_BRICKS + :annotation: diff --git a/doc/api/ev3_mailboxes.rst b/doc/api/ev3_mailboxes.rst new file mode 100644 index 0000000..58057cf --- /dev/null +++ b/doc/api/ev3_mailboxes.rst @@ -0,0 +1,106 @@ +EV3 Mailboxes +============= + +TODO: introduction... messages are sent immediately, received messages are held +in mailbox for getting later. + +.. rubric:: Pairing + +Before two EV3s can communicate with each other via Bluetooth, they must be +paired. + +TODO: screenshots of pairing with brickman, don't press the connect button! + + +.. rubric:: Client and server + +Programs can be written using either an EV3 mailbox *client* object or an EV3 +mailbox *server* object. + +The only difference between the *client* and the *server* is which one +initiates the connection at the beginning of the program. After that, sending +and receiving messages is bidirectional and works the same from either point of +view. + +The *server* waits for an incoming connection while the *client* initiates the +connection. Therefore, the server program must always be started first. If not, +both programs will wait forever for a connection. + + +Here is a basic example where two EV3s are connected and send greetings to +each other. + +.. rubric:: Client program + +.. literalinclude:: + ../../pybricks-projects/snippets/ev3/bluetooth_client/client.py + +.. rubric:: Server program + +.. literalinclude:: + ../../pybricks-projects/snippets/ev3/bluetooth_server/server.py + + +.. rubric:: EV3-G compatibility + +TODO: screenshots of EV3-G programs, difference between client and server +program is that client program has connect block. Server is always running. + +TODO: show equivalent blocks + +- :meth:`pybricks.bluetooth.EV3MailboxMixIn.get_logic` +- :meth:`pybricks.bluetooth.EV3MailboxMixIn.get_numeric` +- :meth:`pybricks.bluetooth.EV3MailboxMixIn.get_text` +- :meth:`pybricks.bluetooth.EV3MailboxMixIn.send_logic` +- :meth:`pybricks.bluetooth.EV3MailboxMixIn.send_numeric` +- :meth:`pybricks.bluetooth.EV3MailboxMixIn.send_text` + + +.. rubric:: Sending objects as text + +Simple Python objects, like dictionaries, can be encoded as text using the +builtin ``repr`` function and turned in to objects again using the builtin +``eval`` function. + +Example:: + + # Server program + my_obj = { 'a': 1, 'b': 2 } + server.send_text(ALL_BRICKS, 'my_obj', repr(my_obj)) + +:: + + # Client program + client.wait_for_update('my_obj') + my_obj = eval(client.get_text('my_obj')) + + +.. warning:: In general, ``eval`` is considered a security risk because it + can execute arbitrary code! Never use ``eval`` with untrusted data, like + like data received from the Internet. + + +.. rubric:: More than two bricks + +A single client EV3 can connect to multiple server EV3s or a single server EV3 +can accept connections from multiple clients. + +TODO: the actual implementation needs to be updated to match this example. + +Example:: + + client = EV3MailboxClient() + + # connect to 4 different servers + client.connect(SERVER1) + client.connect(SERVER2) + client.connect(SERVER3) + client.connect(SERVER4) + + +Example:: + + server = EV3MailboxServer() + + # wait for 4 clients to connect + server.wait_for_connection(4) diff --git a/doc/api/index.rst b/doc/api/index.rst index f380085..7180523 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -29,6 +29,7 @@ Intro tools robotics media + bluetooth .. toctree:: :maxdepth: 1 @@ -37,4 +38,5 @@ Intro signaltypes motors + ev3_mailboxes .. frames diff --git a/doc/common/conf.py b/doc/common/conf.py index f5c4f24..682aa8a 100644 --- a/doc/common/conf.py +++ b/doc/common/conf.py @@ -19,6 +19,10 @@ # import os import sys +from docutils import nodes +from docutils.parsers.rst.directives import flag +from docutils.parsers.rst import Directive +from sphinx.application import Sphinx from sphinx.domains.python import PyClassmember, PythonDomain sys.path.insert(0, os.path.abspath('../..')) @@ -116,6 +120,7 @@ nitpick_ignore = [ ('py:class', 'object'), ('py:class', 'str'), ('py:class', 'tuple'), + ('py:exc', 'OSError'), ('py:exc', 'RuntimeError'), ('py:exc', 'TypeError'), ('py:exc', 'ValueError'), @@ -268,6 +273,29 @@ texinfo_documents = [ ] +# -- .. availability:: directive + +class AvailabilityDirective(Directive): + has_content = True + option_spec = { + 'movehub': flag, + 'cityhub': flag, + 'cplushub': flag, + 'ev3dev-stretch': flag, + } + + def run(self): + if not self.options: + raise self.error('Must specify at least one platform.') + # TODO: make links to platform pages + return [nodes.emphasis(text='Availability: '), + nodes.Text(', '.join(self.options))] + + +def setup(app: Sphinx): + app.add_directive('availability', AvailabilityDirective) + + # -- Python domain hacks --------------------------------------------------- real_get_signature_prefix = PyClassmember.get_signature_prefix diff --git a/pybricks/bluetooth.py b/pybricks/bluetooth.py new file mode 100644 index 0000000..6312e24 --- /dev/null +++ b/pybricks/bluetooth.py @@ -0,0 +1,322 @@ +# SPDX-License-Identifier: MIT +# Copyright (C) 2020 David Lechner + +"""This module provides classes for working with Bluetooth Classic. + +.. availability:: + :ev3dev-stretch: + +.. versionadded:: 2.0.0 +""" + +from socketserver import BaseServer, ThreadingMixIn, StreamRequestHandler + +try: + from socket import BDADDR_ANY +except ImportError: + BDADDR_ANY = '00:00:00:00:00:00' + + +__all__ = ['BDADDR_ANY', 'RFCOMMServer', 'ThreadingRFCOMMServer', + 'StreamRequestHandler', 'ALL_BRICKS', 'EV3MailboxServer', + 'EV3MailboxClient'] + + +class RFCOMMServer(BaseServer): + """Object that simplifies setting up an RFCOMM socket server. + + This is based on the ``socketserver.SocketServer`` class in the Python + standard library. + + .. availability:: + :ev3dev-stretch: + + .. versionadded:: 2.0.0 + """ + + +class ThreadingRFCOMMServer(ThreadingMixIn, RFCOMMServer): + """Version of :class:`RFCOMMServer` that handles connections in a new + thread. + + .. availability:: + :ev3dev-stretch: + + .. versionadded:: version + + .. versionadded:: 2.0.0 + """ + + +ALL_BRICKS = None +"""Can be used in ``send`` methods to broadcast to call connected bricks. + +.. availability:: + :ev3dev-stretch: + +.. versionadded:: 2.0.0 +""" + +EV3_RFCOMM_CHANNEL = 1 + + +class EV3MailboxMixIn: + """Methods shared by both :class:`EV3MailboxServer` and + :class:`EV3MailboxClient` + + .. availability:: + :ev3dev-stretch: + + .. versionadded:: 2.0.0 + """ + def get_raw_data(self, mbox): + """Gets the current raw data from a mailbox. + + Arguments: + mbox (str): + The name of the mailbox. + + Returns: + bytes: + The current mailbox raw data or ``None`` if nothing has ever + been delivered to the mailbox. + """ + return b'' + + def get_packed_data(self, mbox, fmt): + """Gets the current packed data from a mailbox. + + Arguments: + mbox (str): + The name of the mailbox. + fmt (str): + ``ustruct.unpack()`` format string used to decode the binary + data. + + Returns: + tuple: + The result of ``ustruct.unpack()`` on the mailbox data. + + Raises: + TypeError: + ``fmt`` is not a string. + RuntimeError: + ``mbox`` is empty. + """ + return () + + def get_logic(self, mbox): + """Gets the current value of the mailbox as a boolean value. + + This is compatible with the "logic" mailbox type in EV3-G. + + Arguments: + mbox (str): + The name of the mailbox. + + Returns: + bool: + The current value or ``None`` if the mailbox is empty. + """ + return False + + def get_numeric(self, mbox): + """Gets the current value of the mailbox as a floating point value. + + This is compatible with the "numeric" mailbox type in EV3-G. + + Arguments: + mbox (str): + The name of the mailbox. + + Returns: + float: + The current value or ``None`` if the mailbox is empty. + """ + return 0.0 + + def get_text(self, mbox): + """Gets the current value of the mailbox as a string value. + + This is compatible with the "text" mailbox type in EV3-G. + + Arguments: + mbox (str): + The name of the mailbox. + + Returns: + str: + The current value or ``None`` if the mailbox is empty. + """ + return "" + + def send_raw_data(self, brick, mbox, payload): + """Sends a mailbox value using raw bytes data. + + .. todo:: Currently the Bluetooth address must be used instead of the + the brick name. + + Arguments: + brick (str): + The name or Bluetooth address of the brick or + :data:`ALL_BRICKS` to broadcast to all connected devices. + mbox (str): + The name of the mailbox. + payload (bytes): + A bytes-like object that will be sent to the mailbox. + """ + + def send_packed_data(self, brick, mbox, fmt, *args): + """Sends a mailbox value using packed values. + + .. todo:: Currently the Bluetooth address must be used instead of the + the brick name. + + Arguments: + brick (str): + The name or Bluetooth address of the brick or + :data:`ALL_BRICKS` to broadcast to all connected devices. + mbox (str): + The name of the mailbox. + fmt (str): + Format string compatible with ``ustruct.pack()`` + *: + Arguments for ``ustruct.pack()`` + """ + + def send_logic(self, brick, mbox, value): + """Sends a boolean mailbox value. + + This is compatible with the "logic" mailbox type in EV3-G. + + .. todo:: Currently the Bluetooth address must be used instead of the + the brick name. + + Arguments: + brick (str): + The name or Bluetooth address of the brick or + :data:`ALL_BRICKS` to broadcast to all connected devices. + mbox (str): + The name of the mailbox. + value (bool): + The value that will be delivered to the mailbox. + """ + + def send_numeric(self, brick, mbox, value): + """Sends a float mailbox value. + + This is compatible with the "numeric" mailbox type in EV3-G. + + .. todo:: Currently the Bluetooth address must be used instead of the + the brick name. + + Arguments: + brick (str): + The name or Bluetooth address of the brick or + :data:`ALL_BRICKS` to broadcast to all connected devices. + mbox (str): + The name of the mailbox. + value (bool): + The value that will be delivered to the mailbox. + """ + + def send_text(self, brick, mbox, value): + """Sends a string mailbox value. + + This is compatible with the "text" mailbox type in EV3-G. + + .. todo:: Currently the Bluetooth address must be used instead of the + the brick name. + + Arguments: + brick (str): + The name or Bluetooth address of the brick or + :data:`ALL_BRICKS` to broadcast to all connected devices. + mbox (str): + The name of the mailbox. + value (bool): + The value that will be delivered to the mailbox. + """ + + def wait_for_update(self, mbox): + """Waits until ``mbox`` receives a value. + + Arguments: + mbox (str): + The name of the mailbox. + """ + + +class EV3MailboxServer(EV3MailboxMixIn, ThreadingRFCOMMServer): + """Object that represents an incoming Bluetooth connection from another + EV3. + + The remote EV3 can either be running MicroPython or the standard EV3 + firmare. + + See :class:`EV3MailboxMixIn` for additional methods. + + .. availability:: + :ev3dev-stretch: + + .. versionadded:: 2.0.0 + """ + def __init__(self): + pass + + def wait_for_connection(self, count=1): + """Waits for a :class:`EV3MailboxClient` on a remote device to connect. + + Arguments: + count (int): + The number of remote connections to wait for. + + Raises: + OSError: + There was a problem establishing the connection. + """ + + +class EV3MailboxClient(EV3MailboxMixIn, ThreadingMixIn): + """Object that represents an outgoing Bluetooth connection to another + EV3. + + The remote EV3 can either be running MicroPython or the standard EV3 + firmare. + + See :class:`EV3MailboxMixIn` for additional methods. + + .. availability:: + :ev3dev-stretch: + + .. versionadded:: 2.0.0 + """ + def __init__(self, brick): + """ + .. todo:: Currently the Bluetooth address must be used instead of the + the brick name. + + Arguments: + brick (str): + The name or Bluetooth address of the remote EV3 to connect to. + """ + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.close() + + def connect(self): + """Connects to an :class:`EV3MailboxServer` on another device. + + The remote device must be paired and waiting for a connection. See + :meth:`EV3MailboxServer.wait_for_connection`. + + Raises: + OSError: + There was a problem establishing the connection. + """ + + def close(self): + """Closes the connection.""" diff --git a/setup.cfg b/setup.cfg index 2e1bc6b..ee9bd14 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,4 +16,4 @@ exclude = .venv/,versioneer.py max-line-length = 88 [doc8] -ignore-path = .venv/ +ignore-path = .venv/,doc/api/build/