Files
pybricks-projects/snippets/ev3/bluetooth_pc/pybricks/messaging.py
T
Laurens Valk d5ee42156d ev3/snippets/bluetooth_pc: StreamRequestHandler
This restores the previously deleted custom StreamRequestHandler.

In the prevous commits, more and more pieces from the standard socket library were used instead of the custom-made ones made for EV3. This was giving compatibility issues on some platforms (Windows/Mac), and differences with EV3 made it harder to debug.

It was previously dropped to make the MailboxHandler work. This commit reverts that change and modifies the MailboxHandler instead to make it work with the BluetoothSocket objects, which do not have rfile.read. Instead we use recv directly and rely on zero length to detect disconnected state.

It would be useful to eventually use only components from the socket server module and no incomplete custom ones, but this provides at least a working cross platform starting point to fall back to.
2020-05-16 20:13:40 +02:00

341 lines
10 KiB
Python

# SPDX-License-Identifier: MIT
# Copyright (C) 2020 The Pybricks Authors
from _thread import allocate_lock
from errno import ECONNRESET
from struct import pack, unpack
from .bluetooth import (BDADDR_ANY, ThreadingRFCOMMServer,
ThreadingRFCOMMClient, StreamRequestHandler)
def resolve(brick):
"""Fake resolver to get address from Bluetooth name.
To connect to an EV3 server, you must specify its full address.
"""
return brick
class Mailbox:
def __init__(self, name, connection, encode=None, decode=None):
"""Object that represents a mailbox for sending an receiving messages
from other connected devices.
Arguments:
name (str):
The name of this mailbox.
connection:
A connection object that implements the mailbox connection
interface.
encode:
A function that encodes an object into a bytes-like object.
decode:
A function that decodes an object from a bytes-like object.
"""
self.name = name
self._connection = connection
if encode:
self.encode = encode
if decode:
self.decode = decode
def encode(self, value):
return value
def decode(self, payload):
return payload
def read(self):
"""Reads the current value of the mailbox.
Returns:
The decoded value or ``None`` if the mailbox has never received
a value.
"""
data = self._connection.read_from_mailbox(self.name)
if data is None:
return None
return self.decode(data)
def send(self, value, destination=None):
"""Sends a value to remote mailboxes with the same name as this
mailbox.
Arguments:
value: The value to send.
destination: The name or address of a specific device or ``None``
to broadcast to all connected devices.
"""
data = self.encode(value)
self._connection.send_to_mailbox(destination, self.name, data)
def wait(self):
"""Waits for the mailbox to receive a message."""
self._connection.wait_for_mailbox_update(self.name)
def wait_new(self):
"""Waits for the mailbox to receive a message that is different from
the current contents of the mailbox.
Returns:
The new value. (Same as return value of :meth:`read`.)
"""
old = self.read()
while True:
self.wait()
new = self.read()
if new != old:
return new
class LogicMailbox(Mailbox):
""":class:`Mailbox` that holds a logic or boolean value.
This is compatible with the "logic" message blocks in the standard
EV3 firmware.
"""
def encode(self, value):
return b'\x01' if value else b'\x00'
def decode(self, payload):
return bool(payload[0])
class NumericMailbox(Mailbox):
""":class:`Mailbox` that holds a numeric or floating point value.
This is compatible with the "numeric" message blocks in the standard
EV3 firmware.
"""
def encode(self, value):
return pack('<f', value)
def decode(self, payload):
return unpack('<f', payload)[0]
class TextMailbox(Mailbox):
""":class:`Text` that holds a text or string point value.
This is compatible with the "text" message blocks in the standard
EV3 firmware.
"""
def encode(self, value):
return ('{}\0'.format(value)).encode('utf-8')
def decode(self, payload):
return payload.decode().strip('\0')
# EV3 standard firmware is hard-coded to use channel 1
EV3_RFCOMM_CHANNEL = 1
# EV3 VM bytecodes
SYSTEM_COMMAND_NO_REPLY = 0x81
WRITEMAILBOX = 0x9E
class MailboxHandler(StreamRequestHandler):
def handle(self):
with self.server._lock:
self.server._clients[self.client_address[0]] = self.request
while True:
try:
buf = self.rfile.recv(2)
if len(buf) == 0:
break
except OSError as ex:
# The client disconnected the connection
if ex.args[0] == ECONNRESET:
break
raise
size, = unpack('<H', buf)
buf = self.rfile.recv(size)
msg_count, cmd_type, cmd, name_size = unpack('<HBBB', buf[0:5])
if cmd_type != SYSTEM_COMMAND_NO_REPLY:
raise ValueError('Bad message type')
if cmd != WRITEMAILBOX:
raise ValueError('Bad command')
mbox = buf[5:5+name_size].decode().strip('\0')
data_size, = unpack('<H', buf[5+name_size:7+name_size])
data = buf[7+name_size:7+name_size+data_size]
with self.server._lock:
self.server._mailboxes[mbox] = data
update_lock = self.server._updates.get(mbox)
if update_lock:
update_lock.release()
class MailboxHandlerMixIn:
def __init__(self):
# protects against concurrent access of other attributes
self._lock = allocate_lock()
# map of mailbox name to raw data
self._mailboxes = {}
# map of device name/address to object with send() method
self._clients = {}
# map of mailbox name to mutex lock
self._updates = {}
# map of names to addresses
self._addresses = {}
def read_from_mailbox(self, mbox):
"""Reads 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.
"""
with self._lock:
return self._mailboxes.get(mbox)
def send_to_mailbox(self, brick, mbox, payload):
"""Sends a mailbox value using raw bytes data.
Arguments:
brick (str):
The name or address of the brick or ``None``` 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.
"""
mbox_len = len(mbox) + 1
payload_len = len(payload)
send_len = 7 + mbox_len + payload_len
fmt = '<HHBBB{}sH{}s'.format(mbox_len, payload_len)
data = pack(fmt, send_len, 1, SYSTEM_COMMAND_NO_REPLY, WRITEMAILBOX,
mbox_len, mbox.encode('utf-8'), payload_len, payload)
with self._lock:
if brick is None:
for client in self._clients.values():
client.send(data)
else:
addr = self._addresses.get(brick)
if addr is None:
addr = resolve(brick)
self._addresses[brick] = addr
if addr is None:
raise ValueError('no paired devices matching "{}"'.format(brick))
self._clients[addr].send(data)
def wait_for_mailbox_update(self, mbox):
"""Waits until ``mbox`` receives a value."""
lock = allocate_lock()
lock.acquire()
with self._lock:
self._updates[mbox] = lock
try:
return lock.acquire()
finally:
with self._lock:
del self._updates[mbox]
class BluetoothMailboxServer(MailboxHandlerMixIn, ThreadingRFCOMMServer):
def __init__(self):
"""Object that represents an incoming Bluetooth connection from another
EV3.
The remote EV3 can either be running MicroPython or the standard EV3
firmare.
"""
super().__init__()
super(ThreadingRFCOMMServer, self).__init__(
(BDADDR_ANY, EV3_RFCOMM_CHANNEL), MailboxHandler)
def wait_for_connection(self, count=1):
"""Waits for a :class:`BluetoothMailboxClient` 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.
"""
for _ in range(count):
self.handle_request()
class MailboxRFCOMMClient(ThreadingRFCOMMClient):
def __init__(self, parent, bdaddr):
self.parent = parent
super().__init__((bdaddr, EV3_RFCOMM_CHANNEL), MailboxHandler)
def send(self, data):
self.socket.send(data)
def shutdown_request(self, request):
request.close()
def finish_request(self, request, client_address):
self.RequestHandlerClass(request, client_address, self.parent)
class BluetoothMailboxClient(MailboxHandlerMixIn):
"""Object that represents outgoing Bluetooth connections to one or more
remote EV3s.
The remote EV3s can either be running MicroPython or the standard EV3
firmare.
"""
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.close()
def connect(self, brick):
"""Connects to a :class:`BluetoothMailboxServer` on another device.
The remote device must be paired and waiting for a connection. See
:meth:`BluetoothMailboxServer.wait_for_connection`.
Arguments:
brick (str):
The name or address of the remote EV3 to connect to.
Raises:
TypeError:
``brick`` is not a string
ValueError:
There are no paired Bluetooth devices that match ``brick``
or connection to ``brick`` already exists.
OSError:
There was a problem establishing the connection.
"""
addr = resolve(brick)
if addr is None:
raise ValueError('no paired devices matching "{}"'.format(brick))
client = MailboxRFCOMMClient(self, addr)
if self._clients.setdefault(addr, client) is not client:
raise ValueError('connection with this address already exists')
try:
client.handle_request()
except Exception:
del self._clients[addr]
raise
def close(self):
"""Closes the connections."""
for client in self._clients.values():
client.client_close()
self._clients.clear()