14 Commits
v0.2.3 ... dev

Author SHA1 Message Date
Shin'ichiro Kawasaki
2d489321d0 BLESession.setNotifications: Lookup CCCD to start or stop notifications
Per Bluetooth Core Specification Vol.3, Part G, Section 3.3.1.1, if
Notify property is set for a Characteristic, the Client Characteristic
Configuration Descriptor, or CCCD, shall exist. As noted in section
3.3.3.3 of the specification, notification of the Characteristic is
enabled or disabled by setting bit 0 of the CCCD.

This CCCD exists within the characteristic definition following the
Characteristic Value declaration, and section 3.3.3 of the specification
warns that the order of characteristic declaration shall not be assumed.
However, current pyscrlink code assumes that the CCCD comes just after
the Characteristic Value and CCCD handle is obtained just adding 1 to
the handle of the Characteristic Value. This works many of the BLE
devices but does not work all of them.

To get correct CCCD handle, look up descriptors of the Characteristic to
find out the CCCD.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-01-03 12:27:39 +09:00
Shin'ichiro Kawasaki
48e460b9c6 BLESession: Add _getDevName() helper function
GitHub issue #31 reported that Intelino Smart Train is not connected
with pyscrlink. It also reported that getValueText(0x9) call for the
device instead of getValueText(0x8) avoids the failure. This implies
that the Intelino Smart Train does not report AdType 0x8 "Shortened
local name" but it reports AdType 0x9 "Complete local name".

To get local name of the BLE devices regardless AdType support status
of those devices, introduce a helper function _getDevName(). It gets the
AdType 0x9 "Complete local name" first. If it is not available, get
AdType 0x8 "Shortened local name". Use this helper function at all
places to get the device name.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-12-31 14:49:51 +09:00
Shin'ichiro Kawasaki
3cf61a145b BTSession: Additional change to pretend LEGO EV3 with LEGO Hub
The previous commit tweaked the class IDs returned from the LEGO Hub as
that of LEGO EV3. On top of that, it is required to tweak the class ID
to search and discover for bluetooth.DeviceDiscoverer API.

Also, add some more debug prints to track BT device discovery.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-21 14:07:15 +09:00
Shin'ichiro Kawasaki
3ffbde35b1 BLESession: handle device UUIDs as list
In the debug for the issue #30, it turned out that the bluepy API
ScanEntry.getValueText() for adtypes may return multiple UUIDs
concatenated with comma. Current code assumes that the API returns
single UUID, then multiple UUIDs cause the "Error: Non-hexadecimal digit
found" for the comma.

To fix it, replace ScanEntry.getValueText() call with getValue(), and
handle the result as a list of UUIDs. Also rename the helper function
_get_dev_uuid() to _get_dev_uuids() accordingly.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-14 20:46:53 +09:00
Shin'ichiro Kawasaki
18e0818e98 BTSession: Pretend LEGO EV3 with LEGO Hub
To try out LEGO Hub connection as LEGO EV3, tweak BTSession device
discovery code. When the device name has "LEGO Hub", prented its device
class (major/minor=8/4) as LEGO EV3's device class (major/minor=8/1).
This is a trial code for the GitHub issue #21.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-14 14:46:04 +09:00
Shin'ichiro Kawasaki
266bcc98bd BLESession: Enrich logs for device UUID check
To debug the GitHub issue #30, add more debug logs to functions to check
UUID.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-14 13:41:54 +09:00
Shin'ichiro Kawasaki
80b99f84a1 BLESession: Override unknown handle with known handle
Unknown handle "13" was recorded in debug log for the Git Hub issue #26.
As a debug trial, replace the unknown handle with the known handle.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-14 13:13:19 +09:00
Shin'ichiro Kawasaki
f8ce9e3089 BLESession: Enrich logs for notification handling
To debug the GitHub issue #26, add more debug and error logs to
functions to handle notifications.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-14 13:13:19 +09:00
Shin'ichiro Kawasaki
e6d63a5c97 Tag version 0.2.4
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-10-16 11:09:28 +09:00
Shin'ichiro Kawasaki
6f7da4f720 README.md: Add description about "-s 1" option for toio
For toio, it is recommended to add "-s 1" option to scratch_link
command. It reduces scan wait duration, and allows toio Do Visual
Programming to connect toio devices automatically. Document it for toio
users.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-10-16 11:09:28 +09:00
Shin'ichiro Kawasaki
32da72492f scratch_link.py: Add -s option to specify BLE scan duration
Pyscrlink scans BLE devices for 10 seconds. This is a safe number to
cover various environments and devices. However, this is too long for
specific devices. One example is toio. Toio's Visual Programming
environment has automated connection to toio devices via Scratch-link,
at it assumes that the scan finishes with shorter time. To allow users
to specify shorter scan duration, add -s, or --scan_seconds option.

To simplify this new option support, utilize argparse library. Rewrite
option parser with argparse.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-10-10 15:32:06 +09:00
Shin'ichiro Kawasaki
1af4baa7d6 README.md: Clarify bluepy-helper capability setup requirement
@n3storm reported that it is not clear which device is BLE, and requires
bluepy-helper capability setup. Improve description to note that most of
devices require the setup since BLE devices are getting the majority.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-09-25 09:38:37 +09:00
Néstor Díaz Valencia
39587b497b Update README.md 2021-09-25 09:30:55 +09:00
Néstor Díaz Valencia
f506bbcaab Update README.md 2021-09-25 09:30:55 +09:00
3 changed files with 89 additions and 34 deletions

View File

@@ -42,7 +42,7 @@ Linux distros:
* elementary OS 5.1 Hera * elementary OS 5.1 Hera
Browsers: Browsers:
* FireFox * Firefox
* Chromium * Chromium
It was reported that pyscrlink (former bluepy-scratch-link) working with It was reported that pyscrlink (former bluepy-scratch-link) working with
@@ -51,7 +51,7 @@ following devices and Linux distros.
Devices: Devices:
* LEGO Mindstorm EV3 by @chrisglencross * LEGO Mindstorm EV3 by @chrisglencross
* LEGO WeDo by @zhaowe, @KingBBQ * LEGO WeDo by @zhaowe, @KingBBQ
* LEGO Boost by @laurentchar, @miguev, @jacquesdt * LEGO Boost and compatible devices by @laurentchar, @miguev, @jacquesdt, @n3storm
* Intelino Smart Train by @ErrorJan * Intelino Smart Train by @ErrorJan
* toio by @shimodash * toio by @shimodash
@@ -59,6 +59,7 @@ Linux distros:
* Raspbian by @chrisglencross * Raspbian by @chrisglencross
* Ubuntu 16.04 @jacquesdt * Ubuntu 16.04 @jacquesdt
* Ubuntu Studio 20.04 @miguev * Ubuntu Studio 20.04 @miguev
* Debian 11 @n3storm
Installation Installation
------------ ------------
@@ -87,7 +88,8 @@ Installation
$ pip3 install pyscrlink $ pip3 install pyscrlink
``` ```
4. For Bluetooth Low Energy (BLE) devices, set bluepy-helper capability. 4. Set bluepy-helper capability.
This step is required for most of devices except LEGO Mindstorm EV3.
``` ```
$ bluepy_helper_cap $ bluepy_helper_cap
@@ -159,6 +161,8 @@ Usage
```sh ```sh
$ scratch_link $ scratch_link
``` ```
If your device is toio, add "-s 1" option to the scratch_link command. It
allows the toio Do Visual Programming to connect to toio automatically.
3. Connect scratch to the target device such as 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.)
@@ -202,6 +206,11 @@ Please file issues to [GitHub issue tracker](https://github.com/kawasaki/pyscrli
Releases Releases
-------- --------
Release 0.2.4
* Added -s option to specify BLE scan duration
* Improved README.md
Release 0.2.3 Release 0.2.3
* Fixed eternal loop caused by hostname resolve failure * Fixed eternal loop caused by hostname resolve failure

View File

@@ -15,6 +15,7 @@ import logging
import sys import sys
import signal import signal
import traceback import traceback
import argparse
# for Bluetooth (e.g. Lego EV3) # for Bluetooth (e.g. Lego EV3)
import bluetooth import bluetooth
@@ -44,6 +45,9 @@ logger.addHandler(handler)
logger.propagate = False logger.propagate = False
HOSTNAME="device-manager.scratch.mit.edu" HOSTNAME="device-manager.scratch.mit.edu"
scan_seconds=10.0
CCCD_UUID = 0x2902
class Session(): class Session():
"""Base class for BTSession and BLESession""" """Base class for BTSession and BLESession"""
@@ -168,6 +172,9 @@ class BTSession(Session):
logger.debug(f"Found device {name} addr={address} class={device_class} rssi={rssi}") logger.debug(f"Found device {name} addr={address} class={device_class} rssi={rssi}")
major_class = (device_class & 0x1F00) >> 8 major_class = (device_class & 0x1F00) >> 8
minor_class = (device_class & 0xFF) >> 2 minor_class = (device_class & 0xFF) >> 2
if "LEGO Hub" in name:
minor_class = 1
logger.info(f"Pretend to be LEGO EV3 with LEGO Hub: class={major_class/minor_class}")
if major_class == self.major_class and minor_class == self.minor_class: if major_class == self.major_class and minor_class == self.minor_class:
self.found_devices[address] = (name, device_class, rssi) self.found_devices[address] = (name, device_class, rssi)
@@ -183,7 +190,13 @@ class BTSession(Session):
self.ping_time = None self.ping_time = None
def discover(self): def discover(self):
discoverer = self.BTDiscoverer(self.major_device_class, self.minor_device_class) major = self.major_device_class
minor = self.minor_device_class
if major == 8 and minor == 1:
logger.info(f"Search LEGO Hub instead of LEGO EV3")
minor = 4
logger.debug(f"BT discover: class={major}/{minor}")
discoverer = self.BTDiscoverer(major, minor)
discoverer.find_devices(lookup_names=True) discoverer.find_devices(lookup_names=True)
while self.session.status == self.session.DISCOVERY and not discoverer.done and not self.cancel_discovery: while self.session.status == self.session.DISCOVERY and not discoverer.done and not self.cancel_discovery:
readable = select.select([discoverer], [], [], 0.5)[0] readable = select.select([discoverer], [], [], 0.5)[0]
@@ -201,7 +214,7 @@ class BTSession(Session):
def run(self): def run(self):
while self.session.status != self.session.DONE: while self.session.status != self.session.DONE:
logger.debug("loop in BT thread") logger.debug(f"loop in BT thread: session status={self.session.status}")
current_time = int(round(time.time())) current_time = int(round(time.time()))
if self.session.status == self.session.DISCOVERY and not self.cancel_discovery: if self.session.status == self.session.DISCOVERY and not self.cancel_discovery:
@@ -351,6 +364,14 @@ class BLESession(Session):
scan_lock = threading.RLock() scan_lock = threading.RLock()
scan_started = False scan_started = False
@staticmethod
def _getDevName(dev):
"""
Get AdType 0x09 (Completed local name). If it is not available,
get AdType 0x08 (Shortened local name).
"""
return dev.getValueText(0x9) or dev.getValueText(0x8)
class BLEThread(threading.Thread): class BLEThread(threading.Thread):
""" """
Separated thread to control notifications to Scratch. Separated thread to control notifications to Scratch.
@@ -370,7 +391,7 @@ class BLESession(Session):
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)
params['name'] = d.getValueText(0x9) or d.getValueText(0x8) params['name'] = BLESession._getDevName(d)
self.session.notify('didDiscoverPeripheral', params) self.session.notify('didDiscoverPeripheral', params)
time.sleep(1) time.sleep(1)
elif self.session.status == self.session.CONNECTED: elif self.session.status == self.session.CONNECTED:
@@ -387,7 +408,8 @@ class BLESession(Session):
logger.debug("after waitForNotification") logger.debug("after waitForNotification")
logger.debug("released lock for waitForNotification") logger.debug("released lock for waitForNotification")
except Exception as e: except Exception as e:
logger.error(e) logger.error(f"Exception in waitForNotifications: "
f"{type(e).__name__}: {e}")
self.session.close() self.session.close()
break break
else: else:
@@ -411,7 +433,8 @@ class BLESession(Session):
self.restart_notification_event.set() self.restart_notification_event.set()
def add_handle(self, serviceId, charId, handle): def add_handle(self, serviceId, charId, handle):
logger.debug(f"add handle for notification: {handle}") logger.debug(f"add handle for notification: "
f"{serviceId} {charId} {handle}")
params = { 'serviceId': UUID(serviceId).getCommonName(), params = { 'serviceId': UUID(serviceId).getCommonName(),
'characteristicId': charId, 'characteristicId': charId,
'encoding': 'base64' } 'encoding': 'base64' }
@@ -419,6 +442,14 @@ class BLESession(Session):
def handleNotification(self, handle, data): def handleNotification(self, handle, data):
logger.debug(f"BLE notification: {handle} {data}") logger.debug(f"BLE notification: {handle} {data}")
if handle not in self.handles:
logger.error(f"Notification with unknown handle: {handle}")
keys = list(self.handles.keys())
if keys and len(keys) == 1:
logger.debug(f"Debug: override {handle} with {keys[0]}")
handle = keys[0]
else:
return
params = self.handles[handle].copy() params = self.handles[handle].copy()
params['message'] = base64.standard_b64encode(data).decode('ascii') params['message'] = base64.standard_b64encode(data).decode('ascii')
self.session.notify('characteristicDidChange', params) self.session.notify('characteristicDidChange', params)
@@ -451,12 +482,14 @@ class BLESession(Session):
def __del__(self): def __del__(self):
self.close() self.close()
def _get_dev_uuid(self, dev): def _get_dev_uuids(self, dev):
for adtype in self.SERVICE_CLASS_UUID_ADTYPES: for adtype in self.SERVICE_CLASS_UUID_ADTYPES:
service_class_uuid = dev.getValueText(adtype) service_class_uuids = dev.getValue(adtype)
if service_class_uuid: if service_class_uuids:
logger.debug(self.SERVICE_CLASS_UUID_ADTYPES[adtype]) for u in service_class_uuids:
return UUID(service_class_uuid) a = self.SERVICE_CLASS_UUID_ADTYPES[adtype]
logger.debug(f"service class uuid for {a}/{adtype}: {u}")
return service_class_uuids
return None return None
def matches(self, dev, filters): def matches(self, dev, filters):
@@ -469,18 +502,19 @@ class BLESession(Session):
for s in f['services']: for s in f['services']:
logger.debug(f"service to check: {s}") logger.debug(f"service to check: {s}")
given_uuid = s given_uuid = s
logger.debug(f"given: {given_uuid}") logger.debug(f"given UUID: {given_uuid} hash={UUID(given_uuid).__hash__()}")
dev_uuid = self._get_dev_uuid(dev) dev_uuids = self._get_dev_uuids(dev)
if not dev_uuid: if not dev_uuids:
continue continue
logger.debug(f"dev: {dev_uuid}") for u in dev_uuids:
logger.debug(given_uuid == dev_uuid) logger.debug(f"dev UUID: {u} hash={u.__hash__()}")
if given_uuid == dev_uuid: logger.debug(given_uuid == u)
logger.debug("match...") if given_uuid == u:
return True logger.debug("match...")
return True
if 'namePrefix' in f: if 'namePrefix' in f:
# 0x08: Shortened Local Name # 0x08: Shortened Local Name
deviceName = dev.getValueText(0x08) deviceName = self._getDevName(dev)
if not deviceName: if not deviceName:
continue continue
logger.debug(f"Name of \"{deviceName}\" begins with: \"{f['namePrefix']}\"?") logger.debug(f"Name of \"{deviceName}\" begins with: \"{f['namePrefix']}\"?")
@@ -495,6 +529,7 @@ class BLESession(Session):
return False return False
def _scan_devices(self, params): def _scan_devices(self, params):
global scan_seconds
if BLESession.nr_connected > 0: if BLESession.nr_connected > 0:
return len(BLESession.found_devices) > 0 return len(BLESession.found_devices) > 0
found = False found = False
@@ -505,7 +540,8 @@ class BLESession(Session):
for i in range(self.MAX_SCANNER_IF): for i in range(self.MAX_SCANNER_IF):
scanner = Scanner(iface=i) scanner = Scanner(iface=i)
try: try:
devices = scanner.scan(10.0) logger.debug(f"start BLE scan: {scan_seconds} seconds")
devices = scanner.scan(scan_seconds)
for dev in devices: for dev in devices:
if self.matches(dev, params['filters']): if self.matches(dev, params['filters']):
BLESession.found_devices.append(dev) BLESession.found_devices.append(dev)
@@ -594,7 +630,7 @@ 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 = BLESession.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._getDevName(self.device)
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}")
@@ -671,11 +707,19 @@ class BLESession(Session):
service = self._get_service(service_id) service = self._get_service(service_id)
c = self._get_characteristic(chara_id) c = self._get_characteristic(chara_id)
handle = c.getHandle() handle = c.getHandle()
# get CCCD or Client Characterstic Configuration Descriptor
cccd = None
for d in c.getDescriptors():
if d.uuid == UUID(CCCD_UUID):
cccd = d
if not cccd:
logger.error("Characteristic {char_id} does not have CCCD")
return
# prepare notification handler # prepare notification handler
self.delegate.add_handle(service_id, chara_id, handle) self.delegate.add_handle(service_id, chara_id, handle)
# request notification to the BLE device # request notification to the BLE device
with self.lock: with self.lock:
self.perip.writeCharacteristic(handle + 1, value, True) self.perip.writeCharacteristic(cccd.handle, value, True)
def startNotifications(self, service_id, chara_id): def startNotifications(self, service_id, chara_id):
logger.debug(f"start notification for {chara_id}") logger.debug(f"start notification for {chara_id}")
@@ -718,18 +762,20 @@ def stack_trace():
print(line) print(line)
def main(): def main():
opts = [opt for opt in sys.argv[1:] if opt.startswith("-")] global scan_seconds
if "-h" in opts: parser = argparse.ArgumentParser(description='start Scratch-link')
print((f"Usage: {sys.argv[0]} [OPTS]\n" parser.add_argument('-d', '--debug', action='store_true',
"OPTS:\t-h Show this help.\n" help='print debug messages')
"\t-d Print debug messages." parser.add_argument('-s', '--scan_seconds', type=float, default=10.0,
)) help='specifiy duration to scan BLE devices in seconds')
sys.exit(1) args = parser.parse_args()
elif "-d" in opts: if args.debug:
print("Print debug messages") print("Print debug messages")
logLevel = logging.DEBUG logLevel = logging.DEBUG
handler.setLevel(logLevel) handler.setLevel(logLevel)
logger.setLevel(logLevel) logger.setLevel(logLevel)
scan_seconds = args.scan_seconds
logger.debug(f"set scan_seconds: {scan_seconds}")
# Prepare certificate of the WSS server # Prepare certificate of the WSS server
gencert.prep_cert() gencert.prep_cert()

View File

@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
setuptools.setup( setuptools.setup(
name="pyscrlink", name="pyscrlink",
version="0.2.3", version="0.2.4",
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',