10 Commits

Author SHA1 Message Date
Shin'ichiro Kawasaki
9265086b12 Tag version 0.2.3
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-05-08 21:04:03 +09:00
Shin'ichiro Kawasaki
58a60c94db scratch_link.py: Avoid eternal loop by hostname resolve failure
When the address of device-manager.scratch.mit.edu can not be resolved,
scratch_link.py catches the exception for the resolve failure and
restarts itself. This results in eternal loop.

To avoid the eternal loop, catch the resolve failure, print error
message and break the loop. Also improve the error message for the other
exceptions caught in the loop.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-05-08 20:32:05 +09:00
Shin'ichiro Kawasaki
ea1109cee2 Tag version 0.2.2
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-05-03 19:42:50 +09:00
Shin'ichiro Kawasaki
f4af270fbc README.md: Improve description for multiple devices
Describe limitation for multiple device connections in "In Case You Fail
to Connect" section. Also, mention about toio support and improved a few
descriptions.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-05-03 19:33:06 +09:00
Shin'ichiro Kawasaki
1b92af0f0a BLESession: Support multiple connections
When one pyscrlink uses tries to connect multiple devices at the same
time, the user can connect to the first device without problem. However,
the device scan for the second device causes connection loss for the
first connected device. As discussed in its GitHub issue #357 [1], it is
the limitation of bluepy as of today. This limitation is critical for
pyscrlink to bridge toio[2] and its own scratch[3], because the toio
scratch allows to connect two toio devices to single scratch project.

[1] https://github.com/IanHarvey/bluepy/issues/357
[2] https://www.sony.com/en/SonyInfo/design/stories/toio/
[3] https://github.com/toio/toio-visual-programming/

To bridge multiple devices by pyscrlink, keep references to multiple
devices at the first device scan. Assuming that the user prepares all of
the target devices ready at the device scan, pyscrlink can find the all
devices and keep references to them. When the user requests scan for the
second device, pyscrlink does not invoke the device scan. Instead, it
returns the references to the device it keeps. With this approach, the
disconnection by device scan can be avoided.

In detail, add BLESession.found_devices to keep the found devices and
share across multiple BLESessions. Add BLESession.nr_connected to count
connected sessions. While connected sessions exist, do not scan and
refer the list to return the found device list. Also refactor out device
scan part to a private function _scan_devices().

Note that the user must prepare all devices ready before the first scan.
The devices prepared after the first can not be found since pyscrlink
does not invoke scan. User must disconnect all devices to have pyscrlink
scan devices again.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-05-03 19:33:06 +09:00
Shin'ichiro Kawasaki
c206f7a5c5 BLESession: Close session on Exception
When an exception is thrown during a session, still the session no
longer works, but still keeps connection. With this status, following
device scan can not find the device. This symptom was observed when
micro:bit with scratch-microbit.hex is connected to "micro:bit MORE" and
BTLEException is thrown. Another symptom observed is the Scratch web
page closed and websocket connection slows ConnectionClosedOK exception.

To avoid the device scan failure, close the session when an exception is
caught. Also improve error message for the exception.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-05-03 19:32:49 +09:00
Shin'ichiro Kawasaki
e34ec61f3b Tag version 0.2.1
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-04-11 14:14:33 +09:00
Shin'ichiro Kawasaki
e552bd21bd bluepy_helper_cap.py: Check tools out of PATH
It was informed that the required tools setcap and getcap are not placed
in /usr/bin in some user environment. They can not be executed since
they are out of PATH. To allow the script to execute the commands out of
PATH, have the script find the tools. Keep the found paths of each
command in the dictionary 'tools', and refer it to execute the commands.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-04-11 13:36:23 +09:00
Shin'ichiro Kawasaki
dc46869760 BLESession: Refine error message for capability check
The error message on bluepy-helper capability check failure was not
accurate. Refine it.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-02-07 20:27:38 +09:00
Peter Butkovic
461377f7ea Fixed missing glib.h error 2021-02-07 20:25:42 +09:00
4 changed files with 107 additions and 31 deletions

View File

@@ -13,7 +13,7 @@ and its python interfaces [pybluez](https://github.com/pybluez/pybluez) to
handle Bluetooth, and [bluepy](https://github.com/IanHarvey/bluepy) to handle
Bluetooth Low Energy (BLE) connections. It has been reported that pyscrlink
connects Scratch 3.0 with micro:bit, LEGO Mindstorms EV3, LEGO WeDo, LEGO
Boost and Intelino Smart Train.
Boost, Intelino Smart Train and toio.
To use websockets, pyscrlink requires python version 3.6 or later. If your
system has python older than version 3.6, install newer version. If your
@@ -53,6 +53,7 @@ Devices:
* LEGO WeDo by @zhaowe, @KingBBQ
* LEGO Boost by @laurentchar, @miguev, @jacquesdt
* Intelino Smart Train by @ErrorJan
* toio by @shimodash
Linux distros:
* Raspbian by @chrisglencross
@@ -73,7 +74,7 @@ Installation
```sh
Ubuntu
$ sudo apt install bluez libbluetooth-dev libnss3-tools libcap2-bin
$ sudo apt install bluez libbluetooth-dev libnss3-tools libcap2-bin libglib2.0-dev
Arch
$ sudo pacman -S bluez bluez-utils nss libcap
```
@@ -99,7 +100,7 @@ Installation
5. For 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
* 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
'zo9ev'.
@@ -159,7 +160,7 @@ Usage
$ scratch_link
```
3. Connect scratch to micro:bit or LEGO Mindstorms:
3. Connect scratch to the target device such as micro:bit or LEGO Mindstorms:
* Open FireFox or Chrome. (Make sure to run as the same user for scratch-link python script.)
* Access [Scratch 3.0](https://scratch.mit.edu/) and create your project.
* Select the "Add Extension" button.
@@ -187,6 +188,12 @@ In Case You Fail to Connect
3. If scratch_link.py says "failed to connect to BT device: [Errno 13] Permission denied",
make sure to pair the bluetooth device to your PC before connecting to Scratch.
4. To connect to multiple devices at the same time, make all the target devices
ready for scan at the first device scan. This is important for toio. The toio
allows a single project to connect to two toio devices.
* When the second device was prepared after the first device was connected, device scan can not find the second device.
* To scan and find the second device, disconnect connections for the first device beforehand.
Issus Reporting
---------------
@@ -195,6 +202,20 @@ Please file issues to [GitHub issue tracker](https://github.com/kawasaki/pyscrli
Releases
--------
Release 0.2.3
* Fixed eternal loop caused by hostname resolve failure
Release 0.2.2
* Supported multiple device connections for toio
* Improved session closure handling
Release 0.2.1
* Added libglib to required package list in README.md
* Improved setcap and getcap tool finding
Release 0.2.0
* Latency issue fix for BLE devices' write characteristics

View File

@@ -22,10 +22,26 @@ logger.propagate = False
# Check dependent tools
DEPENDENT_TOOLS = {
"setcap": "libcap2-bin (Ubuntu) or libcap (Arch)",
"getcap": "libcap2-bin (Ubuntu) or libcap (Arch)",
}
tools = {}
for cmd in DEPENDENT_TOOLS:
if not shutil.which(cmd):
# find the tools in PATH
path = shutil.which(cmd)
if path:
tools[cmd] = path
logger.debug(f"{cmd} found: {path}")
continue
# find the tools out of PATH but in major directories
for d in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]:
path = d + '/' + cmd
if os.path.isfile(path) and os.access(path, os.X_OK):
tools[cmd] = path
logger.debug(f"{cmd} found: {path}")
break
if not cmd in tools:
print(f"'{cmd}' not found. Install package {DEPENDENT_TOOLS[cmd]}.")
sys.exit(1)
@@ -42,7 +58,7 @@ def helper_path():
def is_set():
path = helper_path()
p = subprocess.run(["getcap", path], stdout=subprocess.PIPE)
p = subprocess.run([tools["getcap"], path], stdout=subprocess.PIPE)
if p.returncode != 0:
logger.error(f"Failed to get capability of {path}")
return False
@@ -53,8 +69,8 @@ def setcap():
path = helper_path()
if is_set():
return True
p = subprocess.run(["sudo", "setcap", "cap_net_raw,cap_net_admin+eip",
path])
p = subprocess.run(["sudo", tools["setcap"],
"cap_net_raw,cap_net_admin+eip", path])
if p.returncode !=0:
logger.error(f"Failed to set capability to {path}")
return False

View File

@@ -8,6 +8,7 @@ import asyncio
import pathlib
import ssl
import websockets
import socket
import json
import base64
import logging
@@ -42,6 +43,8 @@ logger.setLevel(logLevel)
logger.addHandler(handler)
logger.propagate = False
HOSTNAME="device-manager.scratch.mit.edu"
class Session():
"""Base class for BTSession and BLESession"""
def __init__(self, websocket, loop):
@@ -343,6 +346,11 @@ class BLESession(Session):
MAX_SCANNER_IF = 3
found_devices = []
nr_connected = 0
scan_lock = threading.RLock()
scan_started = False
class BLEThread(threading.Thread):
"""
Separated thread to control notifications to Scratch.
@@ -358,7 +366,7 @@ class BLESession(Session):
logger.debug("loop in BLE thread")
if self.session.status == self.session.DISCOVERY:
logger.debug("send out found devices")
devices = self.session.found_devices
devices = BLESession.found_devices
for d in devices:
params = { 'rssi': d.rssi }
params['peripheralId'] = devices.index(d)
@@ -418,7 +426,6 @@ class BLESession(Session):
def __init__(self, websocket, loop):
super().__init__(websocket, loop)
self.status = self.INITIAL
self.found_devices = []
self.device = None
self.deviceName = None
self.perip = None
@@ -426,6 +433,13 @@ class BLESession(Session):
self.characteristics_cache = []
def close(self):
if self.status == self.CONNECTED:
BLESession.nr_connected -= 1
logger.info(f"BLE session disconnected")
logger.debug(f"BLE session connected={BLESession.nr_connected}")
if BLESession.nr_connected == 0:
logger.info("all BLE sessions disconnected")
BLESession.scan_started = False
self.status = self.DONE
if self.perip:
logger.info("disconnect from the BLE peripheral: "
@@ -480,6 +494,29 @@ class BLESession(Session):
# ref: https://github.com/LLK/scratch-link/blob/develop/Documentation/BluetoothLE.md
return False
def _scan_devices(self, params):
if BLESession.nr_connected > 0:
return len(BLESession.found_devices) > 0
found = False
with BLESession.scan_lock:
if not BLESession.scan_started:
BLESession.scan_started = True
BLESession.found_devices.clear()
for i in range(self.MAX_SCANNER_IF):
scanner = Scanner(iface=i)
try:
devices = scanner.scan(10.0)
for dev in devices:
if self.matches(dev, params['filters']):
BLESession.found_devices.append(dev)
found = True
logger.debug(f"BLE device found with iface #{i}");
except BTLEManagementError as e:
logger.debug(f"BLE iface #{i}: {e}");
else:
found = len(BLESession.found_devices) > 0
return found
def _get_service(self, service_id):
with self.lock:
service = self.perip.getServiceByUUID(UUID(service_id))
@@ -528,29 +565,21 @@ class BLESession(Session):
if self.status == self.INITIAL and method == 'discover':
if not bluepy_helper_cap.is_set():
logger.error("Capability is not set to bluepy helper.")
logger.error("Run bluepy_setcap.py with root privilege.")
logger.error("Run bluepy_helper_cap(.py).")
logger.error("e.g. $ bluepy_helper_cap")
logger.error("e.g. $ sudo bluepy_helper_cap.py")
sys.exit(1)
found_ifaces = 0
for i in range(self.MAX_SCANNER_IF):
scanner = Scanner(iface=i)
try:
devices = scanner.scan(10.0)
for dev in devices:
if self.matches(dev, params['filters']):
self.found_devices.append(dev)
found_ifaces += 1
logger.debug(f"BLE device found with iface #{i}");
except BTLEManagementError as e:
logger.debug(f"BLE iface #{i}: {e}");
if found_ifaces == 0:
found = self._scan_devices(params)
if not found:
if BLESession.nr_connected > 0:
err_msg = "Can not scan BLE devices. Disconnect other sessions."
elif len(BLESession.found_devices) == 0:
err_msg = "Can not scan BLE devices. Check BLE controller."
logger.error(err_msg);
res["error"] = { "message": err_msg }
self.status = self.DONE
if len(self.found_devices) == 0 and not err_msg:
if len(BLESession.found_devices) == 0 and not err_msg:
err_msg = (f"No BLE device found: {params['filters']}. "
"Check BLE device.")
res["error"] = { "message": err_msg }
@@ -564,11 +593,12 @@ class BLESession(Session):
elif self.status == self.DISCOVERY and method == 'connect':
logger.debug("connecting to the BLE device")
self.device = self.found_devices[params['peripheralId']]
self.device = BLESession.found_devices[params['peripheralId']]
self.deviceName = self.device.getValueText(0x9) or self.device.getValueText(0x8)
try:
self.perip = Peripheral(self.device)
logger.info(f"connected to the BLE peripheral: {self.deviceName}")
BLESession.found_devices.remove(self.device)
except BTLEDisconnectError as e:
logger.error(f"failed to connect to the BLE device \"{self.deviceName}\": {e}")
self.status = self.DONE
@@ -576,6 +606,8 @@ class BLESession(Session):
if self.perip:
res["result"] = None
self.status = self.CONNECTED
BLESession.nr_connected += 1
logger.debug(f"BLE session connected={BLESession.nr_connected}")
self.delegate = self.BLEDelegate(self)
self.perip.withDelegate(self.delegate)
self._cache_characteristics()
@@ -668,7 +700,8 @@ async def ws_handler(websocket, path):
await session.handle()
except Exception as e:
logger.error(f"Failure in session for web socket path: {path}")
logger.error(e)
logger.error(f"{type(e).__name__}: {e}")
session.close()
def stack_trace():
print("in stack_trace")
@@ -708,7 +741,7 @@ def main():
ssl_context.load_cert_chain(localhost_cer, localhost_key)
start_server = websockets.serve(
ws_handler, "device-manager.scratch.mit.edu", 20110, ssl=ssl_context
ws_handler, HOSTNAME, 20110, ssl=ssl_context
)
while True:
@@ -719,7 +752,13 @@ def main():
except KeyboardInterrupt as e:
stack_trace()
break
except socket.gaierror as e:
logger.error(f"{type(e).__name__}: {e}")
logger.info(f"Check internet connection to {HOSTNAME}. If not "
f"available, add '127.0.0.1 {HOSTNAME}' to /etc/hosts.")
break
except Exception as e:
logger.error(f"{type(e).__name__}: {e}")
logger.info("Restarting scratch-link...")
if __name__ == "__main__":

View File

@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
setuptools.setup(
name="pyscrlink",
version="0.2.0",
version="0.2.3",
author="Shin'ichiro Kawasaki",
author_email='kawasaki@juno.dti.ne.jp',
description='Scratch-link for Linux with Python',