diff --git a/README.md b/README.md index f22523a..0f658c6 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,15 @@ only on Windows and MacOS, and cannot connect Scratch and micro:bit on Linux. Bluepy-scratch-link allows Linux PCs to connect Scratch and micro:bit. It uses Linux Bluetooth protocol stack [Bluez](http://www.bluez.org/) and its python -interface [bluepy](https://github.com/IanHarvey/bluepy) to handle Bluetooth Low +interfaces [pybluez](https://github.com/pybluez/pybluez) to handle Bluetooth, +and [bluepy](https://github.com/IanHarvey/bluepy) to handle Bluetooth Low Energy, or BLE, connections with micro:bit. It is confirmed that -bluepy-scratch-link connects Scratch 3.0 and a micro:bit. +bluepy-scratch-link connects Scratch 3.0 and a micro:bit, and a Lego Mindstorms +EV3. -This is a minimal implementation to support micro:bit. Some of Scratch-link -features are not implemented. For example, Bluetooth (non-BLE) devices are not -supported. BLE device support other than micro:bit is not confirmed. +This is a minimal implementation to support micro:bit and Lego Mindstorms EV3. +It may work with other devices but these are untested. Some Scratch-link +features are not implemented. Bluepy-scratch-link requires python version 3.6 and later to use websockets. If your system has python older than version 3.6, install newer version. If your @@ -27,7 +29,7 @@ feed-backs will be appreciated. Installation ------------ -1. Prepare BLE controller +1. Prepare Bluetooth/BLE controller Confirm that your Linux PC has a Bluetooth controller with BLE support. Bluetooth 4.0 controller supports BLE. If your PC does not have it, need to plug USB Bluetooth 4.0 adapter. @@ -44,14 +46,14 @@ Installation ```sh $ sudo pip install bluepy websockets Or if your system has python3 command, - $ sudo pip3 install bluepy websockets + $ sudo pip3 install bluepy pybluez websockets ``` 4. Get bluepy-scratch-link Example below installs bluepy-scratch-link under your home directory. ```sh $ cd ~ - $ git clone git@github.com:kawasaki/bluepy-scratch-link.git + $ git clone git@github.com:chrisglencross/bluepy-scratch-link.git ``` 5. Prepare web server certificate @@ -71,7 +73,7 @@ Installation | tr -d '\r' > scratch-device-manager.pem ``` -6. Install Scratch-link hex in micro:bit +6. If using a micro:bit, install Scratch-link hex on your device * Download and unzip the [micro:bit Scratch Hex file](https://downloads.scratch.mit.edu/microbit/scratch-microbit-1.1.0.hex.zip). * Flash the micro:bit over USB with the Scratch .Hex File, you will see the five character name of the micro:bit scroll across the screen such as @@ -79,13 +81,56 @@ Installation Usage ----- -1. Turn on Bluetooth Low Energy controller +1. For micro:bit or other BLE devices, turn on Bluetooth Low Energy controller ```sh $ sudo btmgmt le on $ sudo btmgmt power on ``` + +2. For Lego Mindstorms EV3, pair your Linux PC to the EV3 brick. -2. Start scratch-link python script + First, turn on the EV3 and ensure Bluetooth is enabled. + + Then, pair using your Linux desktop's the Bluetooth settings, for example with Gnome: + * Settings -> Bluetooth + * Click on the EV3 device name + * Accept the connection on EV3 brick + * Enter a matching PIN on EV3 brick and Linux PC + * Confirm EV3 status is "Disconnected" in Bluetooth settings + + Alternatively you can perform pairing from the command-line: + ```shell script + $ bluetoothctl + + [bluetooth]# power on + Changing power on succeeded + + [bluetooth]# pairable on + Changing pairable on succeeded + + [bluetooth]# agent KeyboardOnly + Agent registered + + [bluetooth]# devices + ... + Device 00:16:53:53:D3:19 EV3 + ... + + [bluetooth]# pair 00:16:53:53:D3:19 + Attempting to pair with 00:16:53:53:D3:19 + + # Confirm pairing on the EV3 display, set PIN to 1234 + + Request PIN code + [agent] Enter PIN code: 1234 + [CHG] Device 00:16:53:53:D3:19 Connected: yes + [CHG] Device 00:16:53:53:D3:19 Paired: yes + Pairing successful + + [bluetooth]# quit + ``` + +3. Start scratch-link python script ```sh $ cd ~/bluepy-scratch-link $ sudo ./scratch_link.py @@ -93,7 +138,7 @@ Usage $ sudo python3 ./scratch_link.py ``` -3. Start Firefox or Chrome and allow local server certificate +4. Start Firefox or Chrome and allow local server certificate * This action is required only the first time to access. * Open Firefox or Chrome and open [https://device-manager.scratch.mit.edu:20110/](https://device-manager.scratch.mit.edu:20110/). You will see a security risk warning. * In **Firefox**: Click "Advanced" and click "Accept Risk and Continue". @@ -101,8 +146,8 @@ Usage * Immediately, you will see "Failed to open a WebSocket connection". This is expected. -4. Connect scratch to micro:bit +5. Connect scratch to micro:bit or Lego Mindstorms: * Open [Scratch 3.0](https://scratch.mit.edu/) * Select the "Add Extension" button - * Select micro:bit extension and follow the prompts to connect micro:bit + * Select micro:bit or Lego Mindstorms EV3 extension and follow the prompts to connect * Build your project with the extension blocks diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..53b6caf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +websockets +bluepy +pybluez diff --git a/scratch_link.py b/scratch_link.py index 67a45cd..415b105 100755 --- a/scratch_link.py +++ b/scratch_link.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +import select +import struct """Scratch link on bluepy""" @@ -9,9 +11,13 @@ import websockets import json import base64 -# for BLESession +# for Bluetooth (e.g. Lego EV3) +import bluetooth + +# for BLESession (e.g. BBC micro:bit) from bluepy.btle import Scanner, UUID, Peripheral, DefaultDelegate from bluepy.btle import BTLEDisconnectError + import threading import time @@ -92,16 +98,199 @@ class Session(): logger.debug("in handle loop") class BTSession(Session): - """Manage a session for Bluetooh device""" - def __init__(self, websocket): - super().__init__(websocekt) + """Manage a session for Bluetooth device""" + + INITIAL = 1 + DISCOVERY = 2 + DISCOVERY_COMPLETE = 3 + CONNECTED = 4 + DONE = 5 + + # Split this into discovery thread and communication thread + # discovery thread should auto-terminate + + class BTThread(threading.Thread): + """ + Separated thread to control notifications to Scratch. + It handles device discovery notification in DISCOVERY status + and notifications from bluetooth devices in CONNECTED status. + """ + + class BTDiscoverer(bluetooth.DeviceDiscoverer): + + def __init__(self, major_class, minor_class): + super().__init__() + self.major_class = major_class + self.minor_class = minor_class + self.found_devices = {} + self.done = False + + def pre_inquiry(self): + self.done = False + + def device_discovered(self, address, device_class, rssi, name): + logger.debug(f"Found device {name} addr={address} class={device_class} rssi={rssi}") + major_class = (device_class & 0x1F00) >> 8 + minor_class = (device_class & 0xFF) >> 2 + if major_class == self.major_class and minor_class == self.minor_class: + self.found_devices[address] = (name, device_class, rssi) + + def inquiry_complete(self): + self.done = True + + def __init__(self, session, major_device_class, minor_device_class): + threading.Thread.__init__(self) + self.session = session + self.major_device_class = major_device_class + self.minor_device_class = minor_device_class + self.cancel_discovery = False + self.ping_time = None + + def discover(self): + discoverer = self.BTDiscoverer(self.major_device_class, self.minor_device_class) + discoverer.find_devices(lookup_names=True) + while self.session.status == self.session.DISCOVERY and not discoverer.done and not self.cancel_discovery: + readable = select.select([discoverer], [], [], 0.5)[0] + if discoverer in readable: + discoverer.process_event() + for addr, (device_name, device_class, rssi) in discoverer.found_devices.items(): + logger.debug(f"notifying discovered {addr}: {device_name}") + params = {"rssi": rssi, 'peripheralId': addr, 'name': device_name.decode("utf-8")} + self.session.notify('didDiscoverPeripheral', params) + discoverer.found_devices.clear() + + if not discoverer.done: + discoverer.cancel_inquiry() + + def run(self): + while self.session.status != self.session.DONE: + + logger.debug("loop in BT thread") + current_time = int(round(time.time())) + + if self.session.status == self.session.DISCOVERY and not self.cancel_discovery: + logger.debug("in discovery status:") + try: + self.discover() + self.ping_time = current_time + 5 + finally: + self.session.status = self.session.DISCOVERY_COMPLETE + + elif self.session.status == self.session.CONNECTED: + logger.debug("in connected status:") + sock = self.session.sock + try: + ready = select.select([sock], [], [], 1) + if ready[0]: + header = sock.recv(2) + [msg_len] = struct.unpack(" 0: + logger.debug(params) + + res = { "jsonrpc": "2.0" } + + if self.status == self.INITIAL and method == 'discover': + logger.debug("Starting async discovery") + self.status = self.DISCOVERY + self.bt_thread = self.BTThread(self, params["majorDeviceClass"], params["minorDeviceClass"]) + self.bt_thread.start() + res["result"] = None + + elif self.status in [self.DISCOVERY, self.DISCOVERY_COMPLETE] and method == 'connect': + + # Cancel discovery + while self.status == self.DISCOVERY: + logger.debug("Cancelling discovery") + self.bt_thread.cancel_discovery = True + time.sleep(1) + + addr = params['peripheralId'] + logger.debug(f"connecting to the BT device {addr}") + try: + self.sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM) + self.sock.connect((addr, 1)) + logger.info(f"connected to BT device: {addr}") + except bluetooth.BluetoothError as e: + logger.error(f"failed to connect to BT device: {e}", exc_info=e) + self.status = self.DONE + self.sock = None + + if self.sock: + res["result"] = None + self.status = self.CONNECTED + else: + err_msg = f"BT connect failed: {addr}" + res["error"] = { "message": err_msg } + self.status = self.DONE + + elif self.status == self.CONNECTED and method == 'send': + logger.debug("handle send request") + if params['encoding'] != 'base64': + logger.error("encoding other than base 64 is not " + "yet supported: ", params['encoding']) + msg_bstr = params['message'].encode('ascii') + data = base64.standard_b64decode(msg_bstr) + self.sock.send(data) + res['result'] = len(data) + + logger.debug(res) + return res + + def end_request(self): + logger.debug(f"end_request of BTSession {self}") + return self.status == self.DONE - def handle(self): - logger.error("BT session handler is not implemented") class BLESession(Session): """ - Manage a session for Bluetooh Low Energy device such as micro:bit + Manage a session for Bluetooth Low Energy device such as micro:bit """ INITIAL = 1 @@ -346,13 +535,13 @@ sessionTypes = { '/scratch/ble': BLESession, '/scratch/bt': BTSession } async def ws_handler(websocket, path): try: - logger.info(f"Start session for web socket path: {path}"); + logger.info(f"Start session for web socket path: {path}") loop = asyncio.get_event_loop() session = sessionTypes[path](websocket, loop) await session.handle() except Exception as e: - logger.error(f"Failure in session for web socket path: {path}"); - logger.error(e); + logger.error(f"Failure in session for web socket path: {path}") + logger.error(e) start_server = websockets.serve( ws_handler, "device-manager.scratch.mit.edu", 20110, ssl=ssl_context