44 Commits
v0.1.0 ... dbus

Author SHA1 Message Date
Shin'ichiro Kawasaki
2482bba1d8 ble: Separate BLEDBusSession class to a separated new file ble.py
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-09-18 16:51:43 +09:00
Shin'ichiro Kawasaki
06df4af4a0 BLEDBusSession: Create Device class to gather device attributes
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-09-18 15:31:46 +09:00
Shin'ichiro Kawasaki
adb746995e BLEDbusSession: implement multiple devices support and disconnection
Also implemented service search retry to stabilize the first read
operation. Next work is to create a class to gather device attirbutes
that BLEDBusSession refers.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-09-10 20:32:03 +09:00
Shin'ichiro Kawasaki
157e3458b0 BLEDBusSession: implement write method
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-09-05 21:20:18 +09:00
Shin'ichiro Kawasaki
fe3cd35ac0 BLEDbusSession: implement notification reader
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-09-05 21:08:15 +09:00
Shin'ichiro Kawasaki
94a9555c4c BLEDBusSession: get GATT services and characteristics
Using DBus interface, get GATT services and characteristics of the
connected BLE device. Also implement read request to a characteristic.
2023-08-27 19:46:35 +09:00
Shin'ichiro Kawasaki
39b157e839 BLEDBusSession: introduce BTUUID class
bluepy provides its unique UUID class which handles UUIDs of Bluetooth
devices well. Not to depend on bluepy, introduce BTUUID which extends
python standard uuid.UUID class.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-08-27 11:27:51 +09:00
Shin'ichiro Kawasaki
f79e7ac889 BLEDBusSession: WIP
I implmented 'discovery' and 'connect' commands. Next action is to
implment 'read', 'write' and 'notify'. I'm not yet sure how DBus handles
'notify'.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-08-27 11:27:35 +09:00
Shin'ichiro Kawasaki
dc2ee5a22f Tag version 0.2.8
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-07-02 21:52:08 +09:00
Shin'ichiro Kawasaki
caa344ecbb scratch_link.py: Support COMPLETE_LOCAL_NAME for 'namePrefix' filter
Current implementation of match() does filter matching for 'namePrefix'
keyword only for SHORT_LOCAL_NAME of the device. When the device does
not provide SHORT_LOCAL_NAME but provides COMPLETE_LOCAL_NAME, match()
for 'namePrefix' fails. For example, MicroBit More v2 [1] does not
provide the SHORT_LOCAL_NAME.

To support 'namePrefix' filter match with COMPLETE_LOCAL_NAME, add
support for it. While at it, replace the SHORT_LOCAL_NAME identifier
constant with the definition in btle.ScanEntry.

[1] https://lab.yengawa.com/project/microbit-more-v2/

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-07-02 21:52:08 +09:00
Shin'ichiro Kawasaki
0f9ccd3b63 Tag version 0.2.7
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-03-21 17:17:31 +09:00
Shin'ichiro Kawasaki
d1f7f58ca2 README.md: Add description about -r option for scan failure
Add an item to "In Case You Fail to Connect" section to describe that
the "-r" option may help to address device scan failure.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-03-21 16:54:59 +09:00
Shin'ichiro Kawasaki
7050016ee6 scratch_link.py: Add -r option to retry BLE scan
Recently, scratch link connection failure is reported. Similar failure
is observed with my environment. I found BTLEDisconnectError happens at
scanner.scan(). Though the true cause of the failure is not yet known, I
found simple retry of scanner.scan() avoids the failure. As a temporary
work around, implement -r or --scan_retry option which specify how many
times to retry the scan. I confirmed that "-r 2" option avoided the
failure.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-03-21 15:53:34 +09:00
Shin'ichiro Kawasaki
d845d69bb8 gencert.py: Support Snap Firefox and Chromium
Recently Ubuntu provides Firefox and Chromium in the form of Snap
package, and they place NSS DB at different path from non-Snap package.
However, current gencert.py implementation supports only the NSS DB
paths for non-Snap packages. This results in HTTPS communication failure
between the browsers and scratch_link.

Support the NSS DB paths for the Snap packages. Add a new function
prep_cert_for_app() which takes application name and its NSS DB search
path. Call this function for list of browsers, covering both non-Snap
and Snap packages.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2023-03-19 20:59:58 +09:00
Shin'ichiro Kawasaki
751190935a release.sh: add example command lines
I tend to forget the command lines to release new packages. Record it in
the release script.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-18 11:12:46 +09:00
Shin'ichiro Kawasaki
3956c81869 Tag version 0.2.6
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-18 10:58:32 +09:00
Shin'ichiro Kawasaki
caaec40935 release.sh: update package build commands
Recent changes in python eco-system require different command to build
python packages. Update the commands to fit to the latest environment.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-18 10:58:32 +09:00
Shin'ichiro Kawasaki
845b37707e README.md: remove descriptions about EV3
pyscrlink no longer support LEGO Mindstorm EV3. Remove the description
about EV3 and explain why it is not supported. I wish it will be
supported again some day.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-18 10:53:57 +09:00
Shin'ichiro Kawasaki
a5d19fa2ba setup.py: remove dependency to pybluez
pybluez can not be installed with pip. Remove dependency to it.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-18 10:53:57 +09:00
Shin'ichiro Kawasaki
865a4d6f09 scratch_link.py: remove pybluez dependency
Recently pybluez is not maintained actively and causing troubles. One of
the troubles is pip installation failure [1] and this made pyscrlink
uninstallable via pip. To avoid this, remove pybluez dependency from
pyscrlink. This removes Bluetooth Classic protocol support, then LEGO
Mindstorm EV3 is no longer supported.

[1] https://github.com/pybluez/pybluez/issues/431

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-18 10:24:05 +09:00
Shin'ichiro Kawasaki
bf154e0a14 .gitignore: add more directories to ignore
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2022-07-17 10:51:32 +09:00
Shin'ichiro Kawasaki
7cc9ccac2e Tag version 0.2.5
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2021-11-21 14:25:56 +09:00
Shin'ichiro Kawasaki
4558ea43df 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-21 14:21:50 +09:00
Shin'ichiro Kawasaki
641b84a86e 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-21 14:21:42 +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
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
Shin'ichiro Kawasaki
923c036cec Tag version 0.2.0
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2020-12-27 21:09:05 +09:00
Shin'ichiro Kawasaki
8a69c2c917 README.md: Add "Issue Reporting" and Releases" sections
Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2020-12-27 20:57:09 +09:00
Shin'ichiro Kawasaki
1e7f81906e BLESession: Refactor cached characteristics handling
To improve readability a bit, introduce _get_characteristic_cached().
Added some failure handlings in the method for robustness.
Also rename _get_all_characteristics() to _cache_characteristics() for
simplification.

Signed-off-by: Shin'ichiro Kawasaki <kawasaki@juno.dti.ne.jp>
2020-12-19 11:43:45 +09:00
sza2
73523120b9 Update scratch_link.py
Retrieve all characteristics value on connect and cache it to speed up characteristics write that was slow as the handle got by _get_characteristic() every time a write occurred.
2020-12-15 14:02:33 +01:00
8 changed files with 714 additions and 352 deletions

6
.gitignore vendored
View File

@@ -1 +1,5 @@
/__pycache__ __pycache__
build
dist
pyscrlink/__pycache__
pyscrlink.egg-info

159
README.md
View File

@@ -9,11 +9,16 @@ micro:bit.
Pyscrlink allows you to connect Scratch and bluetooth devices with the Linux Pyscrlink allows you to connect Scratch and bluetooth devices with the Linux
OSes. It uses the Linux Bluetooth protocol stack [Bluez](http://www.bluez.org/) OSes. It uses the Linux Bluetooth protocol stack [Bluez](http://www.bluez.org/)
and its python interfaces [pybluez](https://github.com/pybluez/pybluez) to and [bluepy](https://github.com/IanHarvey/bluepy) to handle Bluetooth Low Energy
handle Bluetooth, and [bluepy](https://github.com/IanHarvey/bluepy) to handle (BLE) connections. It has been reported that pyscrlink connects Scratch 3.0 with
Bluetooth Low Energy (BLE) connections. It has been reported that pyscrlink micro:bit, LEGO WeDo, LEGO Boost and toio.
connects Scratch 3.0 with micro:bit, LEGO Mindstorms EV3, LEGO WeDo, LEGO
Boost and Intelino Smart Train. Until version v0.2.5, pyscrlink supported Bluetooth Classic protocol using
[pybluez](https://github.com/pybluez/pybluez). Unfortunately, pybluez is not
well maintained and caused technical troubles. Then Bluetooth Classic protocol
support is dropped from pyscrlink. This means that LEGO Mindstorm EV3 can not
be connected with pyscrlink. Bluetooth Classic support is the improvement
opportunity of pyscrlink.
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
@@ -39,25 +44,24 @@ Devices:
Linux distros: Linux distros:
* Arch Linux * Arch Linux
* 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
following devices and Linux distros. following devices and Linux distros.
Devices: Devices:
* 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 * toio by @shimodash
Linux distros: 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
------------ ------------
@@ -73,7 +77,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
``` ```
@@ -86,7 +90,7 @@ Installation
$ pip3 install pyscrlink $ pip3 install pyscrlink
``` ```
4. For Bluetooth Low Energy (BLE) devices, set bluepy-helper capability. 4. Set bluepy-helper capability.
``` ```
$ bluepy_helper_cap $ bluepy_helper_cap
@@ -99,71 +103,24 @@ 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'.
Usage Usage
----- -----
1. For LEGO Mindstorms EV3, pair your Linux PC to the EV3 brick. 1. 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.
If using 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. '1234' is the value Scratch suggests.
* Confirm EV3 status is "Disconnected" in Bluetooth settings
With a Raspberry Pi default Raspbian desktop, click the Bluetooth logo in the top right of the screen and
Add Device. Then follow the Gnome instructions. You will be warned that the Raspberry Pi
does not know how to talk to this device; that is not a problem.
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
```
2. Start scratch-link python script.
```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 micro:bit or LEGO Mindstorms: 2. Connect scratch to the target device such as micro:bit:
* 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.
* Select the extension for your device (e.g., micro:bit or Lego Mindstorms EV3 extension) and follow the prompts to connect. * Select the extension for your device (e.g., micro:bit) and follow the prompts to connect.
* Build your project with the extension blocks. * Build your project with the extension blocks.
In Case You Fail to Connect In Case You Fail to Connect
@@ -184,5 +141,75 @@ In Case You Fail to Connect
* If the service is not working, refer guide of your distro to set it up. * If the service is not working, refer guide of your distro to set it up.
* If the service is working, also check that /etc/bluetooth/main.conf sets AutoEnable=true. * If the service is working, also check that /etc/bluetooth/main.conf sets AutoEnable=true.
3. If scratch_link.py says "failed to connect to BT device: [Errno 13] Permission denied", 3. If device scan still fails, use -r option to retry device scan.
The command line below does device scan twice. Each scan takes 10 seconds.
```
$ scratch_link -r 2
```
It would be good to use -s option together to reduce each scan duration.
The command line below does 3 seconds device scan twice.
```
$ scratch_link -r 2 -s 3
```
4. 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.
5. 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
---------------
Please file issues to [GitHub issue tracker](https://github.com/kawasaki/pyscrlink/issues).
Releases
--------
Release 0.2.8
* Supported Microbit More v2
Release 0.2.7
* Supported Snap Firefox and Chromium
* Added -r option to retry BLE scan
Release 0.2.6
* Removed Bluetooth Classic and LEGO Mindstorm EV3 support
Release 0.2.5
* Fixed handling of multiple UUIDs for LEGO Boost
Release 0.2.4
* Added -s option to specify BLE scan duration
* Improved README.md
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
Release 0.1.0
* Initial release

374
pyscrlink/ble.py Normal file
View File

@@ -0,0 +1,374 @@
from sdbus import DbusInterfaceCommonAsync, SdBus, sd_bus_open_system
from sdbus.dbus_proxy_async_interfaces import DbusIntrospectableAsync
import xml.etree.ElementTree as ET
from sdbus_async.bluez.adapter_api import AdapterInterfaceAsync
from sdbus_async.bluez.device_api import DeviceInterfaceAsync
from sdbus_async.bluez.gatt_api import (
GattCharacteristicInterfaceAsync,
GattServiceInterfaceAsync,
)
import asyncio
import base64
from asyncio import sleep
from os import dup, fdopen, close
import pyscrlink.scratch_link
from pyscrlink.scratch_link import BTUUID
import logging
logger = logging.getLogger('pyscrlink.scratch_link')
class BLEDBusSession(pyscrlink.scratch_link.Session):
"""
Manage a session for Bluetooth Low Energy device such as micro:bit using
DBus as backend.
"""
INITIAL = 1
DISCOVERY = 2
CONNECTED = 3
DONE = 4
MAX_SCANNER_IF = 3
connected_devices = {}
class Device():
def __init__(self, interface, path, node_name, name, address):
self.interface = interface
self.path = path
self.node_name = node_name
self.name = name
self.address = address
class Notification():
def __init__(self, loop, acquired_fd, fd, fp, params):
self.loop = loop
self.acquired_fd = acquired_fd
self.fd = fd
self.fp = fp
self.params = params
def close(self):
self.loop.remove_reader(self.fd)
self.fp.close()
def _connect_to_adapters(self):
self.iface = None
self.adapter = None
self.adapter_introspect = None
adapter = AdapterInterfaceAsync()
for i in range(self.MAX_SCANNER_IF):
iface = '/org/bluez/hci' + str(i)
logger.debug(f"try connect to {iface}")
try:
adapter._connect('org.bluez', iface, bus=self.dbus)
logger.debug(f"connected to {iface}")
adapter_introspect = DbusIntrospectableAsync()
adapter_introspect._connect('org.bluez', iface, bus=self.dbus)
self.iface = iface
self.adapter = adapter
self.adapter_introspect = adapter_introspect
return
except Exception as e:
logger.error(e)
raise Exception("no adapter is available")
async def _start_discovery(self):
logger.debug(f"Starting discovery... {self.adapter}")
assert not self.discovery_running
await self.adapter.start_discovery()
self.discovery_running = True
asyncio.create_task(self._find_devices())
asyncio.create_task(self._stop_discovery())
logger.debug(f"Task to stop discovery has got created.")
async def _matches(self, dev, filters):
"""
Check if the found BLE device matches the filters Scratch specifies.
"""
logger.debug(f"in matches {dev} {filters}")
for f in filters:
if 'services' in f:
for s in f['services']:
logger.debug(f"service to check: {s}")
given_uuid = BTUUID(s)
logger.debug(f"given UUID: {given_uuid} hash={given_uuid.__hash__()}")
dev_uuids = await dev.interface.uuids
if not dev_uuids:
logger.debug(f"dev UUID not available")
continue
for uuid in dev_uuids:
u = BTUUID(uuid)
logger.debug(f"dev UUID: {u} hash={u.__hash__()}")
logger.debug(given_uuid == u)
if given_uuid == u:
logger.debug("match...")
return True
if 'namePrefix' in f:
logger.debug(f"given namePrefix: {f['namePrefix']}")
if dev.name:
logger.debug(f"name: {dev. name}")
if dev.name.startswith(f['namePrefix']):
logger.debug(f"match...")
return True
if 'name' in f or 'manufactureData' in f:
logger.error("name/manufactureData filters not implemented")
# TODO: implement other filters defined:
# ref: https://github.com/LLK/scratch-link/blob/develop/Documentation/BluetoothLE.md
return False
async def _notify_device(self, device) -> None:
params = { 'rssi': -80, 'name': 'Unknown' }
try:
params['rssi'] = await device.interface.rssi
except Exception:
None
if device.name:
params['name'] = device.name
params['peripheralId'] = device.node_name
await self._send_notification('didDiscoverPeripheral', params)
async def _find_devices(self) -> None:
assert self.discovery_running
while self.discovery_running:
await sleep(1)
s = await self.adapter_introspect.dbus_introspect()
parser = ET.fromstring(s)
nodes = parser.findall("./node")
if not nodes:
logger.info("device not found")
continue
logger.debug(f"{len(nodes)} device(s) found")
for node in nodes:
node_name = node.attrib['name']
logger.debug(f" {node_name}")
if self.found_devices.get(node_name):
continue
devpath = self.iface + "/" + node_name
if BLEDBusSession.connected_devices.get(devpath):
continue
interface = DeviceInterfaceAsync()
interface._connect('org.bluez', devpath, bus=self.dbus)
try:
devname = await interface.name
except Exception as e:
logger.debug(f"device {node_name} does not have name: {e}")
devaddr = await interface.address
device = self.Device(interface, devpath, node_name, devname,
devaddr)
if not await self._matches(device, self.discover_filters):
await interface.disconnect()
continue
self.found_devices[node_name] = device
await self._notify_device(device)
logger.debug("end _find_device.")
async def _stop_discovery(self) -> None:
assert self.discovery_running
logger.debug(f"Wait discovery for {self.scan_seconds} seconds")
await sleep(self.scan_seconds)
logger.debug(f"Stopping discovery... {self.adapter}")
self.discovery_running = False
await self.adapter.stop_discovery()
def __init__(self, websocket, loop, scan_seconds):
super().__init__(websocket, loop, scan_seconds)
logger.debug("dbus init")
self.status = self.INITIAL
self.dbus = sd_bus_open_system()
self.discovery_running = False
self.iface = None
self.services = {}
self.chars = {}
self.chars_cache = {}
self.notifications = {}
self._connect_to_adapters()
self.found_devices = {}
async def _get_characteristics(self, service_path):
service_introspect = DbusInterfaceCommonAsync()
service_introspect._connect('org.bluez', service_path, bus=self.dbus)
s = await service_introspect.dbus_introspect()
parser = ET.fromstring(s)
nodes = parser.findall("./node")
if not nodes:
logger.error(f"characteristic not found at {service_path}")
return
for node in nodes:
path = service_path + '/' + node.attrib['name']
if self.chars.get(path):
continue
logger.debug(f"getting GATT characteristic at {path}")
char = GattCharacteristicInterfaceAsync()
char._connect('org.bluez', path, bus=self.dbus)
self.chars[path] = char
cid = await char.uuid
logger.debug(f"found char {cid}")
async def _get_services(self):
# do D-Bus introspect to the device path and get service paths under it
for i in range(5):
dev_introspect = DbusInterfaceCommonAsync()
dev_introspect._connect('org.bluez', self.device.path,
bus=self.dbus)
s = await dev_introspect.dbus_introspect()
parser = ET.fromstring(s)
nodes = parser.findall("./node")
if nodes:
break
else:
logger.error("Service not found. Try again.")
await sleep(1)
if not nodes:
return []
for node in nodes:
path = self.device.path + '/' + node.attrib['name']
if self.services.get(path):
continue
logger.debug(f"getting GATT service at {path}")
service = GattServiceInterfaceAsync()
service._connect('org.bluez', path, bus=self.dbus)
self.services[path] = service
sid = await service.uuid
logger.debug(f"found service {sid}")
await self._get_characteristics(path)
async def _get_char(self, id):
char = self.chars_cache.get(id)
if char:
return char
for i in range(5):
await self._get_services()
btuuid = BTUUID(id)
for char in self.chars.values():
raw_uuid = await char.uuid
char_uuid = BTUUID(raw_uuid)
if char_uuid == btuuid:
self.chars_cache[id] = char
return char
logger.error(f"Can not get characteristic: {id}. Retry.")
logger.error(f"Abandoned to get characteristic: {id}.")
return None
async def _start_notification(self, sid, cid, char):
logger.debug('startNotification')
(acquired_fd, mtu) = await char.acquire_notify({})
fd = dup(acquired_fd)
fp = fdopen(fd, mode='rb', buffering=0, newline=None)
self.loop.add_reader(fd, self._read_notification, fd)
notification = self.Notification(self.loop, acquired_fd, fd, fp, {
'serviceId': sid,
'characteristicId': cid,
'encoding': 'base64'
})
self.notifications[fd] = notification
logger.debug(f'added notification reader: {notification}')
def _stop_notifications(self):
for n in self.notifications.values():
n.close()
def _read_notification(self, *args):
fd = args[0]
notification = self.notifications[fd]
data = notification.fp.read()
if len(data) == 0:
logger.debug(f'empty notification data')
asyncio.create_task(self.async_close())
return
params = notification.params.copy()
params['message'] = base64.standard_b64encode(data).decode('ascii')
self.loop.create_task(self._send_notification('characteristicDidChange', params))
def handle_request(self, method, params):
logger.debug("handle request")
async def async_handle_request(self, method, params):
logger.debug(f"async handle request: {method} {params}")
res = { "jsonrpc": "2.0" }
err_msg = None
if self.status == self.INITIAL and method == 'discover':
self.discover_filters = params['filters']
logger.debug(f"discover: {self.discover_filters}")
try:
await self._start_discovery()
logger.debug(f"discover started: {self.discover_filters}")
res["result"] = None
self.status = self.DISCOVERY
except Exception as e:
res["error"] = { "message": "Failed to start device discovery" }
self.status = self.DONE
elif self.status == self.DISCOVERY and method == 'connect':
logger.debug("connecting to the BLE device")
dev = self.found_devices[params['peripheralId']]
try:
logger.debug(f" {dev}")
await dev.interface.connect()
res["result"] = None
self.device = dev
self.status = self.CONNECTED
logger.info(f"Connected: '{dev.name}'@{dev.address}")
BLEDBusSession.connected_devices[dev.path] = dev
except NotImplementedError as e:
logger.error(e)
res["error"] = { "message": "Failed to connect to device" }
self.status = self.DONE
except Exception as e:
logger.error(f"failed to connect: {e}")
res["error"] = { "message": "Failed to connect to device" }
self.status = self.DONE
elif self.status == self.CONNECTED and method == 'read':
logger.debug("handle read request")
service_id = params['serviceId']
chara_id = params['characteristicId']
c = await self._get_char(chara_id)
value = await c.read_value({})
message = base64.standard_b64encode(value).decode('ascii')
res['result'] = { 'message': message, 'encode': 'base64' }
if params.get('startNotifications') == True:
await self._start_notification(service_id, chara_id, c)
elif self.status == self.CONNECTED and method == 'write':
logger.debug(f"handle write request {params}")
service_id = params['serviceId']
chara_id = params['characteristicId']
c = await self._get_char(chara_id)
if params['encoding'] != 'base64':
logger.error("encoding other than base 64 is not "
"yet supported: ", params['encoding'])
else:
msg_bstr = params['message'].encode('ascii')
data = base64.standard_b64decode(msg_bstr)
await c.write_value(data, {})
res['result'] = len(data)
logger.debug(res)
return res
def end_request(self):
logger.debug("end request")
return False
async def async_close(self):
if not self.device:
return
dev = self.device
logger.info(f"Disconnecting from '{dev.name}'@{dev.address}")
self._stop_notifications()
await dev.interface.disconnect()
BLEDBusSession.connected_devices.pop(dev.path)
logger.info(f"Disconnected from '{dev.name}'@{dev.address}")
self.device = None
await self.websocket.close()
return
def close(self):
logger.debug("close")
return

View File

@@ -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

View File

@@ -152,36 +152,37 @@ def prep_nss_cert(dir, cert, nickname):
remove_cert(dir, nickname) remove_cert(dir, nickname)
add_cert(dir, cert, nickname) add_cert(dir, cert, nickname)
def prep_cert(): def prep_cert_for_app(cert, app, search_path):
# Generate certification and key """
gen_cert(cert_file_path, key_file_path) Find a NSS DB in the search_path for the app and prepare the cert in the DB.
"""
# Add certificate to FireFox
nssdb = None nssdb = None
firefox_nss_path = os.path.join(homedir, ".mozilla/firefox/") for root, dirs, files in os.walk(os.path.join(homedir, search_path)):
for root, dirs, files in os.walk(firefox_nss_path):
for name in files: for name in files:
if not re.match("key.*\.db", name): if not re.match("key.*\.db", name):
continue continue
nssdb = root nssdb = root
if prep_nss_cert(nssdb, cert_file_path, SCRATCH_CERT_NICKNAME): if prep_nss_cert(nssdb, cert, SCRATCH_CERT_NICKNAME):
logger.error(f"Failed to add certificate to FireFox NSS DB: {nssdb}") logger.error(f"Failed to add certificate to {app}: {nssdb}")
sys.exit(3) sys.exit(3)
else: else:
logger.info(f"Certificate is ready in FireFox NSS DB: {nssdb}") logger.info(f"Certificate is ready in {app} NSS DB: {nssdb}")
if not nssdb: if not nssdb:
logger.info("FireFox NSS DB not found. Do not add certificate.") logger.debug(f"NSS DB for {app} not found. Do not add certificate.")
# Add certificate to Chrome
nssdb = os.path.join(homedir, ".pki/nssdb") def prep_cert():
if os.path.isdir(nssdb): # Generate certification and key
if prep_nss_cert(nssdb, cert_file_path, SCRATCH_CERT_NICKNAME): gen_cert(cert_file_path, key_file_path)
logger.error(f"Failed to add certificate to Chrome")
sys.exit(4) nss_dbs = {
else: "FireFox": ".mozilla/firefox/",
logger.info("Certificate is ready for Chrome") "FireFox(Snap)": "snap/firefox/common/.mozilla/firefox/",
else: "Chrome": ".pki",
logger.info("Chrome NSS DB not found. Do not add certificate.") "Chromium(Snap)": "snap/chromium",
}
[ prep_cert_for_app(cert_file_path, k, nss_dbs[k]) for k in nss_dbs ]
if __name__ == "__main__": if __name__ == "__main__":
prep_cert() prep_cert()

View File

@@ -8,32 +8,35 @@ import asyncio
import pathlib import pathlib
import ssl import ssl
import websockets import websockets
import socket
import json import json
import uuid
import base64 import base64
import logging import logging
import sys import sys
import signal import signal
import traceback import traceback
import argparse
# for Bluetooth (e.g. Lego EV3)
import bluetooth
# for BLESession (e.g. BBC micro:bit) # for BLESession (e.g. BBC micro:bit)
from bluepy.btle import Scanner, UUID, Peripheral, DefaultDelegate from bluepy.btle import Scanner, UUID, Peripheral, DefaultDelegate, ScanEntry
from bluepy.btle import BTLEDisconnectError, BTLEManagementError from bluepy.btle import BTLEDisconnectError, BTLEManagementError
from pyscrlink import bluepy_helper_cap from pyscrlink import bluepy_helper_cap
import threading import threading
import time import time
import queue import queue
from asyncio import sleep
# for websockets certificate # for websockets certificate
from pyscrlink import gencert from pyscrlink import gencert
from pyscrlink import ble
logLevel = logging.INFO logLevel = logging.INFO
# for logging # for logging
logger = logging.getLogger(__name__) logger = logging.getLogger('pyscrlink.scratch_link')
formatter = logging.Formatter(fmt='%(asctime)s %(message)s') formatter = logging.Formatter(fmt='%(asctime)s %(message)s')
handler = logging.StreamHandler() handler = logging.StreamHandler()
handler.setLevel(logLevel) handler.setLevel(logLevel)
@@ -42,11 +45,33 @@ logger.setLevel(logLevel)
logger.addHandler(handler) logger.addHandler(handler)
logger.propagate = False logger.propagate = False
HOSTNAME="device-manager.scratch.mit.edu"
scan_seconds=10.0
class BTUUID(uuid.UUID):
BLUETOOTH_BASE_UUID = "00001000800000805F9B34FB"
def __init__(self, val):
if isinstance(val, int):
if (val < 0) or (val > 0xFFFFFFFF):
raise ValueError(
"Short form UUIDs must be in range 0..0xFFFFFFFF")
val = "%04X" % val
else:
val = str(val)
val = val.replace("-", "")
if len(val) <= 8: # Short form
val = ("0" * (8 - len(val))) + val + self.BLUETOOTH_BASE_UUID
uuid.UUID.__init__(self, val)
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, scan_seconds):
self.websocket = websocket self.websocket = websocket
self.loop = loop self.loop = loop
self.scan_seconds = scan_seconds
self.lock = threading.RLock() self.lock = threading.RLock()
self.notification_queue = queue.Queue() self.notification_queue = queue.Queue()
@@ -65,7 +90,11 @@ class Session():
if jsonreq['jsonrpc'] != '2.0': if jsonreq['jsonrpc'] != '2.0':
logger.error("error: jsonrpc version is not 2.0") logger.error("error: jsonrpc version is not 2.0")
return True return True
jsonres = self.handle_request(jsonreq['method'], jsonreq['params']) if type(self) is ble.BLEDBusSession:
jsonres = await self.async_handle_request(jsonreq['method'],
jsonreq['params'])
else:
jsonres = self.handle_request(jsonreq['method'], jsonreq['params'])
if 'id' in jsonreq: if 'id' in jsonreq:
jsonres['id'] = jsonreq['id'] jsonres['id'] = jsonreq['id']
response = json.dumps(jsonres) response = json.dumps(jsonres)
@@ -79,6 +108,10 @@ class Session():
"""Default request handler""" """Default request handler"""
logger.debug(f"default handle_request: {method}, {params}") logger.debug(f"default handle_request: {method}, {params}")
async def async_handle_request(self, method, params):
"""Default async request handler"""
logger.debug(f"default async handle_request: {method}, {params}")
def end_request(self): def end_request(self):
""" """
Default callback at request end. This callback is required to Default callback at request end. This callback is required to
@@ -118,11 +151,17 @@ class Session():
break break
await self._send_notifications() await self._send_notifications()
logger.debug("in handle loop") logger.debug("in handle loop")
except websockets.ConnectionClosedError as e: except (websockets.ConnectionClosedOK, websockets.ConnectionClosedError) as e:
logger.info("scratch closed session") logger.info("scratch closed session")
logger.debug(e) logger.debug(e)
self.close() if type(self) is ble.BLEDBusSession:
await self.async_close()
else:
self.close()
break break
except Exception as e:
t = type(e)
logger.info(f"scratch closed with unkown exception: {e}: {t}")
def close(self): def close(self):
""" """
@@ -133,198 +172,10 @@ class Session():
class BTSession(Session): class BTSession(Session):
"""Manage a session for Bluetooth device""" """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("<H", header)
msg_data = sock.recv(msg_len)
data = header + msg_data
params = {'message': base64.standard_b64encode(data).decode('utf-8'), "encoding": "base64"}
self.session.notify('didReceiveMessage', params)
self.ping_time = current_time + 5
except Exception as e:
logger.error(e)
self.session.close()
break
# To avoid repeated lock by this single thread,
# yield CPU to other lock waiting threads.
time.sleep(0)
else:
# Nothing to do:
time.sleep(1)
# Terminate if we have lost websocket connection to Scratch (e.g. browser closed)
if self.ping_time is None or self.ping_time <= current_time:
try:
self.session.notify('ping', {})
self.ping_time = current_time + 5
except Exception as e:
logger.error(e)
self.session.close()
break
def __init__(self, websocket, loop):
super().__init__(websocket, loop)
self.status = self.INITIAL
self.sock = None
self.bt_thread = None
def close(self):
self.status = self.DONE
if self.sock:
logger.info(f"disconnect to BT socket: {self.sock}")
self.sock.close()
self.sock = None
def __del__(self):
self.close()
def handle_request(self, method, params):
"""Handle requests from Scratch"""
logger.debug("handle request to BT device")
logger.debug(method)
if len(params) > 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
class BLESession(Session): class BLESession(Session):
""" """
Manage a session for Bluetooth Low Energy device such as micro:bit Manage a session for Bluetooth Low Energy device such as micro:bit using
bluepy as backend.
""" """
INITIAL = 1 INITIAL = 1
@@ -343,6 +194,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 +214,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)
@@ -367,6 +223,7 @@ class BLESession(Session):
time.sleep(1) time.sleep(1)
elif self.session.status == self.session.CONNECTED: elif self.session.status == self.session.CONNECTED:
logger.debug("in connected status:") logger.debug("in connected status:")
delegate = self.session.delegate delegate = self.session.delegate
if delegate and len(delegate.handles) > 0: if delegate and len(delegate.handles) > 0:
if not delegate.restart_notification_event.is_set(): if not delegate.restart_notification_event.is_set():
@@ -415,16 +272,23 @@ class BLESession(Session):
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)
def __init__(self, websocket, loop): def __init__(self, websocket, loop, scan_seconds):
super().__init__(websocket, loop) super().__init__(websocket, loop, scan_seconds)
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
self.delegate = None self.delegate = None
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: "
@@ -436,49 +300,87 @@ 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):
""" """
Check if the found BLE device matches the filters Scratch specifies. Check if the found BLE device matches the filters Scratch specifies.
""" """
logger.debug(f"in matches {dev.addr} {filters}") logger.debug(f"in matches {dev.address} {filters}")
for f in filters: for f in filters:
if 'services' in f: if 'services' in f:
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 logger.debug(f"given namePrefix: {f['namePrefix']}")
deviceName = dev.getValueText(0x08) deviceName = dev.getValueText(ScanEntry.SHORT_LOCAL_NAME)
if not deviceName: if deviceName:
continue logger.debug(f"SHORT_LOCAL_NAME: {deviceName}")
logger.debug(f"Name of \"{deviceName}\" begins with: \"{f['namePrefix']}\"?") if deviceName.startswith(f['namePrefix']):
if(deviceName.startswith(f['namePrefix'])): logger.debug(f"match...")
logger.debug("Yes") return True
return True deviceName = dev.getValueText(ScanEntry.COMPLETE_LOCAL_NAME)
logger.debug("No") if deviceName:
logger.debug(f"COMPLETE_LOCAL_NAME: {deviceName}")
if deviceName.startswith(f['namePrefix']):
logger.debug(f"match...")
return True
if 'name' in f or 'manufactureData' in f: if 'name' in f or 'manufactureData' in f:
logger.error("name/manufactureData filters not implemented") logger.error("name/manufactureData filters not implemented")
# TODO: implement other filters defined: # TODO: implement other filters defined:
# 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)
for j in range(scan_retry):
try:
logger.debug(f"start BLE scan: {self.scan_seconds} seconds")
devices = scanner.scan(self.scan_seconds)
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}");
if found:
break
except BTLEDisconnectError as de:
logger.debug(f"BLE iface #{i}: {de}");
except BTLEManagementError as me:
logger.debug(f"BLE iface #{i}: {me}");
if found:
break
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))
@@ -490,6 +392,25 @@ class BLESession(Session):
charas = self.perip.getCharacteristics(uuid=chara_id) charas = self.perip.getCharacteristics(uuid=chara_id)
return charas[0] return charas[0]
def _cache_characteristics(self):
if not self.perip:
return
with self.lock:
self.characteristics_cache = self.perip.getCharacteristics()
if not self.characteristics_cache:
logger.debug("Characteristics are not cached")
def _get_characteristic_cached(self, chara_id):
if not self.perip:
return None
if not self.characteristics_cache:
self._cache_characteristics()
if self.characteristics_cache:
for characteristic in self.characteristics_cache:
if characteristic.uuid == chara_id:
return characteristic
return _get_characteristic(chara_id)
def handle_request(self, method, params): def handle_request(self, method, params):
"""Handle requests from Scratch""" """Handle requests from Scratch"""
if self.delegate: if self.delegate:
@@ -508,29 +429,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: err_msg = "Can not scan BLE devices. Check BLE controller."
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."
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 }
@@ -544,11 +457,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
@@ -556,8 +470,11 @@ 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()
else: else:
err_msg = f"BLE connect failed: {self.deviceName}" err_msg = f"BLE connect failed: {self.deviceName}"
res["error"] = { "message": err_msg } res["error"] = { "message": err_msg }
@@ -595,7 +512,7 @@ class BLESession(Session):
logger.debug("handle write request") logger.debug("handle write request")
service_id = params['serviceId'] service_id = params['serviceId']
chara_id = params['characteristicId'] chara_id = params['characteristicId']
c = self._get_characteristic(chara_id) c = self._get_characteristic_cached(chara_id)
if not c or c.uuid != UUID(chara_id): if not c or c.uuid != UUID(chara_id):
logger.error(f"Failed to get characteristic {chara_id}") logger.error(f"Failed to get characteristic {chara_id}")
self.status = self.DONE self.status = self.DONE
@@ -639,15 +556,18 @@ class BLESession(Session):
return self.status == self.DONE return self.status == self.DONE
async def ws_handler(websocket, path): async def ws_handler(websocket, path):
sessionTypes = { '/scratch/ble': BLESession, '/scratch/bt': BTSession } global scan_seconds
sessionTypes = { '/scratch/bt': BTSession }
sessionTypes['/scratch/ble'] = ble.BLEDBusSession if dbus else BLESession
try: 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() loop = asyncio.get_event_loop()
session = sessionTypes[path](websocket, loop) session = sessionTypes[path](websocket, loop, scan_seconds)
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")
@@ -664,18 +584,29 @@ 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: global scan_retry
print((f"Usage: {sys.argv[0]} [OPTS]\n" global dbus
"OPTS:\t-h Show this help.\n" parser = argparse.ArgumentParser(description='start Scratch-link')
"\t-d Print debug messages." parser.add_argument('-d', '--debug', action='store_true',
)) help='print debug messages')
sys.exit(1) parser.add_argument('-s', '--scan_seconds', type=float, default=10.0,
elif "-d" in opts: help='specifiy duration to scan BLE devices in seconds')
parser.add_argument('-r', '--scan_retry', type=int, default=1,
help='specifiy retry times to scan BLE devices')
parser.add_argument('-b', '--dbus', action='store_true',
help='use DBus backend for BLE devices')
args = parser.parse_args()
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
scan_retry = args.scan_retry
dbus = args.dbus
logger.debug(f"set scan_seconds: {scan_seconds}")
logger.debug(f"set scan_retry: {scan_retry}")
# Prepare certificate of the WSS server # Prepare certificate of the WSS server
gencert.prep_cert() gencert.prep_cert()
@@ -687,7 +618,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:
@@ -698,7 +629,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__":

View File

@@ -4,13 +4,17 @@ usage() {
echo "Usage: ${0} COMMAND" echo "Usage: ${0} COMMAND"
echo -e "COMMAND:" echo -e "COMMAND:"
echo -e "\tbuild" echo -e "\tbuild"
echo -e "\tupload FILES"
echo -e "\tupload-testpypi FILES" echo -e "\tupload-testpypi FILES"
echo -e "examples:"
echo -e "\t${0} build"
echo -e "\t${0} upload dist/*0.2.6*"
exit 1 exit 1
} }
case ${1} in case ${1} in
build) build)
python setup.py sdist bdist_wheel python -m build
;; ;;
upload) upload)
shift shift

View File

@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
setuptools.setup( setuptools.setup(
name="pyscrlink", name="pyscrlink",
version="0.1.0", version="0.2.8",
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',
@@ -22,7 +22,6 @@ setuptools.setup(
install_requires=[ install_requires=[
'websockets', 'websockets',
'bluepy', 'bluepy',
'pybluez',
'pyOpenSSL', 'pyOpenSSL',
], ],
entry_points={ entry_points={