Bluetooth Low Energy Scanner Example#
A Python application that demonstrates the analogous example in Qt Bluetooth Low Energy Scanner
# 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
from PySide6.QtCore import QCoreApplication
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
from device import Device
from pathlib import Path
if __name__ == '__main__':
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("Scanner", "Main")
if not engine.rootObjects():
sys.exit(-1)
ex = QCoreApplication.exec()
del engine
sys.exit(ex)
# 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 PySide6.QtQml import QmlElement, QmlSingleton
from deviceinfo import DeviceInfo
from serviceinfo import ServiceInfo
from characteristicinfo import CharacteristicInfo
QML_IMPORT_NAME = "Scanner"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
@QmlSingleton
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.disconnectFromDevice()
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.disconnectFromDevice()
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()
@Slot()
def stop_device_discovery(self):
if self.discovery_agent.isActive():
self.discovery_agent.stop()
# 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) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick.Layouts
Window {
id: main
width: 300
height: 600
visible: true
StackLayout {
id: pagesLayout
anchors.fill: parent
currentIndex: 0
Devices {
onShowServices: pagesLayout.currentIndex = 1
}
Services {
onShowDevices: pagesLayout.currentIndex = 0
onShowCharacteristics: pagesLayout.currentIndex = 2
}
Characteristics {
onShowDevices: pagesLayout.currentIndex = 0
onShowServices: pagesLayout.currentIndex = 1
}
}
}
// 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: menu
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: menu.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: {
menu.buttonClick()
}
}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Rectangle {
id: header
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: header.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
pragma ComponentBehavior: Bound
import QtQuick
Rectangle {
id: characteristicsPage
signal showServices
signal showDevices
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 oncharacteristics_pdated() {
menu.menuText = "Back"
if (characteristicview.count === 0) {
info.dialogText = "No characteristic found"
info.busyImage = false
} else {
info.visible = false
info.busyImage = true
}
}
function onDisconnected() {
characteristicsPage.showDevices()
}
}
ListView {
id: characteristicview
width: parent.width
clip: true
anchors.top: header.bottom
anchors.bottom: menu.top
model: Device.characteristicList
delegate: Rectangle {
required property var modelData
id: box
height: 300
width: characteristicview.width
color: "lightsteelblue"
border.width: 2
border.color: "black"
radius: 5
Label {
id: characteristicName
textContent: box.modelData.characteristic_name
anchors.top: parent.top
anchors.topMargin: 5
}
Label {
id: characteristicUuid
font.pointSize: characteristicName.font.pointSize * 0.7
textContent: box.modelData.characteristic_uuid
anchors.top: characteristicName.bottom
anchors.topMargin: 5
}
Label {
id: characteristicValue
font.pointSize: characteristicName.font.pointSize * 0.7
textContent: ("Value: " + box.modelData.characteristic_value)
anchors.bottom: characteristicHandle.top
horizontalAlignment: Text.AlignHCenter
anchors.topMargin: 5
}
Label {
id: characteristicHandle
font.pointSize: characteristicName.font.pointSize * 0.7
textContent: ("Handlers: " + box.modelData.characteristic_handle)
anchors.bottom: characteristicPermission.top
anchors.topMargin: 5
}
Label {
id: characteristicPermission
font.pointSize: characteristicName.font.pointSize * 0.7
textContent: box.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: {
characteristicsPage.showServices()
Device.update = "Back"
}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Rectangle {
id: dialog
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: dialog.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: "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
pragma ComponentBehavior: Bound
import QtQuick
Rectangle {
id: servicesPage
signal showCharacteristics
signal showDevices
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() {
servicesPage.showDevices()
}
}
ListView {
id: servicesview
width: parent.width
anchors.top: header.bottom
anchors.bottom: menu.top
model: Device.servicesList
clip: true
delegate: Rectangle {
required property var modelData
id: box
height: 100
color: "lightsteelblue"
border.width: 2
border.color: "black"
radius: 5
width: servicesview.width
MouseArea {
anchors.fill: parent
onClicked: {
Device.connectToService(box.modelData.service_uuid)
servicesPage.showCharacteristics()
}
}
Label {
id: serviceName
textContent: box.modelData.service_name
anchors.top: parent.top
anchors.topMargin: 5
}
Label {
textContent: box.modelData.service_type
font.pointSize: serviceName.font.pointSize * 0.5
anchors.top: serviceName.bottom
}
Label {
id: serviceUuid
font.pointSize: serviceName.font.pointSize * 0.5
textContent: box.modelData.service_uuid
anchors.bottom: box.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()
servicesPage.showDevices()
Device.update = "Search"
}
}
}
// Copyright (C) 2013 BlackBerry Limited. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
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
}
// 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
pragma ComponentBehavior: Bound
import QtQuick
Rectangle {
id: devicesPage
property bool deviceState: Device.state
signal showServices
width: 300
height: 600
onDeviceStateChanged: {
if (!Device.state)
info.visible = false
}
Header {
id: header
anchors.top: parent.top
headerText: {
if (Device.state)
return "Discovering"
if (Device.devices_list.length > 0)
return "Select a device"
return "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 {
required property var modelData
id: box
height: 100
width: theListView.width
color: "lightsteelblue"
border.width: 2
border.color: "black"
radius: 5
MouseArea {
anchors.fill: parent
onClicked: {
Device.scan_services(box.modelData.device_address)
showServices()
}
}
Label {
id: deviceName
textContent: box.modelData.device_name
anchors.top: parent.top
anchors.topMargin: 5
}
Label {
id: deviceAddress
textContent: box.modelData.device_address
font.pointSize: deviceName.font.pointSize * 0.7
anchors.bottom: box.bottom
anchors.bottomMargin: 5
}
}
}
Menu {
id: connectToggle
menuWidth: parent.width
anchors.bottom: menu.top
menuText: {
visible = Device.devices_list.length > 0
if (Device.use_random_address)
return "Address type: Random"
else
return "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: {
if (!Device.state) {
Device.start_device_discovery()
// if start_device_discovery() failed Device.state is not set
if (Device.state) {
info.dialogText = "Searching..."
info.visible = true
}
} else {
Device.stop_device_discovery()
}
}
}
}
module Scanner
typeinfo scanner.qmltypes
Characteristics 1.0 Characteristics.qml
Devices 1.0 Devices.qml
Dialog 1.0 Dialog.qml
Header 1.0 Header.qml
Label 1.0 Label.qml
Main 1.0 Main.qml
Menu 1.0 Menu.qml
Services 1.0 Services.qml