[+] Add blessed example files

This commit is contained in:
Azalea Gui
2023-01-23 14:27:42 -05:00
parent 8a2bb1b40d
commit 8266c2cda5
13 changed files with 1075 additions and 0 deletions
@@ -0,0 +1,52 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_SFLOAT
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import java.nio.ByteOrder
import java.util.*
data class BloodPressureMeasurement(
val systolic: Float,
val diastolic: Float,
val meanArterialPressure: Float,
val unit: ObservationUnit,
val timestamp: Date?,
val pulseRate: Float?,
val userID: Int?,
val measurementStatus: BloodPressureMeasurementStatus?,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): BloodPressureMeasurement
{
val parser = BluetoothBytesParser(value, ByteOrder.LITTLE_ENDIAN)
val flags = parser.getIntValue(FORMAT_UINT8)
val unit = if (flags and 0x01 > 0) ObservationUnit.MMHG else ObservationUnit.KPA
val timestampPresent = flags and 0x02 > 0
val pulseRatePresent = flags and 0x04 > 0
val userIdPresent = flags and 0x08 > 0
val measurementStatusPresent = flags and 0x10 > 0
val systolic = parser.getFloatValue(FORMAT_SFLOAT)
val diastolic = parser.getFloatValue(FORMAT_SFLOAT)
val meanArterialPressure = parser.getFloatValue(FORMAT_SFLOAT)
val timestamp = if (timestampPresent) parser.dateTime else null
val pulseRate = if (pulseRatePresent) parser.getFloatValue(FORMAT_SFLOAT) else null
val userID = if (userIdPresent) parser.getIntValue(FORMAT_UINT8) else null
val status = if (measurementStatusPresent) BloodPressureMeasurementStatus(parser.getIntValue(FORMAT_UINT16)) else null
return BloodPressureMeasurement(
systolic = systolic,
diastolic = diastolic,
meanArterialPressure = meanArterialPressure,
unit = unit,
timestamp = timestamp,
pulseRate = pulseRate,
userID = userID,
measurementStatus = status
)
}
}
}
@@ -0,0 +1,32 @@
package org.hydev.wearsync.bles
class BloodPressureMeasurementStatus internal constructor(measurementStatus: Int) {
/**
* Body Movement Detected
*/
val isBodyMovementDetected: Boolean
/**
* Cuff is too loose
*/
val isCuffTooLoose: Boolean
/**
* Irregular pulse detected
*/
val isIrregularPulseDetected: Boolean
/**
* Pulse is not in normal range
*/
val isPulseNotInRange: Boolean
/**
* Improper measurement position
*/
val isImproperMeasurementPosition: Boolean
init {
isBodyMovementDetected = measurementStatus and 0x0001 > 0
isCuffTooLoose = measurementStatus and 0x0002 > 0
isIrregularPulseDetected = measurementStatus and 0x0004 > 0
isPulseNotInRange = measurementStatus and 0x0008 > 0
isImproperMeasurementPosition = measurementStatus and 0x0020 > 0
}
}
@@ -0,0 +1,326 @@
package org.hydev.wearsync.bles
import android.bluetooth.BluetoothAdapter
import android.content.Context
import com.welie.blessed.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import timber.log.Timber
import timber.log.Timber.DebugTree
import java.nio.ByteOrder
import java.util.*
internal class BluetoothHandler private constructor(context: Context) {
private var currentTimeCounter = 0
val heartRateChannel = Channel<HeartRateMeasurement>(UNLIMITED)
val bloodpressureChannel = Channel<BloodPressureMeasurement>(UNLIMITED)
val glucoseChannel = Channel<GlucoseMeasurement>(UNLIMITED)
val pulseOxSpotChannel = Channel<PulseOximeterSpotMeasurement>(UNLIMITED)
val pulseOxContinuousChannel = Channel<PulseOximeterContinuousMeasurement>(UNLIMITED)
val temperatureChannel = Channel<TemperatureMeasurement>(UNLIMITED)
val weightChannel = Channel<WeightMeasurement>(UNLIMITED)
private fun handlePeripheral(peripheral: BluetoothPeripheral) {
scope.launch {
try {
val mtu = peripheral.requestMtu(185)
Timber.i("MTU is $mtu")
peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
val rssi = peripheral.readRemoteRssi()
Timber.i("RSSI is $rssi")
peripheral.getCharacteristic(DIS_SERVICE_UUID, MANUFACTURER_NAME_CHARACTERISTIC_UUID)?.let {
val manufacturerName = peripheral.readCharacteristic(it).asString()
Timber.i("Received: $manufacturerName")
}
val model = peripheral.readCharacteristic(DIS_SERVICE_UUID, MODEL_NUMBER_CHARACTERISTIC_UUID).asString()
Timber.i("Received: $model")
val batteryLevel = peripheral.readCharacteristic(BTS_SERVICE_UUID, BATTERY_LEVEL_CHARACTERISTIC_UUID).asUInt8()
Timber.i("Battery level: $batteryLevel")
// Write Current Time if possible
peripheral.getCharacteristic(CTS_SERVICE_UUID, CURRENT_TIME_CHARACTERISTIC_UUID)?.let {
// If it has the write property we write the current time
if (it.supportsWritingWithResponse()) {
// Write the current time unless it is an Omron device
if (!peripheral.name.contains("BLEsmart_", true)) {
val parser = BluetoothBytesParser(ByteOrder.LITTLE_ENDIAN)
parser.setCurrentTime(Calendar.getInstance())
peripheral.writeCharacteristic(it, parser.value, WriteType.WITH_RESPONSE)
}
}
}
setupHRSnotifications(peripheral)
setupPLXnotifications(peripheral)
setupHTSnotifications(peripheral)
setupGLXnotifications(peripheral)
setupBLPnotifications(peripheral)
setupWSSnotifications(peripheral)
setupCTSnotifications(peripheral)
peripheral.getCharacteristic(CONTOUR_SERVICE_UUID, CONTOUR_CLOCK)?.let {
writeContourClock(peripheral)
}
} catch (e: IllegalArgumentException) {
Timber.e(e)
} catch (b: GattException) {
Timber.e(b)
}
}
}
private suspend fun writeContourClock(peripheral: BluetoothPeripheral) {
val calendar = Calendar.getInstance()
val offsetInMinutes = calendar.timeZone.rawOffset / 60000
calendar.timeZone = TimeZone.getTimeZone("UTC")
val parser = BluetoothBytesParser(ByteOrder.LITTLE_ENDIAN)
parser.setIntValue(1, BluetoothBytesParser.FORMAT_UINT8)
parser.setIntValue(calendar[Calendar.YEAR], BluetoothBytesParser.FORMAT_UINT16)
parser.setIntValue(calendar[Calendar.MONTH] + 1, BluetoothBytesParser.FORMAT_UINT8)
parser.setIntValue(calendar[Calendar.DAY_OF_MONTH], BluetoothBytesParser.FORMAT_UINT8)
parser.setIntValue(calendar[Calendar.HOUR_OF_DAY], BluetoothBytesParser.FORMAT_UINT8)
parser.setIntValue(calendar[Calendar.MINUTE], BluetoothBytesParser.FORMAT_UINT8)
parser.setIntValue(calendar[Calendar.SECOND], BluetoothBytesParser.FORMAT_UINT8)
parser.setIntValue(offsetInMinutes, BluetoothBytesParser.FORMAT_SINT16)
peripheral.writeCharacteristic(CONTOUR_SERVICE_UUID, CONTOUR_CLOCK, parser.value, WriteType.WITH_RESPONSE)
}
private suspend fun setupCTSnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(CTS_SERVICE_UUID, CURRENT_TIME_CHARACTERISTIC_UUID)?.let { currentTimeCharacteristic ->
peripheral.observe(currentTimeCharacteristic) { value ->
val parser = BluetoothBytesParser(value)
val currentTime = parser.dateTime
Timber.i("Received device time: %s", currentTime)
// Deal with Omron devices where we can only write currentTime under specific conditions
val name = peripheral.name
if (name.contains("BLEsmart_", true)) {
peripheral.getCharacteristic(BLP_SERVICE_UUID, BLP_MEASUREMENT_CHARACTERISTIC_UUID)?.let {
val isNotifying = peripheral.isNotifying(it)
if (isNotifying) currentTimeCounter++
// We can set device time for Omron devices only if it is the first notification and currentTime is more than 10 min from now
val interval = Math.abs(Calendar.getInstance().timeInMillis - currentTime.time)
if (currentTimeCounter == 1 && interval > 10 * 60 * 1000) {
parser.setCurrentTime(Calendar.getInstance())
scope.launch {
peripheral.writeCharacteristic(it, parser.value, WriteType.WITH_RESPONSE)
}
}
}
}
}
}
}
private suspend fun setupHRSnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(HRS_SERVICE_UUID, HRS_MEASUREMENT_CHARACTERISTIC_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = HeartRateMeasurement.fromBytes(value)
heartRateChannel.trySend(measurement)
Timber.d("%s", measurement)
}
}
}
private suspend fun setupWSSnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(WSS_SERVICE_UUID, WSS_MEASUREMENT_CHAR_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = WeightMeasurement.fromBytes(value)
weightChannel.trySend(measurement)
Timber.d("%s", measurement)
}
}
}
private suspend fun setupGLXnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(GLUCOSE_SERVICE_UUID, GLUCOSE_MEASUREMENT_CHARACTERISTIC_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = GlucoseMeasurement.fromBytes(value)
glucoseChannel.trySend(measurement)
Timber.d("%s", measurement)
}
}
peripheral.getCharacteristic(GLUCOSE_SERVICE_UUID, GLUCOSE_RECORD_ACCESS_POINT_CHARACTERISTIC_UUID)?.let {
val result = peripheral.observe(it) { value ->
Timber.d("record access response: ${value.asHexString()}")
}
if (result) {
writeGetAllGlucoseMeasurements(peripheral)
}
}
}
private suspend fun writeGetAllGlucoseMeasurements(peripheral: BluetoothPeripheral) {
val OP_CODE_REPORT_STORED_RECORDS: Byte = 1
val OPERATOR_ALL_RECORDS: Byte = 1
val command = byteArrayOf(OP_CODE_REPORT_STORED_RECORDS, OPERATOR_ALL_RECORDS)
peripheral.writeCharacteristic(GLUCOSE_SERVICE_UUID, GLUCOSE_RECORD_ACCESS_POINT_CHARACTERISTIC_UUID, command, WriteType.WITH_RESPONSE)
}
private suspend fun setupBLPnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(BLP_SERVICE_UUID, BLP_MEASUREMENT_CHARACTERISTIC_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = BloodPressureMeasurement.fromBytes(value)
bloodpressureChannel.trySend(measurement)
Timber.d("%s", measurement)
}
}
}
private suspend fun setupHTSnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(HTS_SERVICE_UUID, HTS_MEASUREMENT_CHARACTERISTIC_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = TemperatureMeasurement.fromBytes(value)
temperatureChannel.trySend(measurement)
Timber.d("%s", measurement)
}
}
}
private suspend fun setupPLXnotifications(peripheral: BluetoothPeripheral) {
peripheral.getCharacteristic(PLX_SERVICE_UUID, PLX_CONTINUOUS_MEASUREMENT_CHAR_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = PulseOximeterContinuousMeasurement.fromBytes(value)
if (measurement.spO2 <= 100 && measurement.pulseRate <= 220) {
pulseOxContinuousChannel.trySend(measurement)
}
Timber.d("%s", measurement)
}
}
peripheral.getCharacteristic(PLX_SERVICE_UUID, PLX_SPOT_MEASUREMENT_CHAR_UUID)?.let {
peripheral.observe(it) { value ->
val measurement = PulseOximeterSpotMeasurement.fromBytes(value)
pulseOxSpotChannel.trySend(measurement)
Timber.d("%s", measurement)
}
}
}
private fun startScanning() {
central.scanForPeripheralsWithServices(
supportedServices,
{ peripheral, scanResult ->
Timber.i("Found peripheral '${peripheral.name}' with RSSI ${scanResult.rssi}")
central.stopScan()
connectPeripheral(peripheral)
},
{ scanFailure -> Timber.e("scan failed with reason $scanFailure") })
}
private fun connectPeripheral(peripheral: BluetoothPeripheral) {
peripheral.observeBondState {
Timber.i("Bond state is $it")
}
scope.launch {
try {
central.connectPeripheral(peripheral)
} catch (connectionFailed: ConnectionFailedException) {
Timber.e("connection failed")
}
}
}
companion object {
// UUIDs for the Blood Pressure service (BLP)
private val BLP_SERVICE_UUID: UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb")
private val BLP_MEASUREMENT_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A35-0000-1000-8000-00805f9b34fb")
// UUIDs for the Health Thermometer service (HTS)
private val HTS_SERVICE_UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb")
private val HTS_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A1C-0000-1000-8000-00805f9b34fb")
// UUIDs for the Heart Rate service (HRS)
private val HRS_SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb")
private val HRS_MEASUREMENT_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb")
// UUIDs for the Device Information service (DIS)
private val DIS_SERVICE_UUID: UUID = UUID.fromString("0000180A-0000-1000-8000-00805f9b34fb")
private val MANUFACTURER_NAME_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A29-0000-1000-8000-00805f9b34fb")
private val MODEL_NUMBER_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A24-0000-1000-8000-00805f9b34fb")
// UUIDs for the Current Time service (CTS)
private val CTS_SERVICE_UUID: UUID = UUID.fromString("00001805-0000-1000-8000-00805f9b34fb")
private val CURRENT_TIME_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A2B-0000-1000-8000-00805f9b34fb")
// UUIDs for the Battery Service (BAS)
private val BTS_SERVICE_UUID: UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
// UUIDs for the Pulse Oximeter Service (PLX)
val PLX_SERVICE_UUID: UUID = UUID.fromString("00001822-0000-1000-8000-00805f9b34fb")
private val PLX_SPOT_MEASUREMENT_CHAR_UUID: UUID = UUID.fromString("00002a5e-0000-1000-8000-00805f9b34fb")
private val PLX_CONTINUOUS_MEASUREMENT_CHAR_UUID: UUID = UUID.fromString("00002a5f-0000-1000-8000-00805f9b34fb")
// UUIDs for the Weight Scale Service (WSS)
val WSS_SERVICE_UUID: UUID = UUID.fromString("0000181D-0000-1000-8000-00805f9b34fb")
private val WSS_MEASUREMENT_CHAR_UUID: UUID = UUID.fromString("00002A9D-0000-1000-8000-00805f9b34fb")
val GLUCOSE_SERVICE_UUID: UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb")
val GLUCOSE_MEASUREMENT_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A18-0000-1000-8000-00805f9b34fb")
val GLUCOSE_RECORD_ACCESS_POINT_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
val GLUCOSE_MEASUREMENT_CONTEXT_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A34-0000-1000-8000-00805f9b34fb")
// Contour Glucose Service
val CONTOUR_SERVICE_UUID: UUID = UUID.fromString("00000000-0002-11E2-9E96-0800200C9A66")
private val CONTOUR_CLOCK = UUID.fromString("00001026-0002-11E2-9E96-0800200C9A66")
private var instance: BluetoothHandler? = null
private val supportedServices = arrayOf(BLP_SERVICE_UUID, HTS_SERVICE_UUID, HRS_SERVICE_UUID, PLX_SERVICE_UUID, WSS_SERVICE_UUID, GLUCOSE_SERVICE_UUID)
@JvmStatic
@Synchronized
fun getInstance(context: Context): BluetoothHandler
{
if (instance == null) {
instance = BluetoothHandler(context.applicationContext)
}
return requireNotNull(instance)
}
}
@JvmField
var central: BluetoothCentralManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
Timber.plant(DebugTree())
central = BluetoothCentralManager(context)
central.observeConnectionState { peripheral, state ->
Timber.i("Peripheral '${peripheral.name}' is $state")
when (state) {
ConnectionState.CONNECTED -> handlePeripheral(peripheral)
ConnectionState.DISCONNECTED -> scope.launch {
delay(15000)
// Check if this peripheral should still be auto connected
if (central.getPeripheral(peripheral.address).getState() == ConnectionState.DISCONNECTED) {
central.autoConnectPeripheral(peripheral)
}
}
else -> {
}
}
}
central.observeAdapterState { state ->
when (state) {
BluetoothAdapter.STATE_ON -> startScanning()
}
}
startScanning()
}
}
@@ -0,0 +1,49 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_SFLOAT
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_SINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import org.hydev.wearsync.bles.ObservationUnit.MiligramPerDeciliter
import org.hydev.wearsync.bles.ObservationUnit.MmolPerLiter
import java.util.*
data class GlucoseMeasurement(
val value: Float?,
val unit: ObservationUnit,
val timestamp: Date?,
val sequenceNumber: Int,
val contextWillFollow: Boolean,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): GlucoseMeasurement
{
val parser = BluetoothBytesParser(value)
val flags: Int = parser.getIntValue(FORMAT_UINT8)
val timeOffsetPresent = flags and 0x01 > 0
val typeAndLocationPresent = flags and 0x02 > 0
val unit = if (flags and 0x04 > 0) MmolPerLiter else MiligramPerDeciliter
val contextWillFollow = flags and 0x10 > 0
val sequenceNumber = parser.getIntValue(FORMAT_UINT16)
var timestamp = parser.dateTime
if (timeOffsetPresent) {
val timeOffset: Int = parser.getIntValue(FORMAT_SINT16)
timestamp = Date(timestamp.time + timeOffset * 60000)
}
val multiplier = if (unit === MiligramPerDeciliter) 100000 else 1000
val glucoseValue = if (typeAndLocationPresent) parser.getFloatValue(FORMAT_SFLOAT) * multiplier else null
return GlucoseMeasurement(
unit = unit,
timestamp = timestamp,
sequenceNumber = sequenceNumber,
value = glucoseValue,
contextWillFollow = contextWillFollow
)
}
}
}
@@ -0,0 +1,51 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import java.nio.ByteOrder
import java.util.*
data class HeartRateMeasurement(
val pulse: Int,
val energyExpended: Int?,
val rrIntervals: IntArray,
val sensorContactStatus: SensorContactFeature,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): HeartRateMeasurement
{
val parser = BluetoothBytesParser(value, ByteOrder.LITTLE_ENDIAN)
val flags = parser.getIntValue(FORMAT_UINT8)
val pulse = if (flags and 0x01 == 0) parser.getIntValue(FORMAT_UINT8) else parser.getIntValue(FORMAT_UINT16)
val sensorContactStatusFlag = flags and 0x06 shr 1
val energyExpenditurePresent = flags and 0x08 > 0
val rrIntervalPresent = flags and 0x10 > 0
val sensorContactStatus = when (sensorContactStatusFlag) {
0, 1 -> SensorContactFeature.NotSupported
2 -> SensorContactFeature.SupportedNoContact
3 -> SensorContactFeature.SupportedAndContact
else -> SensorContactFeature.NotSupported
}
val energyExpended = if (energyExpenditurePresent) parser.getIntValue(FORMAT_UINT16) else null
val rrArray = ArrayList<Int>()
if (rrIntervalPresent) {
while (parser.offset < value.size) {
val rrInterval = parser.getIntValue(FORMAT_UINT16)
rrArray.add((rrInterval.toDouble() / 1024.0 * 1000.0).toInt())
}
}
return HeartRateMeasurement(
pulse = pulse,
energyExpended = energyExpended,
sensorContactStatus = sensorContactStatus,
rrIntervals = rrArray.toIntArray()
)
}
}
}
@@ -0,0 +1,334 @@
package org.hydev.wearsync.bles
import android.Manifest
import android.app.AlertDialog
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.*
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.welie.blessed.BluetoothPeripheral
import org.hydev.wearsync.bles.BluetoothHandler.Companion.getInstance
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow
import org.hydev.wearsync.R
import timber.log.Timber
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class MainActivity : AppCompatActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var measurementValue: TextView? = null
private val dateFormat: DateFormat = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.ENGLISH)
private val enableBluetoothRequest =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
// Bluetooth has been enabled
checkPermissions()
} else {
// Bluetooth has not been enabled, try again
askToEnableBluetooth()
}
}
private val bluetoothManager by lazy {
applicationContext
.getSystemService(BLUETOOTH_SERVICE)
as BluetoothManager
}
private fun askToEnableBluetooth() {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
enableBluetoothRequest.launch(enableBtIntent)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
measurementValue = findViewById<View>(R.id.bloodPressureValue) as TextView
registerReceiver(
locationServiceStateReceiver,
IntentFilter(LocationManager.MODE_CHANGED_ACTION)
)
}
override fun onResume() {
super.onResume()
if (bluetoothManager.adapter != null) {
if (!isBluetoothEnabled) {
askToEnableBluetooth()
} else {
checkPermissions()
}
} else {
Timber.e("This device has no Bluetooth hardware")
}
}
private val isBluetoothEnabled: Boolean
get() {
val bluetoothAdapter = bluetoothManager.adapter ?: return false
return bluetoothAdapter.isEnabled
}
private fun initBluetoothHandler() {
val bluetoothHandler = getInstance(applicationContext)
collectBloodPressure(bluetoothHandler)
collectHeartRate(bluetoothHandler)
collectGlucose(bluetoothHandler)
collectPulseOxContinuous(bluetoothHandler)
collectPulseOxSpot(bluetoothHandler)
collectTemperature(bluetoothHandler)
collectWeight(bluetoothHandler)
}
private fun collectBloodPressure(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.bloodpressureChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue!!.text = String.format(
Locale.ENGLISH,
"%.0f/%.0f %s, %.0f bpm\n%s\n",
it.systolic,
it.diastolic,
if (it.unit == ObservationUnit.MMHG) "mmHg" else "kpa",
it.pulseRate,
dateFormat.format(it.timestamp ?: Calendar.getInstance())
)
}
}
}
}
private fun collectGlucose(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.glucoseChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue!!.text = String.format(
Locale.ENGLISH,
"%.1f %s\n%s\n",
it.value,
if (it.unit === ObservationUnit.MmolPerLiter) "mmol/L" else "mg/dL",
dateFormat.format(it.timestamp ?: Calendar.getInstance()),
)
}
}
}
}
private fun collectHeartRate(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.heartRateChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue?.text = String.format(Locale.ENGLISH, "%d bpm", it.pulse)
}
}
}
}
private fun collectPulseOxContinuous(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.pulseOxContinuousChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue!!.text = String.format(
Locale.ENGLISH,
"SpO2 %d%%, Pulse %d bpm\n%s\n\nfrom %s",
it.spO2,
it.pulseRate,
dateFormat.format(Calendar.getInstance())
)
}
}
}
}
private fun collectPulseOxSpot(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.pulseOxSpotChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue!!.text = String.format(
Locale.ENGLISH,
"SpO2 %d%%, Pulse %d bpm\n",
it.spO2,
it.pulseRate
)
}
}
}
}
private fun collectTemperature(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.temperatureChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue?.text = String.format(
Locale.ENGLISH,
"%.1f %s (%s)\n%s\n",
it.temperatureValue,
if (it.unit == ObservationUnit.Celsius) "celsius" else "fahrenheit",
it.type,
dateFormat.format(it.timestamp ?: Calendar.getInstance())
)
}
}
}
}
private fun collectWeight(bluetoothHandler: BluetoothHandler) {
scope.launch {
bluetoothHandler.weightChannel.consumeAsFlow().collect {
withContext(Dispatchers.Main) {
measurementValue!!.text = String.format(
Locale.ENGLISH,
"%.1f %s\n%s\n",
it.weight, it.unit.toString(),
dateFormat.format(it.timestamp ?: Calendar.getInstance())
)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(locationServiceStateReceiver)
}
private val locationServiceStateReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action != null && action == LocationManager.MODE_CHANGED_ACTION) {
val isEnabled = areLocationServicesEnabled()
Timber.i("Location service state changed to: %s", if (isEnabled) "on" else "off")
checkPermissions()
}
}
}
private fun getPeripheral(peripheralAddress: String): BluetoothPeripheral {
val central = getInstance(applicationContext).central
return central.getPeripheral(peripheralAddress)
}
private fun checkPermissions() {
val missingPermissions = getMissingPermissions(requiredPermissions)
if (missingPermissions.isNotEmpty()) {
requestPermissions(missingPermissions, ACCESS_LOCATION_REQUEST)
} else {
checkIfLocationIsNeeded()
}
}
private fun getMissingPermissions(requiredPermissions: Array<String>): Array<String> {
val missingPermissions: MutableList<String> = ArrayList()
for (requiredPermission in requiredPermissions) {
if (applicationContext.checkSelfPermission(requiredPermission) != PackageManager.PERMISSION_GRANTED) {
missingPermissions.add(requiredPermission)
}
}
return missingPermissions.toTypedArray()
}
private val requiredPermissions: Array<String>
get() {
val targetSdkVersion = applicationInfo.targetSdkVersion
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && targetSdkVersion >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && targetSdkVersion >= Build.VERSION_CODES.Q) {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
} else arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION)
}
private fun checkIfLocationIsNeeded() {
val targetSdkVersion = applicationInfo.targetSdkVersion
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && targetSdkVersion < Build.VERSION_CODES.S) {
// Check if Location services are on because they are required to make scanning work for SDK < 31
if (checkLocationServices()) {
initBluetoothHandler()
}
} else {
initBluetoothHandler()
}
}
private fun areLocationServicesEnabled(): Boolean {
val locationManager =
applicationContext.getSystemService(LOCATION_SERVICE) as LocationManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
locationManager.isLocationEnabled
} else {
val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
val isNetworkEnabled =
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isGpsEnabled || isNetworkEnabled
}
}
private fun checkLocationServices(): Boolean {
return if (!areLocationServicesEnabled()) {
AlertDialog.Builder(this@MainActivity)
.setTitle("Location services are not enabled")
.setMessage("Scanning for Bluetooth peripherals requires locations services to be enabled.") // Want to enable?
.setPositiveButton("Enable") { dialogInterface, _ ->
dialogInterface.cancel()
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}
.setNegativeButton("Cancel") { dialog, _ ->
// if this button is clicked, just close
// the dialog box and do nothing
dialog.cancel()
}
.create()
.show()
false
} else {
true
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// Check if all permission were granted
var allGranted = true
for (result in grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false
break
}
}
if (allGranted) {
checkIfLocationIsNeeded()
} else {
AlertDialog.Builder(this@MainActivity)
.setTitle("Location permission is required for scanning Bluetooth peripherals")
.setMessage("Please grant permissions")
.setPositiveButton("Retry") { dialogInterface, _ ->
dialogInterface.cancel()
checkPermissions()
}
.create()
.show()
}
}
companion object {
private const val REQUEST_ENABLE_BT = 1
private const val ACCESS_LOCATION_REQUEST = 2
}
}
@@ -0,0 +1,17 @@
package org.hydev.wearsync.bles
enum class ObservationUnit(val notation: String, val mdc: String) {
BeatsPerMinute("bpm", "MDC_DIM_BEAT_PER_MIN"),
Celsius("\u00B0C", "MDC_DIM_DEGC"),
Fahrenheit("\u00B0F", "MDC_DIM_FAHR"),
Inches("inch", "MDC_DIM_INCH"),
Kilograms("Kg", "MDC_DIM_KILO_G"),
KgM2("kg/m2", "MDC_DIM_KG_PER_M_SQ"),
KPA("kPa", "MDC_DIM_KILO_PASCAL"),
Meters("m", "MDC_DIM_M"),
MiligramPerDeciliter("mg/dL", "MDC_DIM_MILLI_G_PER_DL"),
MmolPerLiter("mmol/L", "MDC_DIM_MILLI_MOLE_PER_L"),
MMHG("mmHg", "MDC_DIM_MMHG"),
Percent("%", "MDC_DIM_PERCENT"),
Pounds("lbs", "MDC_DIM_LB"),
}
@@ -0,0 +1,56 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_SFLOAT
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import java.util.*
data class PulseOximeterContinuousMeasurement(
val spO2: Float,
val pulseRate: Float,
val spO2Fast: Float?,
val pulseRateFast: Float?,
val spO2Slow: Float?,
val pulseRateSlow: Float?,
val pulseAmplitudeIndex: Float?,
val measurementStatus: Int?,
val sensorStatus: Int?,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): PulseOximeterContinuousMeasurement
{
val parser = BluetoothBytesParser(value)
val flags = parser.getIntValue(FORMAT_UINT8)
val spo2FastPresent = flags and 0x01 > 0
val spo2SlowPresent = flags and 0x02 > 0
val measurementStatusPresent = flags and 0x04 > 0
val sensorStatusPresent = flags and 0x08 > 0
val pulseAmplitudeIndexPresent = flags and 0x10 > 0
val spO2 = parser.getFloatValue(FORMAT_SFLOAT)
val pulseRate = parser.getFloatValue(FORMAT_SFLOAT)
val spO2Fast = if (spo2FastPresent) parser.getFloatValue(FORMAT_SFLOAT) else null
val pulseRateFast = if (spo2FastPresent) parser.getFloatValue(FORMAT_SFLOAT) else null
val spO2Slow = if (spo2SlowPresent) parser.getFloatValue(FORMAT_SFLOAT) else null
val pulseRateSlow = if (spo2SlowPresent) parser.getFloatValue(FORMAT_SFLOAT) else null
val measurementStatus = if (measurementStatusPresent) parser.getIntValue(FORMAT_UINT16) else null
val sensorStatus = if (sensorStatusPresent) parser.getIntValue(FORMAT_UINT16) else null
if (sensorStatusPresent) parser.getIntValue(FORMAT_UINT8) // Reserved byte
val pulseAmplitudeIndex = if (pulseAmplitudeIndexPresent) parser.getFloatValue(FORMAT_SFLOAT) else null
return PulseOximeterContinuousMeasurement(
spO2 = spO2,
pulseRate = pulseRate,
spO2Fast = spO2Fast,
pulseRateFast = pulseRateFast,
spO2Slow = spO2Slow,
pulseRateSlow = pulseRateSlow,
measurementStatus = measurementStatus,
sensorStatus = sensorStatus,
pulseAmplitudeIndex = pulseAmplitudeIndex
)
}
}
}
@@ -0,0 +1,49 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_SFLOAT
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import java.util.*
data class PulseOximeterSpotMeasurement(
val spO2: Float,
val pulseRate: Float,
val pulseAmplitudeIndex: Float?,
val timestamp: Date?,
val isDeviceClockSet: Boolean,
val measurementStatus: Int?,
val sensorStatus: Int?,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): PulseOximeterSpotMeasurement
{
val parser = BluetoothBytesParser(value)
val flags = parser.getIntValue(FORMAT_UINT8)
val timestampPresent = flags and 0x01 > 0
val measurementStatusPresent = flags and 0x02 > 0
val sensorStatusPresent = flags and 0x04 > 0
val pulseAmplitudeIndexPresent = flags and 0x08 > 0
val isDeviceClockSet = flags and 0x10 == 0
val spO2 = parser.getFloatValue(FORMAT_SFLOAT)
val pulseRate = parser.getFloatValue(FORMAT_SFLOAT)
val timestamp = if (timestampPresent) parser.dateTime else null
val measurementStatus = if (measurementStatusPresent) parser.getIntValue(FORMAT_UINT16) else null
val sensorStatus = if (sensorStatusPresent) parser.getIntValue(FORMAT_UINT16) else null
if (sensorStatusPresent) parser.getIntValue(FORMAT_UINT8) // Reserved byte
val pulseAmplitudeIndex = if (pulseAmplitudeIndexPresent) parser.getFloatValue(FORMAT_SFLOAT) else null
return PulseOximeterSpotMeasurement(
spO2 = spO2,
pulseRate = pulseRate,
measurementStatus = measurementStatus,
sensorStatus = sensorStatus,
pulseAmplitudeIndex = pulseAmplitudeIndex,
timestamp = timestamp,
isDeviceClockSet = isDeviceClockSet
)
}
}
}
@@ -0,0 +1,9 @@
package org.hydev.wearsync.bles
/**
* Enum that contains all sensor contact feature as specified here:
* https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.heart_rate_measurement.xml
*/
enum class SensorContactFeature {
NotSupported, SupportedNoContact, SupportedAndContact
}
@@ -0,0 +1,37 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_FLOAT
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import java.nio.ByteOrder
import java.util.*
data class TemperatureMeasurement(
val temperatureValue: Float,
val unit: ObservationUnit,
val timestamp: Date?,
val type: TemperatureType,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): TemperatureMeasurement
{
val parser = BluetoothBytesParser(value, ByteOrder.LITTLE_ENDIAN)
val flags = parser.getIntValue(FORMAT_UINT8)
val unit = if (flags and 0x01 > 0) ObservationUnit.Fahrenheit else ObservationUnit.Celsius
val timestampPresent = flags and 0x02 > 0
val typePresent = flags and 0x04 > 0
val temperatureValue = parser.getFloatValue(FORMAT_FLOAT)
val timestamp = if (timestampPresent) parser.dateTime else null
val type = if (typePresent) TemperatureType.fromValue(parser.getIntValue(FORMAT_UINT8)) else TemperatureType.Unknown
return TemperatureMeasurement(
unit = unit,
temperatureValue = temperatureValue,
timestamp = timestamp,
type = type
)
}
}
}
@@ -0,0 +1,15 @@
package org.hydev.wearsync.bles
enum class TemperatureType(val value: Int) {
Unknown(0), Armpit(1), Body(2), Ear(3), Finger(4), GastroIntestinalTract(5), Mouth(6), Rectum(7), Toe(8), Tympanum(9);
companion object {
fun fromValue(value: Int): TemperatureType
{
for (type in values()) {
if (type.value == value) return type
}
return Unknown
}
}
}
@@ -0,0 +1,48 @@
package org.hydev.wearsync.bles
import com.welie.blessed.BluetoothBytesParser
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT16
import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_UINT8
import org.hydev.wearsync.bles.ObservationUnit.Kilograms
import org.hydev.wearsync.bles.ObservationUnit.Pounds
import java.util.*
import kotlin.math.round
data class WeightMeasurement(
val weight: Float,
val unit: ObservationUnit,
val timestamp: Date?,
val userID: Int?,
val bmi: Float?,
val heightInMetersOrInches: Float?,
val createdAt: Date = Calendar.getInstance().time
) {
companion object {
fun fromBytes(value: ByteArray): WeightMeasurement
{
val parser = BluetoothBytesParser(value)
val flags = parser.getIntValue(FORMAT_UINT8)
val unit = if (flags and 0x01 > 0) Pounds else Kilograms
val timestampPresent = flags and 0x02 > 0
val userIDPresent = flags and 0x04 > 0
val bmiAndHeightPresent = flags and 0x08 > 0
val weightMultiplier = if (unit == Kilograms) 0.005f else 0.01f
val weight = parser.getIntValue(FORMAT_UINT16) * weightMultiplier
val timestamp = if (timestampPresent) parser.dateTime else null
val userID = if (userIDPresent) parser.getIntValue(FORMAT_UINT8) else null
val bmi = if (bmiAndHeightPresent) parser.getIntValue(FORMAT_UINT16) * 0.1f else null
val heightMultiplier = if (unit == Kilograms) 0.001f else 0.1f
val height = if (bmiAndHeightPresent) parser.getIntValue(FORMAT_UINT16) * heightMultiplier else null
return WeightMeasurement(
weight = round(weight * 100) / 100,
unit = unit,
timestamp = timestamp,
userID = userID,
bmi = bmi,
heightInMetersOrInches = height
)
}
}
}