Change HTS module

This commit is contained in:
Sylwester Zieliński
2022-02-11 15:36:58 +01:00
parent 771717224e
commit 382208454f
14 changed files with 213 additions and 162 deletions

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.cgms.data.CGMRepository
import no.nordicsemi.android.csc.data.CSCRepository
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.navigation.NavigationManager
import no.nordicsemi.android.nrftoolbox.ProfileDestination
import no.nordicsemi.android.nrftoolbox.view.HomeViewState
@@ -20,7 +21,8 @@ class HomeViewModel @Inject constructor(
private val navigationManager: NavigationManager,
cgmRepository: CGMRepository,
cscRepository: CSCRepository,
hrsRepository: HRSRepository
hrsRepository: HRSRepository,
htsRepository: HTSRepository
) : ViewModel() {
private val _state = MutableStateFlow(HomeViewState())
@@ -38,6 +40,10 @@ class HomeViewModel @Inject constructor(
hrsRepository.isRunning.onEach {
_state.value = _state.value.copy(isHRSModuleRunning = it)
}.launchIn(viewModelScope)
htsRepository.isRunning.onEach {
_state.value = _state.value.copy(isHTSModuleRunning = it)
}.launchIn(viewModelScope)
}
fun openProfile(destination: ProfileDestination) {

View File

@@ -120,6 +120,7 @@ internal class HRSManager(
override fun onServicesInvalidated() {
bodySensorLocationCharacteristic = null
heartRateCharacteristic = null
batteryLevelCharacteristic = null
}
}
}

View File

@@ -1,56 +1,6 @@
package no.nordicsemi.android.hts.data
import no.nordicsemi.android.material.you.RadioButtonItem
import no.nordicsemi.android.material.you.RadioGroupViewEntity
private const val DISPLAY_FAHRENHEIT = "°F"
private const val DISPLAY_CELSIUS = "°C"
private const val DISPLAY_KELVIN = "°K"
internal data class HTSData(
val temperatureValue: Float = 0f,
val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
val batteryLevel: Int = 0,
) {
fun displayTemperature(): String {
return when (temperatureUnit) {
TemperatureUnit.CELSIUS -> String.format("%.1f °C", temperatureValue)
TemperatureUnit.FAHRENHEIT -> String.format("%.1f °F", temperatureValue * 1.8f + 32f)
TemperatureUnit.KELVIN -> String.format("%.1f °K", temperatureValue + 273.15f)
}
}
fun getTemperatureUnit(label: String): TemperatureUnit {
return when (label) {
DISPLAY_CELSIUS -> TemperatureUnit.CELSIUS
DISPLAY_FAHRENHEIT -> TemperatureUnit.FAHRENHEIT
DISPLAY_KELVIN -> TemperatureUnit.KELVIN
else -> throw IllegalArgumentException("Can't create TemperatureUnit from this label: $label")
}
}
fun temperatureSettingsItems(): RadioGroupViewEntity {
return RadioGroupViewEntity(
TemperatureUnit.values().map { createRadioButtonItem(it) }
)
}
private fun createRadioButtonItem(unit: TemperatureUnit): RadioButtonItem {
return RadioButtonItem(displayTemperature(unit), unit == temperatureUnit)
}
private fun displayTemperature(unit: TemperatureUnit): String {
return when (unit) {
TemperatureUnit.CELSIUS -> DISPLAY_CELSIUS
TemperatureUnit.FAHRENHEIT -> DISPLAY_FAHRENHEIT
TemperatureUnit.KELVIN -> DISPLAY_KELVIN
}
}
}
internal enum class TemperatureUnit {
CELSIUS,
FAHRENHEIT,
KELVIN
}
)

View File

@@ -1,49 +1,70 @@
package no.nordicsemi.android.hts.data
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
import no.nordicsemi.android.service.BleManagerStatus
import android.bluetooth.BluetoothDevice
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.hts.repository.HTSManager
import no.nordicsemi.android.hts.repository.HTSService
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.service.ConnectingResult
import no.nordicsemi.android.service.ServiceManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class HTSRepository @Inject constructor() {
class HTSRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val serviceManager: ServiceManager,
) {
private var manager: HTSManager? = null
private val _data = MutableStateFlow(HTSData())
val data: StateFlow<HTSData> = _data
private val _data = MutableStateFlow<BleManagerResult<HTSData>>(ConnectingResult())
internal val data = _data.asStateFlow()
private val _command = MutableSharedFlow<DisconnectCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)
val command = _command.asSharedFlow()
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun setNewTemperature(temperature: Float) {
_data.tryEmit(_data.value.copy(temperatureValue = temperature))
fun launch(device: BluetoothDevice) {
serviceManager.startService(HTSService::class.java, device)
}
fun setBatteryLevel(batteryLevel: Int) {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
}
fun start(device: BluetoothDevice, scope: CoroutineScope) {
val manager = HTSManager(context, scope)
this.manager = manager
fun setTemperatureUnit(unit: TemperatureUnit) {
_data.tryEmit(_data.value.copy(temperatureUnit = unit))
}
manager.dataHolder.status.onEach {
_data.value = it
}.launchIn(scope)
fun sendDisconnectCommand() {
if (_command.subscriptionCount.value > 0) {
_command.tryEmit(DisconnectCommand)
} else {
_status.tryEmit(BleManagerStatus.DISCONNECTED)
scope.launch {
manager.start(device)
}
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
private suspend fun HTSManager.start(device: BluetoothDevice) {
try {
connect(device)
.useAutoConnect(false)
.retry(3, 100)
.suspend()
_isRunning.value = true
} catch (e: Exception) {
e.printStackTrace()
}
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(HTSData())
fun release() {
serviceManager.stopService(HTSService::class.java)
manager?.disconnect()?.enqueue()
manager = null
_isRunning.value = false
}
}

View File

@@ -24,70 +24,77 @@ package no.nordicsemi.android.hts.repository
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.ht.TemperatureMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.service.BatteryManager
import no.nordicsemi.android.hts.data.HTSData
import no.nordicsemi.android.service.ConnectionObserverAdapter
import java.util.*
val HTS_SERVICE_UUID: UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb")
private val HT_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A1C-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
internal class HTSManager internal constructor(
context: Context,
scope: CoroutineScope,
private val dataHolder: HTSRepository
) : BatteryManager(context, scope) {
private val scope: CoroutineScope,
) : BleManager(context) {
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private var htCharacteristic: BluetoothGattCharacteristic? = null
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
private val data = MutableStateFlow(HTSData())
val dataHolder = ConnectionObserverAdapter<HTSData>()
init {
setConnectionObserver(dataHolder)
data.onEach {
dataHolder.setValue(it)
}.launchIn(scope)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
dataHolder.setBatteryLevel(batteryLevel)
}
override fun getGattCallback(): BatteryManagerGattCallback {
override fun getGattCallback(): BleManagerGattCallback {
return HTManagerGattCallback()
}
private inner class HTManagerGattCallback : BatteryManagerGattCallback() {
private inner class HTManagerGattCallback : BleManagerGattCallback() {
override fun initialize() {
super.initialize()
setIndicationCallback(htCharacteristic)
.asValidResponseFlow<TemperatureMeasurementResponse>()
.onEach {
dataHolder.setNewTemperature(it.temperature)
data.tryEmit(data.value.copy(temperatureValue = it.temperature))
}.launchIn(scope)
enableIndications(htCharacteristic).enqueue()
scope.launch(exceptionHandler) {
enableIndications(htCharacteristic).suspend()
}
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>().onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
enableNotifications(batteryLevelCharacteristic).enqueue()
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(HTS_SERVICE_UUID)
if (service != null) {
htCharacteristic = service.getCharacteristic(HT_MEASUREMENT_CHARACTERISTIC_UUID)
gatt.getService(HTS_SERVICE_UUID)?.run {
htCharacteristic = getCharacteristic(HT_MEASUREMENT_CHARACTERISTIC_UUID)
}
gatt.getService(BATTERY_SERVICE_UUID)?.run {
batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)
}
return htCharacteristic != null
}
override fun onDeviceDisconnected() {
super.onDeviceDisconnected()
override fun onServicesInvalidated() {
htCharacteristic = null
batteryLevelCharacteristic = null
}
override fun onServicesInvalidated() {}
}
}

View File

@@ -1,25 +1,27 @@
package no.nordicsemi.android.hts.repository
import android.bluetooth.BluetoothDevice
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.service.DEVICE_DATA
import no.nordicsemi.android.service.NotificationService
import javax.inject.Inject
@AndroidEntryPoint
internal class HTSService : ForegroundBleService() {
internal class HTSService : NotificationService() {
@Inject
lateinit var repository: HTSRepository
override val manager: HTSManager by lazy { HTSManager(this, scope, repository) }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
override fun onCreate() {
super.onCreate()
val device = intent!!.getParcelableExtra<BluetoothDevice>(DEVICE_DATA)!!
repository.command.onEach {
stopSelf()
}.launchIn(scope)
repository.start(device, lifecycleScope)
return START_REDELIVER_INTENT
}
}

View File

@@ -18,7 +18,7 @@ import no.nordicsemi.android.theme.view.ScreenSection
import no.nordicsemi.android.theme.view.SectionTitle
@Composable
internal fun HTSContentView(state: HTSData, onEvent: (HTSScreenViewEvent) -> Unit) {
internal fun HTSContentView(state: HTSData, temperatureUnit: TemperatureUnit, onEvent: (HTSScreenViewEvent) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -30,21 +30,21 @@ internal fun HTSContentView(state: HTSData, onEvent: (HTSScreenViewEvent) -> Uni
Spacer(modifier = Modifier.height(16.dp))
RadioButtonGroup(viewEntity = state.temperatureSettingsItems()) {
onEvent(OnTemperatureUnitSelected(state.getTemperatureUnit(it.label)))
RadioButtonGroup(viewEntity = temperatureUnit.temperatureSettingsItems()) {
onEvent(OnTemperatureUnitSelected(it.label.toTemperatureUnit()))
}
}
Spacer(modifier = Modifier.height(16.dp))
ScreenSection {
SectionTitle(resId = R.drawable.ic_records, title = "Records")
SectionTitle(resId = R.drawable.ic_records, title = stringResource(id = R.string.hts_records_section))
Spacer(modifier = Modifier.height(16.dp))
KeyValueField(
stringResource(id = R.string.hts_temperature),
state.displayTemperature()
displayTemperature(state.temperatureValue, temperatureUnit)
)
}
@@ -65,5 +65,5 @@ internal fun HTSContentView(state: HTSData, onEvent: (HTSScreenViewEvent) -> Uni
@Preview
@Composable
private fun Preview() {
HTSContentView(state = HTSData()) { }
HTSContentView(state = HTSData(), TemperatureUnit.CELSIUS) { }
}

View File

@@ -0,0 +1,43 @@
package no.nordicsemi.android.hts.view
import no.nordicsemi.android.material.you.RadioButtonItem
import no.nordicsemi.android.material.you.RadioGroupViewEntity
private const val DISPLAY_FAHRENHEIT = "°F"
private const val DISPLAY_CELSIUS = "°C"
private const val DISPLAY_KELVIN = "°K"
internal fun displayTemperature(value: Float, temperatureUnit: TemperatureUnit): String {
return when (temperatureUnit) {
TemperatureUnit.CELSIUS -> String.format("%.1f °C", value)
TemperatureUnit.FAHRENHEIT -> String.format("%.1f °F", value * 1.8f + 32f)
TemperatureUnit.KELVIN -> String.format("%.1f °K", value + 273.15f)
}
}
internal fun String.toTemperatureUnit(): TemperatureUnit {
return when (this) {
DISPLAY_CELSIUS -> TemperatureUnit.CELSIUS
DISPLAY_FAHRENHEIT -> TemperatureUnit.FAHRENHEIT
DISPLAY_KELVIN -> TemperatureUnit.KELVIN
else -> throw IllegalArgumentException("Can't create TemperatureUnit from this label: $this")
}
}
internal fun TemperatureUnit.temperatureSettingsItems(): RadioGroupViewEntity {
return RadioGroupViewEntity(
TemperatureUnit.values().map { createRadioButtonItem(it, this) }
)
}
private fun createRadioButtonItem(unit: TemperatureUnit, selectedTemperatureUnit: TemperatureUnit): RadioButtonItem {
return RadioButtonItem(displayTemperature(unit), unit == selectedTemperatureUnit)
}
private fun displayTemperature(unit: TemperatureUnit): String {
return when (unit) {
TemperatureUnit.CELSIUS -> DISPLAY_CELSIUS
TemperatureUnit.FAHRENHEIT -> DISPLAY_FAHRENHEIT
TemperatureUnit.KELVIN -> DISPLAY_KELVIN
}
}

View File

@@ -10,7 +10,13 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.hts.R
import no.nordicsemi.android.hts.viewmodel.HTSViewModel
import no.nordicsemi.android.service.*
import no.nordicsemi.android.theme.view.BackIconAppBar
import no.nordicsemi.android.theme.view.scanner.DeviceConnectingView
import no.nordicsemi.android.theme.view.scanner.DeviceDisconnectedView
import no.nordicsemi.android.theme.view.scanner.NoDeviceView
import no.nordicsemi.android.theme.view.scanner.Reason
import no.nordicsemi.android.utils.exhaustive
@Composable
fun HTSScreen() {
@@ -18,15 +24,22 @@ fun HTSScreen() {
val state = viewModel.state.collectAsState().value
Column {
BackIconAppBar(stringResource(id = R.string.hts_title)) {
viewModel.onEvent(DisconnectEvent)
}
val navigateUp = { viewModel.onEvent(NavigateUp) }
BackIconAppBar(stringResource(id = R.string.hts_title), navigateUp)
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// when (state) {
// is DisplayDataState -> HTSContentView(state.data) { viewModel.onEvent(it) }
// LoadingState -> DeviceConnectingView()
// }.exhaustive
when (state.htsManagerState) {
NoDeviceState -> NoDeviceView()
is WorkingState -> when (state.htsManagerState.result) {
is ConnectingResult,
is ReadyResult -> DeviceConnectingView { viewModel.onEvent(DisconnectEvent) }
is DisconnectedResult -> DeviceDisconnectedView(Reason.USER, navigateUp)
is LinkLossResult -> DeviceDisconnectedView(Reason.LINK_LOSS, navigateUp)
is MissingServiceResult -> DeviceDisconnectedView(Reason.MISSING_SERVICE, navigateUp)
is SuccessResult -> HTSContentView(state.htsManagerState.result.data, state.temperatureUnit) { viewModel.onEvent(it) }
}
}.exhaustive
}
}
}

View File

@@ -1,9 +1,9 @@
package no.nordicsemi.android.hts.view
import no.nordicsemi.android.hts.data.TemperatureUnit
internal sealed class HTSScreenViewEvent
internal data class OnTemperatureUnitSelected(val value: TemperatureUnit) : HTSScreenViewEvent()
internal object DisconnectEvent : HTSScreenViewEvent()
internal object NavigateUp : HTSScreenViewEvent()

View File

@@ -1,9 +1,15 @@
package no.nordicsemi.android.hts.view
import no.nordicsemi.android.hts.data.HTSData
import no.nordicsemi.android.service.BleManagerResult
internal sealed class HTSViewState
internal data class HTSViewState(
val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
val htsManagerState: HTSManagerState = NoDeviceState
)
internal object LoadingState : HTSViewState()
internal sealed class HTSManagerState
internal data class DisplayDataState(val data: HTSData) : HTSViewState()
internal data class WorkingState(val result: BleManagerResult<HTSData>) : HTSManagerState()
internal object NoDeviceState : HTSManagerState()

View File

@@ -0,0 +1,7 @@
package no.nordicsemi.android.hts.view
internal enum class TemperatureUnit {
CELSIUS,
FAHRENHEIT,
KELVIN
}

View File

@@ -9,7 +9,6 @@ import no.nordicsemi.android.hts.repository.HTSService
import no.nordicsemi.android.hts.repository.HTS_SERVICE_UUID
import no.nordicsemi.android.hts.view.*
import no.nordicsemi.android.navigation.*
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.ServiceManager
import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.android.utils.getDevice
@@ -23,15 +22,20 @@ internal class HTSViewModel @Inject constructor(
private val navigationManager: NavigationManager
) : ViewModel() {
val state = repository.data.combine(repository.status) { data, status ->
// when (status) {
// BleManagerStatus.CONNECTING -> LoadingState
// BleManagerStatus.OK,
// BleManagerStatus.DISCONNECTED -> DisplayDataState(data)
// }
}.stateIn(viewModelScope, SharingStarted.Lazily, LoadingState)
private val _state = MutableStateFlow(HTSViewState())
val state = _state.asStateFlow()
init {
if (!repository.isRunning.value) {
requestBluetoothDevice()
}
repository.data.onEach {
_state.value = _state.value.copy(htsManagerState = WorkingState(it))
}.launchIn(viewModelScope)
}
private fun requestBluetoothDevice() {
navigationManager.navigateTo(ScannerDestinationId, UUIDArgument(HTS_SERVICE_UUID))
navigationManager.recentResult.onEach {
@@ -39,39 +43,29 @@ internal class HTSViewModel @Inject constructor(
handleArgs(it)
}
}.launchIn(viewModelScope)
repository.status.onEach {
if (it == BleManagerStatus.DISCONNECTED) {
navigationManager.navigateUp()
}
}.launchIn(viewModelScope)
}
private fun handleArgs(args: DestinationResult) {
when (args) {
is CancelDestinationResult -> navigationManager.navigateUp()
is SuccessDestinationResult -> serviceManager.startService(HTSService::class.java, args.getDevice())
is SuccessDestinationResult -> repository.launch(args.getDevice().device)
}.exhaustive
}
fun onEvent(event: HTSScreenViewEvent) {
when (event) {
DisconnectEvent -> onDisconnectButtonClick()
DisconnectEvent -> disconnect()
is OnTemperatureUnitSelected -> onTemperatureUnitSelected(event)
NavigateUp -> navigationManager.navigateUp()
}.exhaustive
}
private fun onDisconnectButtonClick() {
repository.sendDisconnectCommand()
repository.clear()
private fun disconnect() {
repository.release()
navigationManager.navigateUp()
}
private fun onTemperatureUnitSelected(event: OnTemperatureUnitSelected) {
repository.setTemperatureUnit(event.value)
}
override fun onCleared() {
super.onCleared()
repository.clear()
_state.value = _state.value.copy(temperatureUnit = event.value)
}
}

View File

@@ -7,4 +7,5 @@
<string name="hts_kelvin">%.1f °K</string>
<string name="hts_temperature">Temperature</string>
<string name="hts_records_section">Records</string>
</resources>