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.

Download this example

# 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 } }
    }

}