Bluetooth Low Energy Scanner Example#
A Python application that demonstrates the analogous example in Qt `Bluetooth Low Energy Scanner https://doc.qt.io/qt-6/qtbluetooth-lowenergyscanner-example.html`_

# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the bluetooth/lowenergyscanner example from Qt v6.x"""
import sys
import os
from PySide6.QtCore import QUrl
from PySide6.QtGui import QGuiApplication
from PySide6.QtQuick import QQuickView
from device import Device
from pathlib import Path
import rc_resources
if __name__ == '__main__':
app = QGuiApplication(sys.argv)
d = Device()
view = QQuickView()
view.rootContext().setContextProperty("device", d)
src_dir = Path(__file__).resolve().parent
view.engine().addImportPath(os.fspath(src_dir))
view.engine().quit.connect(view.close)
view.setSource(QUrl.fromLocalFile(":/assets/main.qml"))
view.setResizeMode(QQuickView.SizeRootObjectToView)
view.show()
res = app.exec()
del view
sys.exit(res)
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import warnings
from PySide6.QtBluetooth import (QBluetoothDeviceDiscoveryAgent, QLowEnergyController,
QBluetoothDeviceInfo, QBluetoothUuid, QLowEnergyService)
from PySide6.QtCore import QObject, Property, Signal, Slot, QTimer, QMetaObject, Qt
from deviceinfo import DeviceInfo
from serviceinfo import ServiceInfo
from characteristicinfo import CharacteristicInfo
class Device(QObject):
devices_updated = Signal()
services_updated = Signal()
characteristic_updated = Signal()
update_changed = Signal()
state_changed = Signal()
disconnected = Signal()
random_address_changed = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.devices = []
self._services = []
self._characteristics = []
self._previousAddress = ""
self._message = ""
self.currentDevice = DeviceInfo()
self.connected = False
self.controller: QLowEnergyController = None
self._deviceScanState = False
self.random_address = False
self.discovery_agent = QBluetoothDeviceDiscoveryAgent()
self.discovery_agent.setLowEnergyDiscoveryTimeout(25000)
self.discovery_agent.deviceDiscovered.connect(self.add_device)
self.discovery_agent.errorOccurred.connect(self.device_scan_error)
self.discovery_agent.finished.connect(self.device_scan_finished)
self.update = "Search"
@Property("QVariant", notify=devices_updated)
def devices_list(self):
return self.devices
@Property("QVariant", notify=services_updated)
def services_list(self):
return self._services
@Property("QVariant", notify=characteristic_updated)
def characteristic_list(self):
return self._characteristics
@Property(str, notify=update_changed)
def update(self):
return self._message
@update.setter
def update(self, message):
self._message = message
self.update_changed.emit()
@Property(bool, notify=random_address_changed)
def use_random_address(self):
return self.random_address
@use_random_address.setter
def use_random_address(self, newValue):
self.random_address = newValue
self.random_address_changed.emit()
@Property(bool, notify=state_changed)
def state(self):
return self._deviceScanState
@Property(bool)
def controller_error(self):
return self.controller and (self.controller.error() != QLowEnergyController.NoError)
@Slot()
def start_device_discovery(self):
self.devices.clear()
self.devices_updated.emit()
self.update = "Scanning for devices ..."
self.discovery_agent.start(QBluetoothDeviceDiscoveryAgent.LowEnergyMethod)
if self.discovery_agent.isActive():
self._deviceScanState = True
self.state_changed.emit()
@Slot(str)
def scan_services(self, address):
# We need the current device for service discovery.
for device in self.devices:
if device.device_address == address:
self.currentDevice.set_device(device.get_device())
break
if not self.currentDevice.get_device().isValid():
warnings.warn("Not a valid device")
return
self._characteristics.clear()
self.characteristic_updated.emit()
self._services.clear()
self.services_updated.emit()
self.update = "Back\n(Connecting to device...)"
if self.controller and (self._previousAddress != self.currentDevice.device_address):
self.controller.disconnect_from_device()
del self.controller
self.controller = None
if not self.controller:
self.controller = QLowEnergyController.createCentral(self.currentDevice.get_device())
self.controller.connected.connect(self.device_connected)
self.controller.errorOccurred.connect(self.error_received)
self.controller.disconnected.connect(self.device_disconnected)
self.controller.serviceDiscovered.connect(self.add_low_energy_service)
self.controller.discoveryFinished.connect(self.services_scan_done)
if self.random_address:
self.controller.setRemoteAddressType(QLowEnergyController.RandomAddress)
else:
self.controller.setRemoteAddressType(QLowEnergyController.PublicAddress)
self.controller.connectToDevice()
self._previousAddress = self.currentDevice.device_address
@Slot(str)
def connect_to_service(self, uuid):
service: QLowEnergyService = None
for serviceInfo in self._services:
if not serviceInfo:
continue
if serviceInfo.service_uuid == uuid:
service = serviceInfo.service
break
if not service:
return
self._characteristics.clear()
self.characteristic_updated.emit()
if service.state() == QLowEnergyService.RemoteService:
service.state_changed.connect(self.service_details_discovered)
service.discoverDetails()
self.update = "Back\n(Discovering details...)"
return
# discovery already done
chars = service.characteristics()
for ch in chars:
cInfo = CharacteristicInfo(ch)
self._characteristics.append(cInfo)
QTimer.singleShot(0, self.characteristic_updated)
@Slot()
def disconnect_from_device(self):
# UI always expects disconnect() signal when calling this signal
# TODO what is really needed is to extend state() to a multi value
# and thus allowing UI to keep track of controller progress in addition to
# device scan progress
if self.controller.state() != QLowEnergyController.UnconnectedState:
self.controller.disconnect_from_device()
else:
self.device_disconnected()
@Slot(QBluetoothDeviceInfo)
def add_device(self, info):
if info.coreConfigurations() & QBluetoothDeviceInfo.LowEnergyCoreConfiguration:
self.update = "Last device added: " + info.name()
@Slot()
def device_scan_finished(self):
foundDevices = self.discovery_agent.discoveredDevices()
for nextDevice in foundDevices:
if nextDevice.coreConfigurations() & QBluetoothDeviceInfo.LowEnergyCoreConfiguration:
device = DeviceInfo(nextDevice)
self.devices.append(device)
self.devices_updated.emit()
self._deviceScanState = False
self.state_changed.emit()
if not self.devices:
self.update = "No Low Energy devices found..."
else:
self.update = "Done! Scan Again!"
@Slot("QBluetoothDeviceDiscovertAgent::Error")
def device_scan_error(self, error):
if error == QBluetoothDeviceDiscoveryAgent.PoweredOffError:
self.update = (
"The Bluetooth adaptor is powered off, power it on before doing discovery."
)
elif error == QBluetoothDeviceDiscoveryAgent.InputOutputError:
self.update = "Writing or reading from the device resulted in an error."
else:
qme = self.discovery_agent.metaObject().enumerator(
self.discovery_agent.metaObject().indexOfEnumerator("Error")
)
self.update = f"Error: {qme.valueToKey(error)}"
self._deviceScanState = False
self.devices_updated.emit()
self.state_changed.emit()
@Slot(QBluetoothUuid)
def add_low_energy_service(self, service_uuid):
service = self.controller.createServiceObject(service_uuid)
if not service:
warnings.warn("Cannot create service from uuid")
return
serv = ServiceInfo(service)
self._services.append(serv)
self.services_updated.emit()
@Slot()
def device_connected(self):
self.update = "Back\n(Discovering services...)"
self.connected = True
self.controller.discoverServices()
@Slot("QLowEnergyController::Error")
def error_received(self, error):
warnings.warn(f"Error: {self.controller.errorString()}")
self.update = f"Back\n({self.controller.errorString()})"
@Slot()
def services_scan_done(self):
self.update = "Back\n(Service scan done!)"
# force UI in case we didn't find anything
if not self._services:
self.services_updated.emit()
@Slot()
def device_disconnected(self):
warnings.warn("Disconnect from Device")
self.disconnected.emit()
@Slot("QLowEnergyService::ServiceState")
def service_details_discovered(self, newState):
if newState != QLowEnergyService.RemoteServiceDiscovered:
# do not hang in "Scanning for characteristics" mode forever
# in case the service discovery failed
# We have to queue the signal up to give UI time to even enter
# the above mode
if newState != QLowEnergyService.RemoteServiceDiscovering:
QMetaObject.invokeMethod(self.characteristic_updated, Qt.QueuedConnection)
return
service = self.sender()
if not service:
return
chars = service.characteristics()
for ch in chars:
cInfo = CharacteristicInfo(ch)
self._characteristics.append(cInfo)
self.characteristic_updated.emit()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import sys
from PySide6.QtCore import QObject, Property, Signal
from PySide6.QtBluetooth import QBluetoothDeviceInfo
class DeviceInfo(QObject):
device_changed = Signal()
def __init__(self, d: QBluetoothDeviceInfo = None) -> None:
super().__init__()
self._device = d
@Property(str, notify=device_changed)
def device_name(self):
return self._device.name()
@Property(str, notify=device_changed)
def device_address(self):
if sys.platform == "darwin":
return self._device.deviceUuid().toString()
return self._device.address().toString()
def get_device(self):
return self._device
def set_device(self, device):
self._device = device
self.device_changed.emit()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtCore import QObject, Property, Signal
from PySide6.QtBluetooth import QLowEnergyService
class ServiceInfo(QObject):
service_changed = Signal()
def __init__(self, service: QLowEnergyService) -> None:
super().__init__()
self._service = service
self.service.setParent(self)
@Property(str, notify=service_changed)
def service_name(self):
if not self.service:
return ""
return self.service.service_name()
@Property(str, notify=service_changed)
def service_type(self):
if not self.service:
return ""
result = ""
if (self.service.type() & QLowEnergyService.PrimaryService):
result += "primary"
else:
result += "secondary"
if (self.service.type() & QLowEnergyService.IncludedService):
result += " included"
result = '<' + result + '>'
return result
@Property(str, notify=service_changed)
def service_uuid(self):
if not self.service:
return ""
uuid = self.service.service_uuid()
result16, success16 = uuid.toUInt16()
if success16:
return f"0x{result16:x}"
result32, sucesss32 = uuid.toUInt32()
if sucesss32:
return f"0x{result32:x}"
return uuid.toString().replace('{', '').replace('}', '')
@property
def service(self):
return self._service
@service.setter
def service(self, service):
self._service = service
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtCore import QObject, Property, Signal
from PySide6.QtBluetooth import QLowEnergyCharacteristic, QBluetoothUuid
class CharacteristicInfo(QObject):
characteristic_changed = Signal()
def __init__(self, characteristic=None) -> None:
super().__init__()
self._characteristic = characteristic
@Property(str, notify=characteristic_changed)
def characteristic_name(self):
if not self.characteristic:
raise Exception("characteristic unset")
name = self.characteristic.name()
if name:
return name
for descriptor in self.characteristic.descriptors():
if descriptor.type() == QBluetoothUuid.DescriptorType.CharacteristicUserDescription:
name = descriptor.value()
break
if not name:
name = "Unknown"
return name
@Property(str, notify=characteristic_changed)
def characteristic_uuid(self):
uuid = self.characteristic.uuid()
result16, success16 = uuid.toUInt16()
if success16:
return f"0x{result16:x}"
result32, sucess32 = uuid.toUInt32()
if sucess32:
return f"0x{result32:x}"
return uuid.toString().replace('{', '').replace('}', '')
@Property(str, notify=characteristic_changed)
def characteristic_value(self):
# Show raw string first and hex value below
a = self.characteristic.value()
if not a:
return "<none>"
result = f"{str(a)}\n{str(a.toHex())}"
return result
@Property(str, notify=characteristic_changed)
def characteristic_permission(self):
properties = "( "
permission = self.characteristic.properties()
if (permission & QLowEnergyCharacteristic.Read):
properties += " Read"
if (permission & QLowEnergyCharacteristic.Write):
properties += " Write"
if (permission & QLowEnergyCharacteristic.Notify):
properties += " Notify"
if (permission & QLowEnergyCharacteristic.Indicate):
properties += " Indicate"
if (permission & QLowEnergyCharacteristic.ExtendedProperty):
properties += " ExtendedProperty"
if (permission & QLowEnergyCharacteristic.Broadcasting):
properties += " Broadcast"
if (permission & QLowEnergyCharacteristic.WriteNoResponse):
properties += " WriteNoResp"
if (permission & QLowEnergyCharacteristic.WriteSigned):
properties += " WriteSigned"
properties += " )"
return properties
@property
def characteristic(self):
return self._characteristic
@characteristic.setter
def characteristic(self, characteristic):
self._characteristic = characteristic
self.characteristic_changed.emit()
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Rectangle {
id: back
width: 300
height: 600
property bool deviceState: device.state
onDevicestate_changed: {
if (!device.state)
info.visible = false;
}
Header {
id: header
anchors.top: parent.top
headerText: "Start Discovery"
}
Dialog {
id: info
anchors.centerIn: parent
visible: false
}
ListView {
id: theListView
width: parent.width
clip: true
anchors.top: header.bottom
anchors.bottom: connectToggle.top
model: device.devices_list
delegate: Rectangle {
id: box
height:100
width: theListView.width
color: "lightsteelblue"
border.width: 2
border.color: "black"
radius: 5
Component.onCompleted: {
info.visible = false;
header.headerText = "Select a device";
}
MouseArea {
anchors.fill: parent
onClicked: {
device.scan_services(modelData.device_address);
pageLoader.source = "qrc:/assets/Services.qml"
}
}
Label {
id: device_name
textContent: modelData.device_name
anchors.top: parent.top
anchors.topMargin: 5
}
Label {
id: device_address
textContent: modelData.device_address
font.pointSize: device_name.font.pointSize*0.7
anchors.bottom: box.bottom
anchors.bottomMargin: 5
}
}
}
Menu {
id: connectToggle
menuWidth: parent.width
anchors.bottom: menu.top
menuText: { if (device.devices_list.length)
visible = true
else
visible = false
if (device.use_random_address)
"Address type: Random"
else
"Address type: Public"
}
onButtonClick: device.use_random_address = !device.use_random_address;
}
Menu {
id: menu
anchors.bottom: parent.bottom
menuWidth: parent.width
menuHeight: (parent.height/6)
menuText: device.update
onButtonClick: {
device.start_device_discovery();
// if start_device_discovery() failed device.state is not set
if (device.state) {
info.dialogText = "Searching...";
info.visible = true;
}
}
}
Loader {
id: pageLoader
anchors.fill: parent
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick 2.0
Rectangle {
property real menuWidth: 100
property real menuHeight: 50
property string menuText: "Search"
signal buttonClick()
height: menuHeight
width: menuWidth
Rectangle {
id: search
width: parent.width
height: parent.height
anchors.centerIn: parent
color: "#363636"
border.width: 1
border.color: "#E3E3E3"
radius: 5
Text {
id: searchText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.fill: parent
text: menuText
elide: Text.ElideMiddle
color: "#E3E3E3"
wrapMode: Text.WordWrap
}
MouseArea {
anchors.fill: parent
onPressed: {
search.width = search.width - 7
search.height = search.height - 5
}
onReleased: {
search.width = search.width + 7
search.height = search.height + 5
}
onClicked: {
buttonClick()
}
}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick 2.0
Rectangle {
width: parent.width
height: 70
border.width: 1
border.color: "#363636"
radius: 5
property string headerText: ""
Text {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.fill: parent
text: headerText
font.bold: true
font.pointSize: 20
elide: Text.ElideMiddle
color: "#363636"
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick 2.0
Rectangle {
width: 300
height: 600
Header {
id: header
anchors.top: parent.top
headerText: "Characteristics list"
}
Dialog {
id: info
anchors.centerIn: parent
visible: true
dialogText: "Scanning for characteristics...";
}
Connections {
target: device
function oncharacteristic_updated() {
menu.menuText = "Back"
if (characteristicview.count === 0) {
info.dialogText = "No characteristic found"
info.busyImage = false
} else {
info.visible = false
info.busyImage = true
}
}
function onDisconnected() {
pageLoader.source = "qrc:/assets/main.qml"
}
}
ListView {
id: characteristicview
width: parent.width
clip: true
anchors.top: header.bottom
anchors.bottom: menu.top
model: device.characteristic_list
delegate: Rectangle {
id: characteristicbox
height:300
width: characteristicview.width
color: "lightsteelblue"
border.width: 2
border.color: "black"
radius: 5
Label {
id: characteristic_name
textContent: modelData.characteristic_name
anchors.top: parent.top
anchors.topMargin: 5
}
Label {
id: characteristic_uuid
font.pointSize: characteristic_name.font.pointSize*0.7
textContent: modelData.characteristic_uuid
anchors.top: characteristic_name.bottom
anchors.topMargin: 5
}
Label {
id: characteristic_value
font.pointSize: characteristic_name.font.pointSize*0.7
textContent: ("Value: " + modelData.characteristic_value)
anchors.bottom: characteristicHandle.top
horizontalAlignment: Text.AlignHCenter
anchors.topMargin: 5
}
Label {
id: characteristicHandle
font.pointSize: characteristic_name.font.pointSize*0.7
textContent: ("Handlers: " + modelData.characteristicHandle)
anchors.bottom: characteristic_permission.top
anchors.topMargin: 5
}
Label {
id: characteristic_permission
font.pointSize: characteristic_name.font.pointSize*0.7
textContent: modelData.characteristic_permission
anchors.bottom: parent.bottom
anchors.topMargin: 5
anchors.bottomMargin: 5
}
}
}
Menu {
id: menu
anchors.bottom: parent.bottom
menuWidth: parent.width
menuText: device.update
menuHeight: (parent.height/6)
onButtonClick: {
pageLoader.source = "qrc:/assets/Services.qml"
device.update = "Back"
}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick 2.0
Rectangle {
width: parent.width/3*2
height: dialogTextId.height + background.height + 20
z: 50
property string dialogText: ""
property bool busyImage: true
border.width: 1
border.color: "#363636"
radius: 10
Text {
id: dialogTextId
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top;
anchors.topMargin: 10
elide: Text.ElideMiddle
text: dialogText
color: "#363636"
wrapMode: Text.Wrap
}
Image {
id: background
width:20
height:20
anchors.top: dialogTextId.bottom
anchors.horizontalCenter: dialogTextId.horizontalCenter
visible: parent.busyImage
source: "qrc:/assets/busy_dark.png"
fillMode: Image.PreserveAspectFit
NumberAnimation on rotation { duration: 3000; from:0; to: 360; loops: Animation.Infinite}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick 2.0
Rectangle {
width: 300
height: 600
Component.onCompleted: {
// Loading this page may take longer than QLEController
// stopping with an error, go back and readjust this view
// based on controller errors
if (device.controller_error) {
info.visible = false;
menu.menuText = device.update
}
}
Header {
id: header
anchors.top: parent.top
headerText: "Services list"
}
Dialog {
id: info
anchors.centerIn: parent
visible: true
dialogText: "Scanning for services...";
}
Connections {
target: device
function onservices_updated() {
if (servicesview.count === 0)
info.dialogText = "No services found"
else
info.visible = false;
}
function onDisconnected() {
pageLoader.source = "qrc:/assets/main.qml"
}
}
ListView {
id: servicesview
width: parent.width
anchors.top: header.bottom
anchors.bottom: menu.top
model: device.services_list
clip: true
delegate: Rectangle {
id: servicebox
height:100
color: "lightsteelblue"
border.width: 2
border.color: "black"
radius: 5
width: servicesview.width
Component.onCompleted: {
info.visible = false
}
MouseArea {
anchors.fill: parent
onClicked: {
pageLoader.source = "qrc:/assets/Characteristics.qml";
device.connect_to_service(modelData.service_uuid);
}
}
Label {
id: service_name
textContent: modelData.service_name
anchors.top: parent.top
anchors.topMargin: 5
}
Label {
textContent: modelData.service_type
font.pointSize: service_name.font.pointSize * 0.5
anchors.top: service_name.bottom
}
Label {
id: service_uuid
font.pointSize: service_name.font.pointSize * 0.5
textContent: modelData.service_uuid
anchors.bottom: servicebox.bottom
anchors.bottomMargin: 5
}
}
}
Menu {
id: menu
anchors.bottom: parent.bottom
menuWidth: parent.width
menuText: device.update
menuHeight: (parent.height/6)
onButtonClick: {
device.disconnect_from_device()
pageLoader.source = "qrc:/assets/main.qml"
device.update = "Search"
}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick 2.0
Text {
property string textContent: ""
font.pointSize: 20
anchors.horizontalCenter: parent.horizontalCenter
color: "#363636"
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideMiddle
width: parent.width
wrapMode: Text.Wrap
text: textContent
}
<RCC>
<qresource>
<file>assets/Characteristics.qml</file>
<file>assets/main.qml</file>
<file>assets/Menu.qml</file>
<file>assets/Services.qml</file>
<file>assets/Header.qml</file>
<file>assets/Dialog.qml</file>
<file>assets/Label.qml</file>
<file>assets/busy_dark.png</file>
</qresource>
</RCC>