Bluetooth Low Energy Heart Rate Game#
The Bluetooth Low Energy Heart Rate Game shows how to develop a Bluetooth Low Energy application using the Qt Bluetooth API. The application covers the scanning for Bluetooth Low Energy devices, connecting to a Heart Rate service on the device, writing characteristics and descriptors, and receiving updates from the device once the heart rate has changed.
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the bluetooth/heartrate-game example from Qt v6.x"""
from pathlib import Path
import sys
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QCoreApplication, QLoggingCategory
from connectionhandler import ConnectionHandler
from devicefinder import DeviceFinder
from devicehandler import DeviceHandler
from heartrate_global import set_simulator
if __name__ == '__main__':
parser = ArgumentParser(prog="heartrate-game",
formatter_class=RawDescriptionHelpFormatter)
parser.add_argument("-v", "--verbose", action="store_true",
help="Generate more output")
parser.add_argument("-s", "--simulator", action="store_true",
help="Use Simulator")
options = parser.parse_args()
set_simulator(options.simulator)
if options.verbose:
QLoggingCategory.setFilterRules("qt.bluetooth* = true")
app = QGuiApplication(sys.argv)
connectionHandler = ConnectionHandler()
deviceHandler = DeviceHandler()
deviceFinder = DeviceFinder(deviceHandler)
engine = QQmlApplicationEngine()
engine.setInitialProperties({
"connectionHandler": connectionHandler,
"deviceFinder": deviceFinder,
"deviceHandler": deviceHandler})
engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("HeartRateGame", "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
from PySide6.QtCore import QObject, Property, Signal, Slot
class BluetoothBaseClass(QObject):
errorChanged = Signal()
infoChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.m_error = ""
self.m_info = ""
@Property(str, notify=errorChanged)
def error(self):
return self.m_error
@error.setter
def error(self, e):
if self.m_error != e:
self.m_error = e
self.errorChanged.emit()
@Property(str, notify=infoChanged)
def info(self):
return self.m_info
@info.setter
def info(self, i):
if self.m_info != i:
self.m_info = i
self.infoChanged.emit()
@Slot()
def clearMessages(self):
self.info = ""
self.error = ""
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import sys
from PySide6.QtBluetooth import QBluetoothLocalDevice
from PySide6.QtQml import QmlElement
from PySide6.QtCore import QObject, Property, Signal, Slot
from heartrate_global import simulator
# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class ConnectionHandler(QObject):
deviceChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.m_localDevice = QBluetoothLocalDevice()
self.m_localDevice.hostModeStateChanged.connect(self.hostModeChanged)
@Property(bool, notify=deviceChanged)
def alive(self):
if sys.platform == "darwin":
return True
if simulator():
return True
return (self.m_localDevice.isValid()
and self.m_localDevice.hostMode() != QBluetoothLocalDevice.HostPoweredOff)
@Property(bool, constant=True)
def requiresAddressType(self):
return sys.platform == "linux" # QT_CONFIG(bluez)?
@Property(str, notify=deviceChanged)
def name(self):
return self.m_localDevice.name()
@Property(str, notify=deviceChanged)
def address(self):
return self.m_localDevice.address().toString()
@Property(bool, notify=deviceChanged)
def hasPermission(self):
return True
@Slot(QBluetoothLocalDevice.HostMode)
def hostModeChanged(self, mode):
self.deviceChanged.emit()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtBluetooth import (QBluetoothDeviceDiscoveryAgent,
QBluetoothDeviceInfo)
from PySide6.QtQml import QmlElement
from PySide6.QtCore import QTimer, Property, Signal, Slot
from bluetoothbaseclass import BluetoothBaseClass
from deviceinfo import DeviceInfo
from heartrate_global import simulator
# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class DeviceFinder(BluetoothBaseClass):
scanningChanged = Signal()
devicesChanged = Signal()
def __init__(self, handler, parent=None):
super().__init__(parent)
self.m_deviceHandler = handler
self.m_devices = []
self.m_demoTimer = QTimer()
#! [devicediscovery-1]
self.m_deviceDiscoveryAgent = QBluetoothDeviceDiscoveryAgent(self)
self.m_deviceDiscoveryAgent.setLowEnergyDiscoveryTimeout(15000)
self.m_deviceDiscoveryAgent.deviceDiscovered.connect(self.addDevice)
self.m_deviceDiscoveryAgent.errorOccurred.connect(self.scanError)
self.m_deviceDiscoveryAgent.finished.connect(self.scanFinished)
self.m_deviceDiscoveryAgent.canceled.connect(self.scanFinished)
#! [devicediscovery-1]
if simulator():
self.m_demoTimer.setSingleShot(True)
self.m_demoTimer.setInterval(2000)
self.m_demoTimer.timeout.connect(self.scanFinished)
@Slot()
def startSearch(self):
self.clearMessages()
self.m_deviceHandler.setDevice(None)
self.m_devices.clear()
self.devicesChanged.emit()
if simulator():
self.m_demoTimer.start()
else:
#! [devicediscovery-2]
self.m_deviceDiscoveryAgent.start(QBluetoothDeviceDiscoveryAgent.LowEnergyMethod)
#! [devicediscovery-2]
self.scanningChanged.emit()
self.info = "Scanning for devices..."
#! [devicediscovery-3]
@Slot(QBluetoothDeviceInfo)
def addDevice(self, device):
# If device is LowEnergy-device, add it to the list
if device.coreConfigurations() & QBluetoothDeviceInfo.LowEnergyCoreConfiguration:
self.m_devices.append(DeviceInfo(device))
self.info = "Low Energy device found. Scanning more..."
#! [devicediscovery-3]
self.devicesChanged.emit()
#! [devicediscovery-4]
#...
#! [devicediscovery-4]
@Slot(QBluetoothDeviceDiscoveryAgent.Error)
def scanError(self, error):
if error == QBluetoothDeviceDiscoveryAgent.PoweredOffError:
self.error = "The Bluetooth adaptor is powered off."
elif error == QBluetoothDeviceDiscoveryAgent.InputOutputError:
self.error = "Writing or reading from the device resulted in an error."
else:
self.error = "An unknown error has occurred."
@Slot()
def scanFinished(self):
if simulator():
# Only for testing
for i in range(5):
self.m_devices.append(DeviceInfo(QBluetoothDeviceInfo()))
if self.m_devices:
self.info = "Scanning done."
else:
self.error = "No Low Energy devices found."
self.scanningChanged.emit()
self.devicesChanged.emit()
@Slot(str)
def connectToService(self, address):
self.m_deviceDiscoveryAgent.stop()
currentDevice = None
for entry in self.m_devices:
device = entry
if device and device.deviceAddress == address:
currentDevice = device
break
if currentDevice:
self.m_deviceHandler.setDevice(currentDevice)
self.clearMessages()
@Property(bool, notify=scanningChanged)
def scanning(self):
if simulator():
return self.m_demoTimer.isActive()
return self.m_deviceDiscoveryAgent.isActive()
@Property("QVariant", notify=devicesChanged)
def devices(self):
return self.m_devices
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import struct
from enum import IntEnum
from PySide6.QtBluetooth import (QLowEnergyCharacteristic,
QLowEnergyController,
QLowEnergyDescriptor,
QLowEnergyService,
QBluetoothUuid)
from PySide6.QtQml import QmlElement
from PySide6.QtCore import (QByteArray, QDateTime, QRandomGenerator, QTimer,
Property, Signal, Slot, QEnum)
from bluetoothbaseclass import BluetoothBaseClass
from heartrate_global import simulator
# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class DeviceHandler(BluetoothBaseClass):
@QEnum
class AddressType(IntEnum):
PUBLIC_ADDRESS = 1
RANDOM_ADDRESS = 2
measuringChanged = Signal()
aliveChanged = Signal()
statsChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.m_control = None
self.m_service = None
self.m_notificationDesc = QLowEnergyDescriptor()
self.m_currentDevice = None
self.m_foundHeartRateService = False
self.m_measuring = False
self.m_currentValue = 0
self.m_min = 0
self.m_max = 0
self.m_sum = 0
self.m_avg = 0.0
self.m_calories = 0.0
self.m_start = QDateTime()
self.m_stop = QDateTime()
self.m_measurements = []
self.m_addressType = QLowEnergyController.PublicAddress
self.m_demoTimer = QTimer()
if simulator():
self.m_demoTimer.setSingleShot(False)
self.m_demoTimer.setInterval(2000)
self.m_demoTimer.timeout.connect(self.updateDemoHR)
self.m_demoTimer.start()
self.updateDemoHR()
@Property(int)
def addressType(self):
if self.m_addressType == QLowEnergyController.RandomAddress:
return DeviceHandler.AddressType.RANDOM_ADDRESS
return DeviceHandler.AddressType.PUBLIC_ADDRESS
@addressType.setter
def addressType(self, type):
if type == DeviceHandler.AddressType.PUBLIC_ADDRESS:
self.m_addressType = QLowEnergyController.PublicAddress
elif type == DeviceHandler.AddressType.RANDOM_ADDRESS:
self.m_addressType = QLowEnergyController.RandomAddress
@Slot(QLowEnergyController.Error)
def controllerErrorOccurred(self, device):
self.error = "Cannot connect to remote device."
@Slot()
def controllerConnected(self):
self.info = "Controller connected. Search services..."
self.m_control.discoverServices()
@Slot()
def controllerDisconnected(self):
self.error = "LowEnergy controller disconnected"
def setDevice(self, device):
self.clearMessages()
self.m_currentDevice = device
if simulator():
self.info = "Demo device connected."
return
# Disconnect and delete old connection
if self.m_control:
self.m_control.disconnectFromDevice()
m_control = None
# Create new controller and connect it if device available
if self.m_currentDevice:
# Make connections
#! [Connect-Signals-1]
self.m_control = QLowEnergyController.createCentral(self.m_currentDevice.device(), self)
#! [Connect-Signals-1]
self.m_control.setRemoteAddressType(self.m_addressType)
#! [Connect-Signals-2]
self.m_control.serviceDiscovered.connect(self.serviceDiscovered)
self.m_control.discoveryFinished.connect(self.serviceScanDone)
self.m_control.errorOccurred.connect(self.controllerErrorOccurred)
self.m_control.connected.connect(self.controllerConnected)
self.m_control.disconnected.connect(self.controllerDisconnected)
# Connect
self.m_control.connectToDevice()
#! [Connect-Signals-2]
@Slot()
def startMeasurement(self):
if self.alive:
self.m_start = QDateTime.currentDateTime()
self.m_min = 0
self.m_max = 0
self.m_avg = 0
self.m_sum = 0
self.m_calories = 0.0
self.m_measuring = True
self.m_measurements.clear()
self.measuringChanged.emit()
@Slot()
def stopMeasurement(self):
self.m_measuring = False
self.measuringChanged.emit()
#! [Filter HeartRate service 1]
@Slot(QBluetoothUuid)
def serviceDiscovered(self, gatt):
if gatt == QBluetoothUuid(QBluetoothUuid.ServiceClassUuid.HeartRate):
self.info = "Heart Rate service discovered. Waiting for service scan to be done..."
self.m_foundHeartRateService = True
#! [Filter HeartRate service 1]
@Slot()
def serviceScanDone(self):
self.info = "Service scan done."
# Delete old service if available
if self.m_service:
self.m_service = None
#! [Filter HeartRate service 2]
# If heartRateService found, create new service
if self.m_foundHeartRateService:
self.m_service = self.m_control.createServiceObject(QBluetoothUuid(QBluetoothUuid.ServiceClassUuid.HeartRate), self)
if self.m_service:
self.m_service.stateChanged.connect(self.serviceStateChanged)
self.m_service.characteristicChanged.connect(self.updateHeartRateValue)
self.m_service.descriptorWritten.connect(self.confirmedDescriptorWrite)
self.m_service.discoverDetails()
else:
self.error = "Heart Rate Service not found."
#! [Filter HeartRate service 2]
# Service functions
#! [Find HRM characteristic]
@Slot(QLowEnergyService.ServiceState)
def serviceStateChanged(self, switch):
if switch == QLowEnergyService.RemoteServiceDiscovering:
self.info = "Discovering services..."
elif switch == QLowEnergyService.RemoteServiceDiscovered:
self.info = "Service discovered."
hrChar = self.m_service.characteristic(QBluetoothUuid(QBluetoothUuid.CharacteristicType.HeartRateMeasurement))
if hrChar.isValid():
self.m_notificationDesc = hrChar.descriptor(QBluetoothUuid.DescriptorType.ClientCharacteristicConfiguration)
if self.m_notificationDesc.isValid():
self.m_service.writeDescriptor(self.m_notificationDesc,
QByteArray.fromHex(b"0100"))
else:
self.error = "HR Data not found."
self.aliveChanged.emit()
#! [Find HRM characteristic]
#! [Reading value]
@Slot(QLowEnergyCharacteristic, QByteArray)
def updateHeartRateValue(self, c, value):
# ignore any other characteristic change. Shouldn't really happen though
if c.uuid() != QBluetoothUuid(QBluetoothUuid.CharacteristicType.HeartRateMeasurement):
return
data = value.data()
flags = int(data[0])
# Heart Rate
hrvalue = 0
if flags & 0x1: # HR 16 bit little endian? otherwise 8 bit
hrvalue = struct.unpack("<H", data[1:3])[0]
else:
hrvalue = struct.unpack("B", data[1:2])[0]
self.addMeasurement(hrvalue)
#! [Reading value]
@Slot()
def updateDemoHR(self):
randomValue = 0
if self.m_currentValue < 30: # Initial value
randomValue = 55 + QRandomGenerator.global_().bounded(30)
elif not self.m_measuring: # Value when relax
random = QRandomGenerator.global_().bounded(5)
randomValue = self.m_currentValue - 2 + random
randomValue = max(min(randomValue, 55), 75)
else: # Measuring
random = QRandomGenerator.global_().bounded(10)
randomValue = self.m_currentValue + random - 2
self.addMeasurement(randomValue)
@Slot(QLowEnergyCharacteristic, QByteArray)
def confirmedDescriptorWrite(self, d, value):
if (d.isValid() and d == self.m_notificationDesc
and value == QByteArray.fromHex(b"0000")):
# disabled notifications . assume disconnect intent
self.m_control.disconnectFromDevice()
self.m_service = None
@Slot()
def disconnectService(self):
self.m_foundHeartRateService = False
# disable notifications
if (self.m_notificationDesc.isValid() and self.m_service
and self.m_notificationDesc.value() == QByteArray.fromHex(b"0100")):
self.m_service.writeDescriptor(self.m_notificationDesc,
QByteArray.fromHex(b"0000"))
else:
if self.m_control:
self.m_control.disconnectFromDevice()
self.m_service = None
@Property(bool, notify=measuringChanged)
def measuring(self):
return self.m_measuring
@Property(bool, notify=aliveChanged)
def alive(self):
if simulator():
return True
if self.m_service:
return self.m_service.state() == QLowEnergyService.RemoteServiceDiscovered
return False
@Property(int, notify=statsChanged)
def hr(self):
return self.m_currentValue
@Property(int, notify=statsChanged)
def time(self):
return self.m_start.secsTo(self.m_stop)
@Property(int, notify=statsChanged)
def maxHR(self):
return self.m_max
@Property(int, notify=statsChanged)
def minHR(self):
return self.m_min
@Property(float, notify=statsChanged)
def average(self):
return self.m_avg
@Property(float, notify=statsChanged)
def calories(self):
return self.m_calories
def addMeasurement(self, value):
self.m_currentValue = value
# If measuring and value is appropriate
if self.m_measuring and value > 30 and value < 250:
self.m_stop = QDateTime.currentDateTime()
self.m_measurements.append(value)
self.m_min = value if self.m_min == 0 else min(value, self.m_min)
self.m_max = max(value, self.m_max)
self.m_sum += value
self.m_avg = float(self.m_sum) / len(self.m_measurements)
self.m_calories = ((-55.0969 + (0.6309 * self.m_avg) + (0.1988 * 94)
+ (0.2017 * 24)) / 4.184) * 60 * self.time / 3600
self.statsChanged.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 heartrate_global import simulator
class DeviceInfo(QObject):
deviceChanged = Signal()
def __init__(self, device):
super().__init__()
self.m_device = device
def device(self):
return self.m_device
def setDevice(self, device):
self.m_device = device
self.deviceChanged.emit()
@Property(str, notify=deviceChanged)
def deviceName(self):
if simulator():
return "Demo device"
return self.m_device.name()
@Property(str, notify=deviceChanged)
def deviceAddress(self):
if simulator():
return "00:11:22:33:44:55"
if sys.platform == "Darwin": # workaround for Core Bluetooth:
return self.m_device.deviceUuid().toString()
return self.m_device.address().toString()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import sys
_simulator = False
def simulator():
global _simulator
return _simulator
def set_simulator(s):
global _simulator
_simulator = s
module HeartRateGame
App 1.0 App.qml
BluetoothAlarmDialog 1.0 BluetoothAlarmDialog.qml
BottomLine 1.0 BottomLine.qml
Connect 1.0 Connect.qml
GameButton 1.0 GameButton.qml
GamePage 1.0 GamePage.qml
singleton GameSettings 1.0 GameSettings.qml
Measure 1.0 Measure.qml
SplashScreen 1.0 SplashScreen.qml
Stats 1.0 Stats.qml
StatsLabel 1.0 StatsLabel.qml
TitleBar 1.0 TitleBar.qml
Main 1.0 Main.qml
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Window
import HeartRateGame
Window {
id: wroot
visible: true
width: 720 * .7
height: 1240 * .7
title: qsTr("HeartRateGame")
color: GameSettings.backgroundColor
required property ConnectionHandler connectionHandler
required property DeviceFinder deviceFinder
required property DeviceHandler deviceHandler
Component.onCompleted: {
GameSettings.wWidth = Qt.binding(function () {
return width
})
GameSettings.wHeight = Qt.binding(function () {
return height
})
}
Loader {
id: splashLoader
anchors.fill: parent
asynchronous: false
visible: true
sourceComponent: SplashScreen {
appIsReady: appLoader.status === Loader.Ready
onReadyChanged: {
if (ready) {
appLoader.visible = true
splashLoader.visible = false
splashLoader.active = false
}
}
}
onStatusChanged: {
if (status === Loader.Ready)
appLoader.active = true
}
}
Loader {
id: appLoader
anchors.fill: parent
active: false
asynchronous: true
visible: false
sourceComponent: App {
connectionHandler: wroot.connectionHandler
deviceFinder: wroot.deviceFinder
deviceHandler: wroot.deviceHandler
}
onStatusChanged: {
if (status === Loader.Error)
Qt.quit()
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick.Layouts
import HeartRateGame
Item {
id: app
required property ConnectionHandler connectionHandler
required property DeviceFinder deviceFinder
required property DeviceHandler deviceHandler
anchors.fill: parent
opacity: 0.0
Behavior on opacity {
NumberAnimation {
duration: 500
}
}
property int __currentIndex: 0
TitleBar {
id: titleBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
currentIndex: app.__currentIndex
onTitleClicked: (index) => {
if (index < app.__currentIndex)
app.__currentIndex = index
}
}
StackLayout {
id: pageStack
anchors.left: parent.left
anchors.right: parent.right
anchors.top: titleBar.bottom
anchors.bottom: parent.bottom
currentIndex: app.__currentIndex
Connect {
connectionHandler: app.connectionHandler
deviceFinder: app.deviceFinder
deviceHandler: app.deviceHandler
onShowMeasurePage: app.__currentIndex = 1
}
Measure {
id: measurePage
deviceHandler: app.deviceHandler
onShowStatsPage: app.__currentIndex = 2
}
Stats {
deviceHandler: app.deviceHandler
}
onCurrentIndexChanged: {
if (currentIndex === 0)
measurePage.close()
}
}
BluetoothAlarmDialog {
id: btAlarmDialog
anchors.fill: parent
visible: !app.connectionHandler.alive || permissionError
permissionError: !app.connectionHandler.hasPermission
}
Keys.onReleased: (event) => {
switch (event.key) {
case Qt.Key_Escape:
case Qt.Key_Back:
{
if (app.__currentIndex > 0) {
app.__currentIndex = app.__currentIndex - 1
event.accepted = true
} else {
Qt.quit()
}
break
}
default:
break
}
}
Component.onCompleted: {
forceActiveFocus()
app.opacity = 1.0
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Item {
id: root
property bool permissionError: false
anchors.fill: parent
Rectangle {
anchors.fill: parent
color: "black"
opacity: 0.9
}
MouseArea {
id: eventEater
}
Rectangle {
id: dialogFrame
anchors.centerIn: parent
width: parent.width * 0.8
height: parent.height * 0.6
border.color: "#454545"
color: GameSettings.backgroundColor
radius: width * 0.05
Item {
id: dialogContainer
anchors.fill: parent
anchors.margins: parent.width*0.05
Image {
id: offOnImage
anchors.left: quitButton.left
anchors.right: quitButton.right
anchors.top: parent.top
height: GameSettings.heightForWidth(width, sourceSize)
source: "images/bt_off_to_on.png"
}
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: offOnImage.bottom
anchors.bottom: quitButton.top
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
font.pixelSize: GameSettings.mediumFontSize
color: GameSettings.textColor
text: root.permissionError
? qsTr("Bluetooth permissions are not granted. Please grant the permissions in the system settings.")
: qsTr("This application cannot be used without Bluetooth. Please switch Bluetooth ON to continue.")
}
GameButton {
id: quitButton
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: dialogContainer.width * 0.6
height: GameSettings.buttonHeight
onClicked: Qt.quit()
Text {
anchors.centerIn: parent
color: GameSettings.textColor
font.pixelSize: GameSettings.bigFontSize
text: qsTr("Quit")
}
}
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
width: parent.width * 0.85
height: parent.height * 0.05
radius: height*0.5
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
pragma ComponentBehavior: Bound
import QtQuick
import HeartRateGame
GamePage {
id: connectPage
required property ConnectionHandler connectionHandler
required property DeviceFinder deviceFinder
required property DeviceHandler deviceHandler
signal showMeasurePage
errorMessage: deviceFinder.error
infoMessage: deviceFinder.info
Rectangle {
id: viewContainer
anchors.top: parent.top
// only BlueZ platform has address type selection
anchors.bottom: connectPage.connectionHandler.requiresAddressType ? addressTypeButton.top
: searchButton.top
anchors.topMargin: GameSettings.fieldMargin + connectPage.messageHeight
anchors.bottomMargin: GameSettings.fieldMargin
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - GameSettings.fieldMargin * 2
color: GameSettings.viewColor
radius: GameSettings.buttonRadius
Text {
id: title
width: parent.width
height: GameSettings.fieldHeight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
color: GameSettings.textColor
font.pixelSize: GameSettings.mediumFontSize
text: qsTr("FOUND DEVICES")
BottomLine {
height: 1
width: parent.width
color: "#898989"
}
}
ListView {
id: devices
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.top: title.bottom
model: connectPage.deviceFinder.devices
clip: true
delegate: Rectangle {
id: box
required property int index
required property var modelData
height: GameSettings.fieldHeight * 1.2
width: devices.width
color: index % 2 === 0 ? GameSettings.delegate1Color : GameSettings.delegate2Color
MouseArea {
anchors.fill: parent
onClicked: {
connectPage.deviceFinder.connectToService(box.modelData.deviceAddress)
connectPage.showMeasurePage()
}
}
Text {
id: device
font.pixelSize: GameSettings.smallFontSize
text: box.modelData.deviceName
anchors.top: parent.top
anchors.topMargin: parent.height * 0.1
anchors.leftMargin: parent.height * 0.1
anchors.left: parent.left
color: GameSettings.textColor
}
Text {
id: deviceAddress
font.pixelSize: GameSettings.smallFontSize
text: box.modelData.deviceAddress
anchors.bottom: parent.bottom
anchors.bottomMargin: parent.height * 0.1
anchors.rightMargin: parent.height * 0.1
anchors.right: parent.right
color: Qt.darker(GameSettings.textColor)
}
}
}
}
GameButton {
id: addressTypeButton
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: searchButton.top
anchors.bottomMargin: GameSettings.fieldMargin * 0.5
width: viewContainer.width
height: GameSettings.fieldHeight
visible: connectPage.connectionHandler.requiresAddressType // only required on BlueZ
state: "public"
onClicked: state === "public" ? state = "random" : state = "public"
states: [
State {
name: "public"
PropertyChanges {
addressTypeText.text: qsTr("Public Address")
}
PropertyChanges {
connectPage.deviceHandler.addressType: DeviceHandler.PUBLIC_ADDRESS
}
},
State {
name: "random"
PropertyChanges {
addressTypeText.text: qsTr("Random Address")
}
PropertyChanges {
connectPage.deviceHandler.addressType: DeviceHandler.RANDOM_ADDRESS
}
}
]
Text {
id: addressTypeText
anchors.centerIn: parent
font.pixelSize: GameSettings.tinyFontSize
color: GameSettings.textColor
}
}
GameButton {
id: searchButton
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: GameSettings.fieldMargin
width: viewContainer.width
height: GameSettings.fieldHeight
enabled: !connectPage.deviceFinder.scanning
onClicked: connectPage.deviceFinder.startSearch()
Text {
anchors.centerIn: parent
font.pixelSize: GameSettings.tinyFontSize
text: qsTr("START SEARCH")
color: searchButton.enabled ? GameSettings.textColor : GameSettings.disabledTextColor
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Rectangle {
id: button
color: baseColor
onEnabledChanged: checkColor()
radius: GameSettings.buttonRadius
property color baseColor: GameSettings.buttonColor
property color pressedColor: GameSettings.buttonPressedColor
property color disabledColor: GameSettings.disabledButtonColor
signal clicked
function checkColor() {
if (!button.enabled) {
button.color = disabledColor
} else {
if (mouseArea.containsPress)
button.color = pressedColor
else
button.color = baseColor
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
onPressed: button.checkColor()
onReleased: button.checkColor()
onClicked: {
button.checkColor()
button.clicked()
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Item {
id: page
property string errorMessage: ""
property string infoMessage: ""
property real messageHeight: msg.height
property bool hasError: errorMessage != ""
property bool hasInfo: infoMessage != ""
Rectangle {
id: msg
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: GameSettings.fieldHeight
color: page.hasError ? GameSettings.errorColor : GameSettings.infoColor
visible: page.hasError || page.hasInfo
Text {
id: error
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
minimumPixelSize: 5
font.pixelSize: GameSettings.smallFontSize
fontSizeMode: Text.Fit
color: GameSettings.textColor
text: page.hasError ? page.errorMessage : page.infoMessage
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
pragma Singleton
import QtQuick
Item {
property int wHeight
property int wWidth
// Colors
readonly property color backgroundColor: "#2d3037"
readonly property color buttonColor: "#202227"
readonly property color buttonPressedColor: "#6ccaf2"
readonly property color disabledButtonColor: "#555555"
readonly property color viewColor: "#202227"
readonly property color delegate1Color: Qt.darker(viewColor, 1.2)
readonly property color delegate2Color: Qt.lighter(viewColor, 1.2)
readonly property color textColor: "#ffffff"
readonly property color textDarkColor: "#232323"
readonly property color disabledTextColor: "#777777"
readonly property color sliderColor: "#6ccaf2"
readonly property color errorColor: "#ba3f62"
readonly property color infoColor: "#3fba62"
// Font sizes
property real microFontSize: hugeFontSize * 0.2
property real tinyFontSize: hugeFontSize * 0.4
property real smallTinyFontSize: hugeFontSize * 0.5
property real smallFontSize: hugeFontSize * 0.6
property real mediumFontSize: hugeFontSize * 0.7
property real bigFontSize: hugeFontSize * 0.8
property real largeFontSize: hugeFontSize * 0.9
property real hugeFontSize: (wWidth + wHeight) * 0.03
property real giganticFontSize: (wWidth + wHeight) * 0.04
// Some other values
property real fieldHeight: wHeight * 0.08
property real fieldMargin: fieldHeight * 0.5
property real buttonHeight: wHeight * 0.08
property real buttonRadius: buttonHeight * 0.1
// Some help functions
function widthForHeight(h, ss) {
return h / ss.height * ss.width
}
function heightForWidth(w, ss) {
return w / ss.width * ss.height
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import HeartRateGame
GamePage {
id: measurePage
required property DeviceHandler deviceHandler
errorMessage: deviceHandler.error
infoMessage: deviceHandler.info
property real __timeCounter: 0
property real __maxTimeCount: 60
property string relaxText: qsTr("Relax!\nWhen you are ready, press Start. You have %1s time to increase heartrate so much as possible.\nGood luck!").arg(__maxTimeCount)
signal showStatsPage
function close() {
deviceHandler.stopMeasurement()
deviceHandler.disconnectService()
}
function start() {
if (!deviceHandler.measuring) {
__timeCounter = 0
deviceHandler.startMeasurement()
}
}
function stop() {
if (deviceHandler.measuring)
deviceHandler.stopMeasurement()
measurePage.showStatsPage()
}
Timer {
id: measureTimer
interval: 1000
running: measurePage.deviceHandler.measuring
repeat: true
onTriggered: {
measurePage.__timeCounter++
if (measurePage.__timeCounter >= measurePage.__maxTimeCount)
measurePage.stop()
}
}
Column {
anchors.centerIn: parent
spacing: GameSettings.fieldHeight * 0.5
Rectangle {
id: circle
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(measurePage.width, measurePage.height - GameSettings.fieldHeight * 4)
- 2 * GameSettings.fieldMargin
height: width
radius: width * 0.5
color: GameSettings.viewColor
Text {
id: hintText
anchors.centerIn: parent
anchors.verticalCenterOffset: -parent.height * 0.1
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
width: parent.width * 0.8
height: parent.height * 0.6
wrapMode: Text.WordWrap
text: measurePage.relaxText
visible: !measurePage.deviceHandler.measuring
color: GameSettings.textColor
fontSizeMode: Text.Fit
minimumPixelSize: 10
font.pixelSize: GameSettings.mediumFontSize
}
Text {
id: text
anchors.centerIn: parent
anchors.verticalCenterOffset: -parent.height * 0.15
font.pixelSize: parent.width * 0.45
text: measurePage.deviceHandler.hr
visible: measurePage.deviceHandler.measuring
color: GameSettings.textColor
}
Item {
id: minMaxContainer
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width * 0.7
height: parent.height * 0.15
anchors.bottom: parent.bottom
anchors.bottomMargin: parent.height * 0.16
visible: measurePage.deviceHandler.measuring
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: measurePage.deviceHandler.minHR
color: GameSettings.textColor
font.pixelSize: GameSettings.hugeFontSize
Text {
anchors.left: parent.left
anchors.bottom: parent.top
font.pixelSize: parent.font.pixelSize * 0.8
color: parent.color
text: "MIN"
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: measurePage.deviceHandler.maxHR
color: GameSettings.textColor
font.pixelSize: GameSettings.hugeFontSize
Text {
anchors.right: parent.right
anchors.bottom: parent.top
font.pixelSize: parent.font.pixelSize * 0.8
color: parent.color
text: "MAX"
}
}
}
Image {
id: heart
anchors.horizontalCenter: minMaxContainer.horizontalCenter
anchors.verticalCenter: minMaxContainer.bottom
width: parent.width * 0.2
height: width
source: "images/heart.png"
smooth: true
antialiasing: true
SequentialAnimation {
id: heartAnim
running: measurePage.deviceHandler.alive
loops: Animation.Infinite
alwaysRunToEnd: true
PropertyAnimation {
target: heart
property: "scale"
to: 1.2
duration: 500
easing.type: Easing.InQuad
}
PropertyAnimation {
target: heart
property: "scale"
to: 1.0
duration: 500
easing.type: Easing.OutQuad
}
}
}
}
Rectangle {
id: timeSlider
color: GameSettings.viewColor
anchors.horizontalCenter: parent.horizontalCenter
width: circle.width
height: GameSettings.fieldHeight
radius: GameSettings.buttonRadius
Rectangle {
height: parent.height
radius: parent.radius
color: GameSettings.sliderColor
width: Math.min(
1.0,
measurePage.__timeCounter / measurePage.__maxTimeCount) * parent.width
}
Text {
anchors.centerIn: parent
color: "gray"
text: (measurePage.__maxTimeCount - measurePage.__timeCounter).toFixed(0) + " s"
font.pixelSize: GameSettings.bigFontSize
}
}
}
GameButton {
id: startButton
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: GameSettings.fieldMargin
width: circle.width
height: GameSettings.fieldHeight
enabled: !measurePage.deviceHandler.measuring
radius: GameSettings.buttonRadius
onClicked: measurePage.start()
Text {
anchors.centerIn: parent
font.pixelSize: GameSettings.tinyFontSize
text: qsTr("START")
color: startButton.enabled ? GameSettings.textColor : GameSettings.disabledTextColor
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import HeartRateGame
Item {
id: root
property bool appIsReady: false
property bool splashIsReady: false
property bool ready: appIsReady && splashIsReady
anchors.fill: parent
Image {
anchors.centerIn: parent
width: Math.min(parent.height, parent.width) * 0.6
height: GameSettings.heightForWidth(width, sourceSize)
source: "images/logo.png"
}
Timer {
id: splashTimer
interval: 1000
onTriggered: splashIsReady = true
}
Component.onCompleted: splashTimer.start()
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import HeartRateGame
GamePage {
id: statsPage
required property DeviceHandler deviceHandler
Column {
anchors.centerIn: parent
width: parent.width
Text {
anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: GameSettings.hugeFontSize
color: GameSettings.textColor
text: qsTr("RESULT")
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: GameSettings.giganticFontSize * 3
color: GameSettings.textColor
text: (statsPage.deviceHandler.maxHR - statsPage.deviceHandler.minHR).toFixed(0)
}
Item {
height: GameSettings.fieldHeight
width: 1
}
StatsLabel {
title: qsTr("MIN")
value: statsPage.deviceHandler.minHR.toFixed(0)
}
StatsLabel {
title: qsTr("MAX")
value: statsPage.deviceHandler.maxHR.toFixed(0)
}
StatsLabel {
title: qsTr("AVG")
value: statsPage.deviceHandler.average.toFixed(1)
}
StatsLabel {
title: qsTr("CALORIES")
value: statsPage.deviceHandler.calories.toFixed(3)
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Item {
height: GameSettings.fieldHeight
width: parent.width
property alias title: leftText.text
property alias value: rightText.text
Text {
id: leftText
anchors.left: parent.left
height: parent.height
width: parent.width * 0.45
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
font.pixelSize: GameSettings.mediumFontSize
color: GameSettings.textColor
}
Text {
id: rightText
anchors.right: parent.right
height: parent.height
width: parent.width * 0.45
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
font.pixelSize: GameSettings.mediumFontSize
color: GameSettings.textColor
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
pragma ComponentBehavior: Bound
import QtQuick
Rectangle {
id: titleBar
property var __titles: ["CONNECT", "MEASURE", "STATS"]
property int currentIndex: 0
signal titleClicked(int index)
height: GameSettings.fieldHeight
color: GameSettings.viewColor
Repeater {
model: 3
Text {
id: caption
required property int index
width: titleBar.width / 3
height: titleBar.height
x: index * width
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: titleBar.__titles[index]
font.pixelSize: GameSettings.tinyFontSize
color: titleBar.currentIndex === index ? GameSettings.textColor
: GameSettings.disabledTextColor
MouseArea {
anchors.fill: parent
onClicked: titleBar.titleClicked(caption.index)
}
}
}
Item {
anchors.bottom: parent.bottom
width: parent.width / 3
height: parent.height
x: titleBar.currentIndex * width
BottomLine {}
Behavior on x {
NumberAnimation {
duration: 200
}
}
}
}