mirror of
https://github.com/pybricks/pybricks-api.git
synced 2026-09-12 17:46:19 +00:00
352 lines
10 KiB
Python
352 lines
10 KiB
Python
# SPDX-License-Identifier: MIT
|
|
# Copyright (C) 2020,2023 The Pybricks Authors
|
|
|
|
from errno import ECONNRESET
|
|
from socket import BDADDR_ANY
|
|
from socketserver import StreamRequestHandler
|
|
from struct import pack, unpack
|
|
from threading import Lock
|
|
|
|
from .bluetooth import ThreadingRFCOMMClient, ThreadingRFCOMMServer
|
|
|
|
|
|
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 (f"{value}\0").encode()
|
|
|
|
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.read(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.read(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 = 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 = f"<HHBBB{mbox_len}sH{payload_len}s"
|
|
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(f'no paired devices matching "{brick}"')
|
|
self._clients[addr].send(data)
|
|
|
|
def wait_for_mailbox_update(self, mbox):
|
|
"""Waits until ``mbox`` receives a value."""
|
|
lock = 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
|
|
firmware.
|
|
"""
|
|
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
|
|
firmware.
|
|
"""
|
|
|
|
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(f'no paired devices matching "{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()
|