Bluetooth Low Energy Heart Rate Game

Tags: Android

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
from __future__ import annotations

"""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 __future__ import annotations

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
from __future__ import annotations

import sys

from PySide6.QtBluetooth import QBluetoothLocalDevice
from PySide6.QtQml import QmlElement
from PySide6.QtCore import QObject, Property, Signal, Slot, Qt

from heartrate_global import simulator, is_android, error_not_nuitka

if is_android or sys.platform == "darwin":
    from PySide6.QtCore import QBluetoothPermission

# 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_hasPermission = False
        self.initLocalDevice()

    @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 self.m_hasPermission

    @Slot(QBluetoothLocalDevice.HostMode)
    def hostModeChanged(self, mode):
        self.deviceChanged.emit()

    def initLocalDevice(self):
        if is_android or sys.platform == "darwin":
            error_not_nuitka()
            permission = QBluetoothPermission()
            permission.setCommunicationModes(QBluetoothPermission.Access)
            permission_status = qApp.checkPermission(permission)  # noqa: F821
            if permission_status == Qt.PermissionStatus.Undetermined:
                qApp.requestPermission(permission, self, self.initLocalDevice)  # noqa: F821
                return
            if permission_status == Qt.PermissionStatus.Denied:
                return
            elif permission_status == Qt.PermissionStatus.Granted:
                print("[HeartRateGame] Bluetooth Permission Granted")

        self.m_localDevice = QBluetoothLocalDevice()
        self.m_localDevice.hostModeStateChanged.connect(self.hostModeChanged)
        self.m_hasPermission = True
        self.deviceChanged.emit()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations
import sys

from PySide6.QtBluetooth import (QBluetoothDeviceDiscoveryAgent,
                                 QBluetoothDeviceInfo)
from PySide6.QtQml import QmlElement
from PySide6.QtCore import QTimer, Property, Signal, Slot, Qt

from bluetoothbaseclass import BluetoothBaseClass
from deviceinfo import DeviceInfo
from heartrate_global import simulator, is_android, error_not_nuitka

if is_android or sys.platform == "darwin":
    from PySide6.QtCore import QBluetoothPermission

# 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):
        if is_android or sys.platform == "darwin":
            error_not_nuitka()
            permission = QBluetoothPermission()
            permission.setCommunicationModes(QBluetoothPermission.Access)
            permission_status = qApp.checkPermission(permission)  # noqa: F821
            if permission_status == Qt.PermissionStatus.Undetermined:
                qApp.requestPermission(permission, self, self.startSearch)  # noqa: F82 1
                return
            elif permission_status == Qt.PermissionStatus.Denied:
                return
            elif permission_status == Qt.PermissionStatus.Granted:
                print("[HeartRateGame] Bluetooth Permission Granted")

        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
from __future__ import annotations

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()
            self.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
from __future__ import annotations

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
from __future__ import annotations
import os
import sys

_simulator = False


def simulator():
    global _simulator
    return _simulator


def set_simulator(s):
    global _simulator
    _simulator = s


is_android = os.environ.get('ANDROID_ARGUMENT')


def error_not_nuitka():
    """Errors and exits for macOS if run in interpreted mode.
    """
    is_nuitka = "__compiled__" in globals()
    if not is_nuitka and sys.platform == "darwin":
        print("This example does not work on macOS when Python is run in interpreted mode."
              "For this example to work on macOS, package the example using pyside6-deploy"
              "For more information, read `Notes for Developer` in the documentation")
        sys.exit(0)
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
            }
        }
    }
}