mirror of
https://github.com/kawasaki/pyscrlink.git
synced 2025-09-06 17:50:20 +02:00
Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9265086b12 | ||
|
58a60c94db | ||
|
ea1109cee2 | ||
|
f4af270fbc | ||
|
1b92af0f0a | ||
|
c206f7a5c5 | ||
|
e34ec61f3b | ||
|
e552bd21bd | ||
|
dc46869760 | ||
|
461377f7ea |
29
README.md
29
README.md
@@ -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
|
handle Bluetooth, and [bluepy](https://github.com/IanHarvey/bluepy) to handle
|
||||||
Bluetooth Low Energy (BLE) connections. It has been reported that pyscrlink
|
Bluetooth Low Energy (BLE) connections. It has been reported that pyscrlink
|
||||||
connects Scratch 3.0 with micro:bit, LEGO Mindstorms EV3, LEGO WeDo, LEGO
|
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
|
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
|
system has python older than version 3.6, install newer version. If your
|
||||||
@@ -53,6 +53,7 @@ Devices:
|
|||||||
* LEGO WeDo by @zhaowe, @KingBBQ
|
* LEGO WeDo by @zhaowe, @KingBBQ
|
||||||
* LEGO Boost by @laurentchar, @miguev, @jacquesdt
|
* LEGO Boost by @laurentchar, @miguev, @jacquesdt
|
||||||
* Intelino Smart Train by @ErrorJan
|
* Intelino Smart Train by @ErrorJan
|
||||||
|
* toio by @shimodash
|
||||||
|
|
||||||
Linux distros:
|
Linux distros:
|
||||||
* Raspbian by @chrisglencross
|
* Raspbian by @chrisglencross
|
||||||
@@ -73,7 +74,7 @@ Installation
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
Ubuntu
|
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
|
Arch
|
||||||
$ sudo pacman -S bluez bluez-utils nss libcap
|
$ sudo pacman -S bluez bluez-utils nss libcap
|
||||||
```
|
```
|
||||||
@@ -99,7 +100,7 @@ Installation
|
|||||||
5. For micro:bit, install Scratch-link hex on your device.
|
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).
|
* 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
|
five character name of the micro:bit scroll across the screen such as
|
||||||
'zo9ev'.
|
'zo9ev'.
|
||||||
|
|
||||||
@@ -159,7 +160,7 @@ Usage
|
|||||||
$ scratch_link
|
$ 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.)
|
* 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.
|
* Access [Scratch 3.0](https://scratch.mit.edu/) and create your project.
|
||||||
* Select the "Add Extension" button.
|
* 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",
|
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.
|
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
|
Issus Reporting
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
@@ -195,6 +202,20 @@ Please file issues to [GitHub issue tracker](https://github.com/kawasaki/pyscrli
|
|||||||
Releases
|
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
|
Release 0.2.0
|
||||||
|
|
||||||
* Latency issue fix for BLE devices' write characteristics
|
* Latency issue fix for BLE devices' write characteristics
|
||||||
|
@@ -22,10 +22,26 @@ logger.propagate = False
|
|||||||
# Check dependent tools
|
# Check dependent tools
|
||||||
DEPENDENT_TOOLS = {
|
DEPENDENT_TOOLS = {
|
||||||
"setcap": "libcap2-bin (Ubuntu) or libcap (Arch)",
|
"setcap": "libcap2-bin (Ubuntu) or libcap (Arch)",
|
||||||
|
"getcap": "libcap2-bin (Ubuntu) or libcap (Arch)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools = {}
|
||||||
|
|
||||||
for cmd in DEPENDENT_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]}.")
|
print(f"'{cmd}' not found. Install package {DEPENDENT_TOOLS[cmd]}.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -42,7 +58,7 @@ def helper_path():
|
|||||||
|
|
||||||
def is_set():
|
def is_set():
|
||||||
path = helper_path()
|
path = helper_path()
|
||||||
p = subprocess.run(["getcap", path], stdout=subprocess.PIPE)
|
p = subprocess.run([tools["getcap"], path], stdout=subprocess.PIPE)
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
logger.error(f"Failed to get capability of {path}")
|
logger.error(f"Failed to get capability of {path}")
|
||||||
return False
|
return False
|
||||||
@@ -53,8 +69,8 @@ def setcap():
|
|||||||
path = helper_path()
|
path = helper_path()
|
||||||
if is_set():
|
if is_set():
|
||||||
return True
|
return True
|
||||||
p = subprocess.run(["sudo", "setcap", "cap_net_raw,cap_net_admin+eip",
|
p = subprocess.run(["sudo", tools["setcap"],
|
||||||
path])
|
"cap_net_raw,cap_net_admin+eip", path])
|
||||||
if p.returncode !=0:
|
if p.returncode !=0:
|
||||||
logger.error(f"Failed to set capability to {path}")
|
logger.error(f"Failed to set capability to {path}")
|
||||||
return False
|
return False
|
||||||
|
@@ -8,6 +8,7 @@ import asyncio
|
|||||||
import pathlib
|
import pathlib
|
||||||
import ssl
|
import ssl
|
||||||
import websockets
|
import websockets
|
||||||
|
import socket
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
@@ -42,6 +43,8 @@ logger.setLevel(logLevel)
|
|||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
|
|
||||||
|
HOSTNAME="device-manager.scratch.mit.edu"
|
||||||
|
|
||||||
class Session():
|
class Session():
|
||||||
"""Base class for BTSession and BLESession"""
|
"""Base class for BTSession and BLESession"""
|
||||||
def __init__(self, websocket, loop):
|
def __init__(self, websocket, loop):
|
||||||
@@ -343,6 +346,11 @@ class BLESession(Session):
|
|||||||
|
|
||||||
MAX_SCANNER_IF = 3
|
MAX_SCANNER_IF = 3
|
||||||
|
|
||||||
|
found_devices = []
|
||||||
|
nr_connected = 0
|
||||||
|
scan_lock = threading.RLock()
|
||||||
|
scan_started = False
|
||||||
|
|
||||||
class BLEThread(threading.Thread):
|
class BLEThread(threading.Thread):
|
||||||
"""
|
"""
|
||||||
Separated thread to control notifications to Scratch.
|
Separated thread to control notifications to Scratch.
|
||||||
@@ -358,7 +366,7 @@ class BLESession(Session):
|
|||||||
logger.debug("loop in BLE thread")
|
logger.debug("loop in BLE thread")
|
||||||
if self.session.status == self.session.DISCOVERY:
|
if self.session.status == self.session.DISCOVERY:
|
||||||
logger.debug("send out found devices")
|
logger.debug("send out found devices")
|
||||||
devices = self.session.found_devices
|
devices = BLESession.found_devices
|
||||||
for d in devices:
|
for d in devices:
|
||||||
params = { 'rssi': d.rssi }
|
params = { 'rssi': d.rssi }
|
||||||
params['peripheralId'] = devices.index(d)
|
params['peripheralId'] = devices.index(d)
|
||||||
@@ -418,7 +426,6 @@ class BLESession(Session):
|
|||||||
def __init__(self, websocket, loop):
|
def __init__(self, websocket, loop):
|
||||||
super().__init__(websocket, loop)
|
super().__init__(websocket, loop)
|
||||||
self.status = self.INITIAL
|
self.status = self.INITIAL
|
||||||
self.found_devices = []
|
|
||||||
self.device = None
|
self.device = None
|
||||||
self.deviceName = None
|
self.deviceName = None
|
||||||
self.perip = None
|
self.perip = None
|
||||||
@@ -426,6 +433,13 @@ class BLESession(Session):
|
|||||||
self.characteristics_cache = []
|
self.characteristics_cache = []
|
||||||
|
|
||||||
def close(self):
|
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
|
self.status = self.DONE
|
||||||
if self.perip:
|
if self.perip:
|
||||||
logger.info("disconnect from the BLE peripheral: "
|
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
|
# ref: https://github.com/LLK/scratch-link/blob/develop/Documentation/BluetoothLE.md
|
||||||
return False
|
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):
|
def _get_service(self, service_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
service = self.perip.getServiceByUUID(UUID(service_id))
|
service = self.perip.getServiceByUUID(UUID(service_id))
|
||||||
@@ -528,29 +565,21 @@ class BLESession(Session):
|
|||||||
if self.status == self.INITIAL and method == 'discover':
|
if self.status == self.INITIAL and method == 'discover':
|
||||||
if not bluepy_helper_cap.is_set():
|
if not bluepy_helper_cap.is_set():
|
||||||
logger.error("Capability is not set to bluepy helper.")
|
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")
|
logger.error("e.g. $ sudo bluepy_helper_cap.py")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
found_ifaces = 0
|
found = self._scan_devices(params)
|
||||||
for i in range(self.MAX_SCANNER_IF):
|
if not found:
|
||||||
scanner = Scanner(iface=i)
|
if BLESession.nr_connected > 0:
|
||||||
try:
|
err_msg = "Can not scan BLE devices. Disconnect other sessions."
|
||||||
devices = scanner.scan(10.0)
|
elif len(BLESession.found_devices) == 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:
|
|
||||||
err_msg = "Can not scan BLE devices. Check BLE controller."
|
err_msg = "Can not scan BLE devices. Check BLE controller."
|
||||||
logger.error(err_msg);
|
logger.error(err_msg);
|
||||||
res["error"] = { "message": err_msg }
|
res["error"] = { "message": err_msg }
|
||||||
self.status = self.DONE
|
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']}. "
|
err_msg = (f"No BLE device found: {params['filters']}. "
|
||||||
"Check BLE device.")
|
"Check BLE device.")
|
||||||
res["error"] = { "message": err_msg }
|
res["error"] = { "message": err_msg }
|
||||||
@@ -564,11 +593,12 @@ class BLESession(Session):
|
|||||||
|
|
||||||
elif self.status == self.DISCOVERY and method == 'connect':
|
elif self.status == self.DISCOVERY and method == 'connect':
|
||||||
logger.debug("connecting to the BLE device")
|
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)
|
self.deviceName = self.device.getValueText(0x9) or self.device.getValueText(0x8)
|
||||||
try:
|
try:
|
||||||
self.perip = Peripheral(self.device)
|
self.perip = Peripheral(self.device)
|
||||||
logger.info(f"connected to the BLE peripheral: {self.deviceName}")
|
logger.info(f"connected to the BLE peripheral: {self.deviceName}")
|
||||||
|
BLESession.found_devices.remove(self.device)
|
||||||
except BTLEDisconnectError as e:
|
except BTLEDisconnectError as e:
|
||||||
logger.error(f"failed to connect to the BLE device \"{self.deviceName}\": {e}")
|
logger.error(f"failed to connect to the BLE device \"{self.deviceName}\": {e}")
|
||||||
self.status = self.DONE
|
self.status = self.DONE
|
||||||
@@ -576,6 +606,8 @@ class BLESession(Session):
|
|||||||
if self.perip:
|
if self.perip:
|
||||||
res["result"] = None
|
res["result"] = None
|
||||||
self.status = self.CONNECTED
|
self.status = self.CONNECTED
|
||||||
|
BLESession.nr_connected += 1
|
||||||
|
logger.debug(f"BLE session connected={BLESession.nr_connected}")
|
||||||
self.delegate = self.BLEDelegate(self)
|
self.delegate = self.BLEDelegate(self)
|
||||||
self.perip.withDelegate(self.delegate)
|
self.perip.withDelegate(self.delegate)
|
||||||
self._cache_characteristics()
|
self._cache_characteristics()
|
||||||
@@ -668,7 +700,8 @@ async def ws_handler(websocket, path):
|
|||||||
await session.handle()
|
await session.handle()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failure in session for web socket path: {path}")
|
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():
|
def stack_trace():
|
||||||
print("in stack_trace")
|
print("in stack_trace")
|
||||||
@@ -708,7 +741,7 @@ def main():
|
|||||||
ssl_context.load_cert_chain(localhost_cer, localhost_key)
|
ssl_context.load_cert_chain(localhost_cer, localhost_key)
|
||||||
|
|
||||||
start_server = websockets.serve(
|
start_server = websockets.serve(
|
||||||
ws_handler, "device-manager.scratch.mit.edu", 20110, ssl=ssl_context
|
ws_handler, HOSTNAME, 20110, ssl=ssl_context
|
||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -719,7 +752,13 @@ def main():
|
|||||||
except KeyboardInterrupt as e:
|
except KeyboardInterrupt as e:
|
||||||
stack_trace()
|
stack_trace()
|
||||||
break
|
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:
|
except Exception as e:
|
||||||
|
logger.error(f"{type(e).__name__}: {e}")
|
||||||
logger.info("Restarting scratch-link...")
|
logger.info("Restarting scratch-link...")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
2
setup.py
2
setup.py
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
|
|||||||
|
|
||||||
setuptools.setup(
|
setuptools.setup(
|
||||||
name="pyscrlink",
|
name="pyscrlink",
|
||||||
version="0.2.0",
|
version="0.2.3",
|
||||||
author="Shin'ichiro Kawasaki",
|
author="Shin'ichiro Kawasaki",
|
||||||
author_email='kawasaki@juno.dti.ne.jp',
|
author_email='kawasaki@juno.dti.ne.jp',
|
||||||
description='Scratch-link for Linux with Python',
|
description='Scratch-link for Linux with Python',
|
||||||
|
Reference in New Issue
Block a user