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"""
import os
from pathlib import Path
import sys
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
from PySide6.QtQml import QQmlApplicationEngine, QQmlContext
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QCoreApplication, QLoggingCategory, QUrl
from connectionhandler import ConnectionHandler
from devicefinder import DeviceFinder
from devicehandler import DeviceHandler
from heartrate_global import 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()
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})
qml_file = os.fspath(Path(__file__).resolve().parent / "qml" / "main.qml")
engine.load(QUrl.fromLocalFile(qml_file))
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 = "Shared"
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()
@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 = "Shared"
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 QmlNamedElement, QmlUncreatable
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 = "Shared"
QML_IMPORT_MAJOR_VERSION = 1
@QmlNamedElement("AddressType")
@QmlUncreatable("Enum is not a type")
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.getDevice(), self)
#! [Connect-Signals-1]
self.m_control.setRemoteAddressType(self.m_addressType)
#! [Connect-Signals-2]
m_control.serviceDiscovered.connect(self.serviceDiscovered)
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.setInfo(tr("Discovering services..."))
elif switch == QLowEnergyService.RemoteServiceDiscovered:
self.setInfo(tr("Service discovered."))
hrChar = 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(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])
else:
hrvalue = struct.unpack("B", data[1:2])
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 = sys.platform == "win32"
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick.Window
import "."
import Shared
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 AddressType deviceHandler
Component.onCompleted: {
GameSettings.wWidth = Qt.binding(function() {return width})
GameSettings.wHeight = Qt.binding(function() {return height})
}
Loader {
id: splashLoader
anchors.fill: parent
source: "SplashScreen.qml"
asynchronous: false
visible: true
onStatusChanged: {
if (status === Loader.Ready) {
appLoader.setSource("App.qml");
}
}
}
Connections {
target: splashLoader.item
function onReadyToGo() {
appLoader.visible = true
appLoader.item.init()
splashLoader.visible = false
splashLoader.setSource("")
appLoader.item.forceActiveFocus();
}
}
Loader {
id: appLoader
anchors.fill: parent
visible: false
asynchronous: true
onStatusChanged: {
if (status === Loader.Ready)
splashLoader.item.appReady()
if (status === Loader.Error)
splashLoader.item.errorInLoadingApp();
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Item {
id: app
anchors.fill: parent
opacity: 0.0
Behavior on opacity { NumberAnimation { duration: 500 } }
property var lastPages: []
property int __currentIndex: 0
function init()
{
opacity = 1.0
showPage("Connect.qml")
}
function prevPage()
{
lastPages.pop()
pageLoader.setSource(lastPages[lastPages.length-1])
__currentIndex = lastPages.length-1;
}
function showPage(name)
{
lastPages.push(name)
pageLoader.setSource(name)
__currentIndex = lastPages.length-1;
}
TitleBar {
id: titleBar
currentIndex: __currentIndex
onTitleClicked: (index) => {
if (index < __currentIndex)
pageLoader.item.close()
}
}
Loader {
id: pageLoader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: titleBar.bottom
anchors.bottom: parent.bottom
onStatusChanged: {
if (status === Loader.Ready)
{
pageLoader.item.init();
pageLoader.item.forceActiveFocus()
}
}
}
Keys.onReleased: (event) => {
switch (event.key) {
case Qt.Key_Escape:
case Qt.Key_Back: {
if (__currentIndex > 0) {
pageLoader.item.close()
event.accepted = true
} else {
Qt.quit()
}
break;
}
default: break;
}
}
BluetoothAlarmDialog {
id: btAlarmDialog
anchors.fill: parent
visible: !connectionHandler.alive
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
Item {
id: root
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: 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
import QtQuick
import Shared
GamePage {
errorMessage: deviceFinder.error
infoMessage: deviceFinder.info
Rectangle {
id: viewContainer
anchors.top: parent.top
anchors.bottom:
// only BlueZ platform has address type selection
connectionHandler.requiresAddressType ? addressTypeButton.top : searchButton.top
anchors.topMargin: GameSettings.fieldMargin + 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: deviceFinder.devices
clip: true
delegate: Rectangle {
id: box
height:GameSettings.fieldHeight * 1.2
width: devices.width
color: index % 2 === 0 ? GameSettings.delegate1Color : GameSettings.delegate2Color
MouseArea {
anchors.fill: parent
onClicked: {
deviceFinder.connectToService(modelData.deviceAddress);
app.showPage("Measure.qml")
}
}
Text {
id: device
font.pixelSize: GameSettings.smallFontSize
text: 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: 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: connectionHandler.requiresAddressType // only required on BlueZ
state: "public"
onClicked: state == "public" ? state = "random" : state = "public"
states: [
State {
name: "public"
PropertyChanges { target: addressTypeText; text: qsTr("Public Address") }
PropertyChanges { target: deviceHandler; addressType: AddressType.PUBLIC_ADDRESS }
},
State {
name: "random"
PropertyChanges { target: addressTypeText; text: qsTr("Random Address") }
PropertyChanges { target: deviceHandler; addressType: AddressType.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: !deviceFinder.scanning
onClicked: 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
import "."
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: checkColor()
onReleased: checkColor()
onClicked: {
checkColor()
button.clicked()
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import "."
Item {
anchors.fill: parent
property string errorMessage: ""
property string infoMessage: ""
property real messageHeight: msg.height
property bool hasError: errorMessage != ""
property bool hasInfo: infoMessage != ""
function init()
{
}
function close()
{
app.prevPage()
}
Rectangle {
id: msg
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: GameSettings.fieldHeight
color: hasError ? GameSettings.errorColor : GameSettings.infoColor
visible: hasError || 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: hasError ? errorMessage : 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
GamePage {
id: measurePage
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)
function close()
{
deviceHandler.stopMeasurement();
deviceHandler.disconnectService();
app.prevPage();
}
function start()
{
if (!deviceHandler.measuring) {
__timeCounter = 0;
deviceHandler.startMeasurement()
}
}
function stop()
{
if (deviceHandler.measuring) {
deviceHandler.stopMeasurement()
}
app.showPage("Stats.qml")
}
Timer {
id: measureTimer
interval: 1000
running: deviceHandler.measuring
repeat: true
onTriggered: {
__timeCounter++;
if (__timeCounter >= __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: !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: deviceHandler.hr
visible: 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: deviceHandler.measuring
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: 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: 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: 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,__timeCounter / __maxTimeCount) * parent.width
}
Text {
anchors.centerIn: parent
color: "gray"
text: (__maxTimeCount - __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: !deviceHandler.measuring
radius: GameSettings.buttonRadius
onClicked: 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 "."
Item {
id: root
anchors.fill: parent
property bool appIsReady: false
property bool splashIsReady: false
property bool ready: appIsReady && splashIsReady
onReadyChanged: if (ready) readyToGo();
signal readyToGo()
function appReady()
{
appIsReady = true
}
function errorInLoadingApp()
{
Qt.quit()
}
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
GamePage {
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: (deviceHandler.maxHR - deviceHandler.minHR).toFixed(0)
}
Item {
height: GameSettings.fieldHeight
width: 1
}
StatsLabel {
title: qsTr("MIN")
value: deviceHandler.minHR.toFixed(0)
}
StatsLabel {
title: qsTr("MAX")
value: deviceHandler.maxHR.toFixed(0)
}
StatsLabel {
title: qsTr("AVG")
value: deviceHandler.average.toFixed(1)
}
StatsLabel {
title: qsTr("CALORIES")
value: deviceHandler.calories.toFixed(3)
}
}
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import "."
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
import QtQuick
Rectangle {
id: titleBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: GameSettings.fieldHeight
color: GameSettings.viewColor
property var __titles: ["CONNECT", "MEASURE", "STATS"]
property int currentIndex: 0
signal titleClicked(int index)
Repeater {
model: 3
Text {
width: titleBar.width / 3
height: titleBar.height
x: index * width
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: __titles[index]
font.pixelSize: GameSettings.tinyFontSize
color: titleBar.currentIndex === index ? GameSettings.textColor : GameSettings.disabledTextColor
MouseArea {
anchors.fill: parent
onClicked: titleClicked(index)
}
}
}
Item {
anchors.bottom: parent.bottom
width: parent.width / 3
height: parent.height
x: currentIndex * width
BottomLine{}
Behavior on x { NumberAnimation { duration: 200 } }
}
}