From 6064738792c18f2f5a5b0f9bb1d88396fe76ee70 Mon Sep 17 00:00:00 2001 From: Tebbe Ubben Date: Wed, 10 Sep 2025 06:29:35 +0200 Subject: [PATCH 01/13] Initial commit for Remora --- .../kotlin/app/aaps/di/PluginsListModule.kt | 7 + .../core/interfaces/db/PersistenceLayer.kt | 11 + .../kotlin/app/aaps/database/AppRepository.kt | 11 + .../aaps/database/daos/TemporaryTargetDao.kt | 3 + .../persistence/PersistenceLayerImpl.kt | 8 + plugins/main/build.gradle.kts | 2 + .../app/aaps/plugins/main/di/PluginsModule.kt | 1 + .../app/aaps/plugins/main/di/RemoraModule.kt | 12 + .../main/general/remora/RemoraFragment.kt | 26 + .../main/general/remora/RemoraPlugin.kt | 78 +++ .../main/general/remora/StatusDataBuilder.kt | 473 ++++++++++++++++++ .../src/main/res/layout/remora_fragment.xml | 19 + plugins/main/src/main/res/values/strings.xml | 6 + 13 files changed, 657 insertions(+) create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/di/RemoraModule.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraFragment.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraPlugin.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/StatusDataBuilder.kt create mode 100644 plugins/main/src/main/res/layout/remora_fragment.xml diff --git a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt index 30570b41c1de..783d58e21729 100644 --- a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt +++ b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt @@ -24,6 +24,7 @@ import app.aaps.plugins.main.general.actions.ActionsPlugin import app.aaps.plugins.main.general.food.FoodPlugin import app.aaps.plugins.main.general.overview.OverviewPlugin import app.aaps.plugins.main.general.persistentNotification.PersistentNotificationPlugin +import app.aaps.plugins.main.general.remora.RemoraPlugin import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin import app.aaps.plugins.main.general.themes.ThemeSwitcherPlugin import app.aaps.plugins.main.iob.iobCobCalculator.IobCobCalculatorPlugin @@ -288,6 +289,12 @@ abstract class PluginsListModule { @IntKey(280) abstract fun bindSmsCommunicatorPlugin(plugin: SmsCommunicatorPlugin): PluginBase + @Binds + @NotNSClient + @IntoMap + @IntKey(285) + abstract fun bindRemoraPlugin(plugin: RemoraPlugin): PluginBase + @Binds @APS @IntoMap diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt index f68f8519aff8..0fa1be50a04a 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt @@ -255,6 +255,16 @@ interface PersistenceLayer { */ fun getCarbsFromTimeToTimeExpanded(startTime: Long, endTime: Long, ascending: Boolean): List + /** + * Get carbs in time interval (eCarbs as single records) + * + * @param startTime from + * @param endTime to + * @param ascending sort order + * @return List of carbs + */ + fun getCarbsFromTimeToTimeNotExpanded(startTime: Long, endTime: Long, ascending: Boolean): List + /** * Get next changed record after id * @@ -1054,6 +1064,7 @@ interface PersistenceLayer { fun getTemporaryTargetByNSId(nsId: String): TT? fun getTemporaryTargetDataFromTime(timestamp: Long, ascending: Boolean): Single> + fun getTemporaryTargetDataFromTimeToTime(start: Long, end: Long, ascending: Boolean): Single> fun getTemporaryTargetDataIncludingInvalidFromTime(timestamp: Long, ascending: Boolean): Single> /** diff --git a/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt b/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt index 18f1cb0afd6a..857505f61a7a 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt @@ -206,6 +206,11 @@ class AppRepository @Inject internal constructor( .map { if (!ascending) it.reversed() else it } .subscribeOn(Schedulers.io()) + fun getTemporaryTargetDataFromTimeToTime(start: Long, end: Long, ascending: Boolean): Single> = + database.temporaryTargetDao.getTemporaryTargetDataFromTimeToTime(start, end) + .map { if (!ascending) it.reversed() else it } + .subscribeOn(Schedulers.io()) + fun getTemporaryTargetDataIncludingInvalidFromTime(timestamp: Long, ascending: Boolean): Single> = database.temporaryTargetDao.getTemporaryTargetDataIncludingInvalidFromTime(timestamp) .map { if (!ascending) it.reversed() else it } @@ -591,6 +596,12 @@ class AppRepository @Inject internal constructor( .sort() .map { if (!ascending) it.reversed() else it } .subscribeOn(Schedulers.io()) + .subscribeOn(Schedulers.io()) + + fun getCarbsDataFromTimeToTimeNotExpanded(from: Long, to: Long, ascending: Boolean): Single> = + database.carbsDao.getCarbsFromTimeToTimeExpandable(from, to) + .map { if (!ascending) it.reversed() else it } + .subscribeOn(Schedulers.io()) fun getCarbsIncludingInvalidFromTime(timestamp: Long, ascending: Boolean): Single> = database.carbsDao.getCarbsIncludingInvalidFromTime(timestamp) diff --git a/database/impl/src/main/kotlin/app/aaps/database/daos/TemporaryTargetDao.kt b/database/impl/src/main/kotlin/app/aaps/database/daos/TemporaryTargetDao.kt index 2a4b318dc03f..7b380680d9c3 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/daos/TemporaryTargetDao.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/daos/TemporaryTargetDao.kt @@ -37,6 +37,9 @@ internal interface TemporaryTargetDao : TraceableDao { @Query("SELECT * FROM $TABLE_TEMPORARY_TARGETS WHERE unlikely(timestamp >= :timestamp) AND likely(isValid = 1) AND likely(referenceId IS NULL) ORDER BY timestamp ASC") fun getTemporaryTargetDataFromTime(timestamp: Long): Single> + @Query("SELECT * FROM $TABLE_TEMPORARY_TARGETS WHERE unlikely(timestamp + duration >= :start) AND unlikely(timestamp <= :end) AND likely(isValid = 1) AND likely(referenceId IS NULL) ORDER BY timestamp ASC") + fun getTemporaryTargetDataFromTimeToTime(start: Long, end: Long): Single> + @Query("SELECT * FROM $TABLE_TEMPORARY_TARGETS WHERE unlikely(timestamp >= :timestamp) AND likely(referenceId IS NULL) ORDER BY timestamp ASC") fun getTemporaryTargetDataIncludingInvalidFromTime(timestamp: Long): Single> diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt index 3e4fa5f01ca1..bcb61b4ab7d6 100644 --- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt +++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt @@ -348,6 +348,11 @@ class PersistenceLayerImpl @Inject constructor( .map { list -> list.asSequence().map { it.fromDb() }.toList() } .blockingGet() + override fun getCarbsFromTimeToTimeNotExpanded(startTime: Long, endTime: Long, ascending: Boolean): List = + repository.getCarbsDataFromTimeToTimeNotExpanded(startTime, endTime, ascending) + .map { list -> list.asSequence().map { it.fromDb() }.toList() } + .blockingGet() + override fun getNextSyncElementCarbs(id: Long): Maybe> = repository.getNextSyncElementCarbs(id) .map { pair -> Pair(pair.first.fromDb(), pair.second.fromDb()) } @@ -1400,6 +1405,9 @@ class PersistenceLayerImpl @Inject constructor( override fun getTemporaryTargetDataFromTime(timestamp: Long, ascending: Boolean): Single> = repository.getTemporaryTargetDataFromTime(timestamp, ascending).map { list -> list.asSequence().map { it.fromDb() }.toList() } + override fun getTemporaryTargetDataFromTimeToTime(start: Long, end: Long, ascending: Boolean): Single> = + repository.getTemporaryTargetDataFromTimeToTime(start, end, ascending).map { list -> list.asSequence().map { it.fromDb() }.toList() } + override fun getTemporaryTargetDataIncludingInvalidFromTime(timestamp: Long, ascending: Boolean): Single> = repository.getTemporaryTargetDataIncludingInvalidFromTime(timestamp, ascending).map { list -> list.asSequence().map { it.fromDb() }.toList() } diff --git a/plugins/main/build.gradle.kts b/plugins/main/build.gradle.kts index 2e21a3531582..85f252a487f0 100644 --- a/plugins/main/build.gradle.kts +++ b/plugins/main/build.gradle.kts @@ -47,4 +47,6 @@ dependencies { ksp(libs.com.google.dagger.compiler) ksp(libs.com.google.dagger.android.processor) + + implementation("de.tebbeubben.remora:lib") } \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt index 07f0e904e96c..b2c5012e5c3b 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt @@ -21,6 +21,7 @@ import dagger.android.ContributesAndroidInjector SkinsModule::class, ActionsModule::class, OverviewModule::class, + RemoraModule::class ] ) diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/RemoraModule.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/RemoraModule.kt new file mode 100644 index 000000000000..3a60442bf75d --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/RemoraModule.kt @@ -0,0 +1,12 @@ +package app.aaps.plugins.main.di + +import app.aaps.plugins.main.general.remora.RemoraFragment +import dagger.Module +import dagger.android.ContributesAndroidInjector + +@Module +@Suppress("unused") +abstract class RemoraModule { + + @ContributesAndroidInjector abstract fun contributesRemoraFragment(): RemoraFragment +} \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraFragment.kt new file mode 100644 index 000000000000..cc3f8bd309fe --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraFragment.kt @@ -0,0 +1,26 @@ +package app.aaps.plugins.main.general.remora + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import app.aaps.plugins.main.R +import dagger.android.support.DaggerFragment +import de.tebbeubben.remora.lib.ui.RemoraLibActivity + +class RemoraFragment : DaggerFragment() { + + @SuppressLint("MissingInflatedId") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.remora_fragment, container, false) + val launchActivityButton: Button = view.findViewById(R.id.launch_activity_button) + launchActivityButton.setOnClickListener { + val intent = Intent(activity, RemoraLibActivity::class.java) + startActivity(intent) + } + return view + } +} \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraPlugin.kt new file mode 100644 index 000000000000..a8618f160d27 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/RemoraPlugin.kt @@ -0,0 +1,78 @@ +package app.aaps.plugins.main.general.remora + +import android.content.Context +import app.aaps.core.data.plugin.PluginType +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.events.EventUpdateOverviewGraph +import app.aaps.plugins.main.R +import de.tebbeubben.remora.lib.LibraryMode +import de.tebbeubben.remora.lib.RemoraLib +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemoraPlugin @Inject constructor( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + context: Context, + private val statusDataBuilder: StatusDataBuilder, + val activePlugin: ActivePlugin, + val aapsSchedulers: AapsSchedulers +) : PluginBase( + PluginDescription() + .mainType(PluginType.GENERAL) + .fragmentClass(RemoraFragment::class.java.name) + .pluginIcon(de.tebbeubben.remora.lib.R.drawable.remora_logo) + .pluginName(R.string.remora) + .shortName(R.string.remora_shortname) + .description(R.string.description_remora), + aapsLogger, rh +) { + + private var scope = CoroutineScope(Dispatchers.Default) + + init { + RemoraLib.initialize(context, LibraryMode.MAIN) + } + + override fun onStart() { + scope = CoroutineScope(Dispatchers.Default) + scope.launch { + RemoraLib.instance.startup() + withContext(Dispatchers.IO) { + activePlugin.activeOverview.overviewBus + .toObservable(EventUpdateOverviewGraph::class.java) + .debounce(1L, TimeUnit.SECONDS) + .asFlow() + .collectLatest { + val statusData = statusDataBuilder.constructStatusData() + if (statusData != null) { + RemoraLib.instance.shareStatus(statusData) + } + } + } + + } + } + + override fun onStop() { + scope.cancel() + runBlocking { + RemoraLib.instance.shutdown() + } + } +} \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/StatusDataBuilder.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/StatusDataBuilder.kt new file mode 100644 index 000000000000..92915879ef5e --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/remora/StatusDataBuilder.kt @@ -0,0 +1,473 @@ +package app.aaps.plugins.main.general.remora + +import app.aaps.core.data.model.BS +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.data.model.RM +import app.aaps.core.data.model.SourceSensor +import app.aaps.core.data.model.TB +import app.aaps.core.data.model.TE +import app.aaps.core.data.model.TT +import app.aaps.core.data.model.TrendArrow +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.aps.IobTotal +import app.aaps.core.interfaces.aps.Loop +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.iob.GlucoseStatusProvider +import app.aaps.core.interfaces.iob.IobCobCalculator +import app.aaps.core.interfaces.overview.LastBgData +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.receivers.ReceiverStatusStore +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.utils.HardLimits +import app.aaps.core.interfaces.utils.TrendCalculator +import app.aaps.core.keys.UnitDoubleKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.objects.extensions.combine +import app.aaps.core.objects.extensions.target +import app.aaps.core.objects.profile.ProfileSealed +import de.tebbeubben.remora.lib.model.RemoraStatusData +import de.tebbeubben.remora.lib.model.RemoraStatusData.BasalDataPoint +import de.tebbeubben.remora.lib.model.RemoraStatusData.BgReading +import de.tebbeubben.remora.lib.model.RemoraStatusData.Bolus +import de.tebbeubben.remora.lib.model.RemoraStatusData.BucketedDataPoint +import de.tebbeubben.remora.lib.model.RemoraStatusData.CarbEntry +import de.tebbeubben.remora.lib.model.RemoraStatusData.Deltas +import de.tebbeubben.remora.lib.model.RemoraStatusData.DisplayBg +import de.tebbeubben.remora.lib.model.RemoraStatusData.ExtendedBolus +import de.tebbeubben.remora.lib.model.RemoraStatusData.Prediction +import de.tebbeubben.remora.lib.model.RemoraStatusData.ProfileSwitch +import de.tebbeubben.remora.lib.model.RemoraStatusData.TargetDataPoint +import de.tebbeubben.remora.lib.model.RemoraStatusData.TemporaryBasal +import de.tebbeubben.remora.lib.model.RemoraStatusData.TemporaryTarget +import de.tebbeubben.remora.lib.model.RemoraStatusData.TemporaryTargetReason +import de.tebbeubben.remora.lib.model.RemoraStatusData.TherapyEvent +import de.tebbeubben.remora.lib.model.RemoraStatusData.TherapyEventType +import org.joda.time.DateTimeZone +import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +class StatusDataBuilder @Inject constructor( + private val persistenceLayer: PersistenceLayer, + private val iobCobCalculator: IobCobCalculator, + private val activePlugin: ActivePlugin, + private val loop: Loop, + private val config: Config, + private val profileFunction: ProfileFunction, + private val rxBus: RxBus, + private val hardLimits: HardLimits, + private val rh: ResourceHelper, + private val lastBgData: LastBgData, + private val glucoseStatusProvider: GlucoseStatusProvider, + private val preferences: Preferences, + private val trendCalculator: TrendCalculator, + private val receiverStatusStore: ReceiverStatusStore, + private val profileUtil: ProfileUtil +) { + + fun constructStatusData(): RemoraStatusData? { + val nowInstant = Clock.System.now() + val nowMillis = nowInstant.toEpochMilliseconds() + val endMillis = (nowInstant + 6.hours).toEpochMilliseconds() + val startMillis = (nowInstant - 24.hours - 2.minutes).toEpochMilliseconds() + + val profileSwitch = persistenceLayer.getEffectiveProfileSwitchActiveAt(nowMillis) ?: return null + val profile = profileFunction.getProfile(nowMillis) ?: return null + + val basalData = iobCobCalculator.getBasalData(profile, nowMillis) + + val tempTarget = persistenceLayer.getTemporaryTargetActiveAt(nowMillis) + + val shortStatus = RemoraStatusData.Short( + timestamp = nowInstant, + timezone = DateTimeZone.getDefault().id, + displayCob = iobCobCalculator.getCobInfo("Remora").displayCob?.toFloat(), + futureCarbs = iobCobCalculator.getCobInfo("Remora").futureCarbs.toFloat(), + activeProfile = profileSwitch.originalProfileName, + activeProfilePercentage = profileSwitch.originalPercentage, + activeProfileShift = profileSwitch.originalTimeshift.milliseconds.inWholeHours.toInt(), + activeProfileStart = Instant.fromEpochMilliseconds(profileSwitch.timestamp), + activeProfileDuration = if (profileSwitch.originalDuration != 0L) profileSwitch.originalDuration.milliseconds else null, + usesMgdl = profileFunction.getUnits() == GlucoseUnit.MGDL, + lowBgThreshold = profileUtil.convertToMgdl(preferences.get(UnitDoubleKey.OverviewLowMark), profileUtil.units).toFloat(), + highBgThreshold = profileUtil.convertToMgdl(preferences.get(UnitDoubleKey.OverviewHighMark), profileUtil.units).toFloat(), + displayBg = lastBgData.lastBg()?.let { bg -> + DisplayBg( + timestamp = Instant.fromEpochMilliseconds(bg.timestamp), + value = bg.value.toFloat(), + smoothedValue = bg.smoothed?.toFloat(), + trendArrow = convertAapsTrendToRemoraTrend(trendCalculator.getTrendArrow(iobCobCalculator.ads) ?: TrendArrow.NONE), + deltas = glucoseStatusProvider.glucoseStatusData?.let { status -> + Deltas( + delta = status.delta.toFloat(), + shortAverageDelta = status.shortAvgDelta.toFloat(), + longAverageDelta = status.longAvgDelta.toFloat() + ) + } + ) + }, + bolusIob = iobCobCalculator.calculateIobFromBolus().iob.toFloat(), + basalIob = iobCobCalculator.calculateIobFromTempBasalsIncludingConvertedExtended().basaliob.toFloat(), + reservoirLevel = activePlugin.activePump.reservoirLevel.let { if (it >= 0) it.toFloat() else null }, // Assuming -1 or similar for unknown + isReservoirLevelMax = activePlugin.activePump.let { pump -> + pump.reservoirLevel >= pump.pumpDescription.maxResorvoirReading && pump.pumpDescription.isPatchPump + }, + sensorChangedAt = persistenceLayer.getLastTherapyRecordUpToNow(TE.Type.SENSOR_CHANGE)?.timestamp?.let { Instant.fromEpochMilliseconds(it) }, + sensorBatteryLevel = activePlugin.activeBgSource.sensorBatteryLevel.let { if (it >= 0) it else null }, + batteryChangedAt = persistenceLayer.getLastTherapyRecordUpToNow(TE.Type.PUMP_BATTERY_CHANGE)?.timestamp?.let { Instant.fromEpochMilliseconds(it) }, + batteryLevel = activePlugin.activePump.batteryLevel.let { if (it >= 0) it else null }, + cannulaChangedAt = persistenceLayer.getLastTherapyRecordUpToNow(TE.Type.CANNULA_CHANGE)?.timestamp?.let { Instant.fromEpochMilliseconds(it) }, + podChangedAt = if (activePlugin.activePump.pumpDescription.isPatchPump) { + persistenceLayer.getLastTherapyRecordUpToNow(TE.Type.CANNULA_CHANGE)?.timestamp?.let { Instant.fromEpochMilliseconds(it) } + } else null, + insulinChangedAt = persistenceLayer.getLastTherapyRecordUpToNow(TE.Type.INSULIN_CHANGE)?.timestamp?.let { Instant.fromEpochMilliseconds(it) }, + runningMode = convertAapsRmModeToRemoraMode(loop.runningMode), + runningModeStart = Instant.fromEpochMilliseconds(loop.runningModeRecord.timestamp), + runningModeDuration = if (loop.runningModeRecord.duration == 0L) null else loop.runningModeRecord.duration.milliseconds, + baseBasal = basalData.basal.toFloat(), + tempBasalAbsolute = if (basalData.isTempBasalRunning) basalData.tempBasalAbsolute.toFloat() else null, + target = tempTarget?.target()?.toFloat() ?: profile.getTargetMgdl().toFloat(), + tempTargetStart = tempTarget?.timestamp?.let { Instant.fromEpochMilliseconds(it) }, + tempTargetDuration = tempTarget?.duration?.milliseconds, + autosensRatio = iobCobCalculator.ads.getAutosensDataAtTime(nowMillis)?.autosensResult?.ratio?.toFloat() ?: 1f, + deviceBattery = receiverStatusStore.batteryLevel, + isCharging = receiverStatusStore.isCharging + ) + + return RemoraStatusData( + short = shortStatus, + isFakingTemps = activePlugin.activePump.isFakingTempsByExtendedBoluses, + bucketedData = getBucketedData(nowMillis, startMillis, endMillis), + basalData = getBasalData(startMillis, endMillis), + targetData = getTargetData(startMillis, endMillis), + predictions = getPredictions(), + bgReadings = getBgReadings(), + boluses = getBoluses(startMillis, endMillis), + carbs = getCarbs(startMillis, endMillis), + profileSwitches = getProfileSwitches(startMillis, endMillis), + therapyEvents = getTherapyEvents(startMillis, endMillis), + temporaryBasals = getTemporaryBasals(startMillis, endMillis), + temporaryTargets = getTemporaryTargets(startMillis, endMillis), + extendedBoluses = getExtendedBoluses(startMillis, endMillis), + profiles = getProfiles(), + runningModes = getRunningModeData(startMillis, endMillis) + ) + } + + private fun convertAapsRmModeToRemoraMode(mode: RM.Mode): RemoraStatusData.RunningMode = when (mode) { + RM.Mode.OPEN_LOOP -> RemoraStatusData.RunningMode.OPEN_LOOP + RM.Mode.CLOSED_LOOP -> RemoraStatusData.RunningMode.CLOSED_LOOP + RM.Mode.CLOSED_LOOP_LGS -> RemoraStatusData.RunningMode.CLOSED_LOOP_LGS + RM.Mode.DISABLED_LOOP -> RemoraStatusData.RunningMode.DISABLED_LOOP + RM.Mode.SUPER_BOLUS -> RemoraStatusData.RunningMode.SUPER_BOLUS + RM.Mode.DISCONNECTED_PUMP -> RemoraStatusData.RunningMode.DISCONNECTED_PUMP + RM.Mode.SUSPENDED_BY_PUMP -> RemoraStatusData.RunningMode.SUSPENDED_BY_PUMP + RM.Mode.SUSPENDED_BY_USER -> RemoraStatusData.RunningMode.SUSPENDED_BY_USER + RM.Mode.RESUME -> error("Invalid mode") + } + + private fun getRunningModeData(start: Long, end: Long): List = + generateSequence(start) { it + T.mins(5).msecs() } + .takeWhile { it <= end } + .map { time -> + val mode = persistenceLayer.getRunningModeActiveAt(time) + RemoraStatusData.RunningModeDataPoint( + timestamp = Instant.fromEpochMilliseconds(time), + runningMode = when (mode.mode) { + RM.Mode.OPEN_LOOP -> RemoraStatusData.RunningMode.OPEN_LOOP + RM.Mode.CLOSED_LOOP -> RemoraStatusData.RunningMode.CLOSED_LOOP + RM.Mode.CLOSED_LOOP_LGS -> RemoraStatusData.RunningMode.CLOSED_LOOP_LGS + RM.Mode.DISABLED_LOOP -> RemoraStatusData.RunningMode.DISABLED_LOOP + RM.Mode.SUPER_BOLUS -> RemoraStatusData.RunningMode.SUPER_BOLUS + RM.Mode.DISCONNECTED_PUMP -> RemoraStatusData.RunningMode.DISCONNECTED_PUMP + RM.Mode.SUSPENDED_BY_PUMP -> RemoraStatusData.RunningMode.SUSPENDED_BY_PUMP + RM.Mode.SUSPENDED_BY_USER -> RemoraStatusData.RunningMode.SUSPENDED_BY_USER + RM.Mode.RESUME -> error("Invalid mode") + } + ) + } + .fold(mutableListOf()) { acc, dp -> + if (acc.isEmpty() || acc.last().runningMode != dp.runningMode) acc += dp + acc + } + + private fun convertAapsTrendToRemoraTrend(aapsTrend: TrendArrow): RemoraStatusData.TrendArrow { + return when (aapsTrend) { + TrendArrow.NONE -> RemoraStatusData.TrendArrow.NONE + TrendArrow.TRIPLE_UP -> RemoraStatusData.TrendArrow.TRIPLE_UP + TrendArrow.DOUBLE_UP -> RemoraStatusData.TrendArrow.DOUBLE_UP + TrendArrow.SINGLE_UP -> RemoraStatusData.TrendArrow.SINGLE_UP + TrendArrow.FORTY_FIVE_UP -> RemoraStatusData.TrendArrow.FORTY_FIVE_UP + TrendArrow.FLAT -> RemoraStatusData.TrendArrow.FLAT + TrendArrow.FORTY_FIVE_DOWN -> RemoraStatusData.TrendArrow.FORTY_FIVE_DOWN + TrendArrow.SINGLE_DOWN -> RemoraStatusData.TrendArrow.SINGLE_DOWN + TrendArrow.DOUBLE_DOWN -> RemoraStatusData.TrendArrow.DOUBLE_DOWN + TrendArrow.TRIPLE_DOWN -> RemoraStatusData.TrendArrow.TRIPLE_DOWN + } + } + + private fun getProfiles(): List { + val profileStore = activePlugin.activeProfileSource.profile + return profileStore?.getProfileList()?.filter { profile -> + val profileToCheck = activePlugin.activeProfileSource.profile?.getSpecificProfile(profile.toString()) + profileToCheck != null && + ProfileSealed.Pure(profileToCheck, activePlugin) + .isValid("ProfileSwitch", activePlugin.activePump, config, rh, rxBus, hardLimits, false) + .isValid + }?.map { it.toString() } ?: emptyList() + } + + private fun TT.toModel() = TemporaryTarget( + timestamp = Instant.fromEpochMilliseconds(timestamp), + id = id, + duration = duration.milliseconds, + target = target().toFloat(), + reason = when (reason) { + TT.Reason.CUSTOM -> TemporaryTargetReason.CUSTOM + TT.Reason.HYPOGLYCEMIA -> TemporaryTargetReason.HYPOGLYCEMIA + TT.Reason.ACTIVITY -> TemporaryTargetReason.ACTIVITY + TT.Reason.EATING_SOON -> TemporaryTargetReason.EATING_SOON + TT.Reason.AUTOMATION -> TemporaryTargetReason.AUTOMATION + TT.Reason.WEAR -> TemporaryTargetReason.WEAR + } + ) + + private fun getTemporaryTargets(start: Long, end: Long): List = + persistenceLayer.getTemporaryTargetDataFromTimeToTime(start, end, true).blockingGet().map { it.toModel() } + + private fun TB.toModel() = TemporaryBasal( + timestamp = Instant.fromEpochMilliseconds(timestamp), + id = id, + isAbsolute = isAbsolute, + rate = rate.toFloat(), + duration = duration.milliseconds + ) + + private fun getTemporaryBasals(start: Long, end: Long): List = + persistenceLayer.getTemporaryBasalsActiveBetweenTimeAndTime(start, end).map { it.toModel() } + + private fun getTherapyEvents(start: Long, end: Long): List = + persistenceLayer.getTherapyEventDataFromToTime(start, end).blockingGet().map { te -> + TherapyEvent( + timestamp = Instant.fromEpochMilliseconds(te.timestamp), + id = te.id, + duration = te.duration.milliseconds, + type = when (te.type) { + TE.Type.NONE -> TherapyEventType.NONE + TE.Type.CANNULA_CHANGE -> TherapyEventType.CANNULA_CHANGE + TE.Type.INSULIN_CHANGE -> TherapyEventType.INSULIN_CHANGE + TE.Type.PUMP_BATTERY_CHANGE -> TherapyEventType.PUMP_BATTERY_CHANGE + TE.Type.SENSOR_CHANGE -> TherapyEventType.SENSOR_CHANGE + TE.Type.SENSOR_STARTED -> TherapyEventType.SENSOR_STARTED + TE.Type.SENSOR_STOPPED -> TherapyEventType.SENSOR_STOPPED + TE.Type.FINGER_STICK_BG_VALUE -> TherapyEventType.FINGER_STICK_BG_VALUE + TE.Type.EXERCISE -> TherapyEventType.EXERCISE + TE.Type.ANNOUNCEMENT -> TherapyEventType.ANNOUNCEMENT + TE.Type.QUESTION -> TherapyEventType.QUESTION + TE.Type.NOTE -> TherapyEventType.NOTE + TE.Type.APS_OFFLINE -> TherapyEventType.APS_OFFLINE + else -> TherapyEventType.NONE + }, + note = te.note, + glucose = te.glucose?.toFloat(), + glucoseType = when (te.glucoseType) { + TE.MeterType.FINGER -> RemoraStatusData.MeterType.FINGER + TE.MeterType.SENSOR -> RemoraStatusData.MeterType.SENSOR + TE.MeterType.MANUAL -> RemoraStatusData.MeterType.MANUAL + else -> null + }, + isMgdl = profileFunction.getUnits() == GlucoseUnit.MGDL + ) + } + + private fun getBucketedData(now: Long, start: Long, end: Long): List { + val existingBucketedData = iobCobCalculator.ads.bucketedData ?: emptyList() + + val existingBuckets = existingBucketedData.map { bucket -> + BucketedDataPoint( + timestamp = Instant.fromEpochMilliseconds(bucket.timestamp), + bgData = RemoraStatusData.BgData( + value = (bucket.smoothed ?: bucket.value).toFloat(), + filledGap = bucket.filledGap + ), + insulinData = getInsulinDataFor(bucket.timestamp), + autosensData = getAutosensDataFor(bucket.timestamp) + ) + } + val firstFutureBucketTime = existingBucketedData.maxByOrNull { it.timestamp }?.timestamp?.plus(T.mins(5).msecs()) ?: now + val missingFutureBucketTimestamps = generateSequence(firstFutureBucketTime) { it + T.mins(5).msecs() }.takeWhile { it <= end }.toList() + + val lastPastBucketTime = existingBucketedData.minByOrNull { it.timestamp }?.timestamp?.minus(T.mins(5).msecs()) ?: (now - T.mins(5).msecs()) + val missingPastBucketTimestamps = generateSequence(lastPastBucketTime) { it - T.mins(5).msecs() }.takeWhile { it >= start }.toList() + + val missingBuckets = (missingFutureBucketTimestamps + missingPastBucketTimestamps) + .map { time -> + BucketedDataPoint( + timestamp = Instant.fromEpochMilliseconds(time), + bgData = null, + insulinData = getInsulinDataFor(time), + autosensData = null + ) + } + + return (existingBuckets + missingBuckets) + } + + private fun getAutosensDataFor(time: Long) = + iobCobCalculator.ads.getAutosensDataAtTime(time)?.let { + RemoraStatusData.AutosensData( + ratio = it.autosensResult.ratio.toFloat(), + cob = it.cob.toFloat(), + carbsFromBolus = it.carbsFromBolus.toFloat(), + bgi = it.bgi.toFloat(), + deviation = it.deviation.toFloat(), + type = when (it.type) { + "", "non-meal" -> when (it.pastSensitivity) { + "C" -> RemoraStatusData.AutosensType.CSF + "+" -> RemoraStatusData.AutosensType.POSITIVE + "-" -> RemoraStatusData.AutosensType.NEGATIVE + else -> RemoraStatusData.AutosensType.NEUTRAL + } + "uam" -> RemoraStatusData.AutosensType.UAM + "csf" -> RemoraStatusData.AutosensType.CSF + else -> RemoraStatusData.AutosensType.NEUTRAL + } + ) + } + + private fun getInsulinDataFor(time: Long) = + profileFunction.getProfile(time)?.let { profile -> + val iobTotal = iobCobCalculator.calculateFromTreatmentsAndTemps(time, profile) + val baseBasalIob = iobCobCalculator.calculateAbsoluteIobFromBaseBasals(time) + val absIob = IobTotal.combine(iobTotal, baseBasalIob).iob + RemoraStatusData.InsulinData( + iob = iobTotal.iob.toFloat(), + absoluteIob = absIob.toFloat(), + insulinActivity = iobTotal.activity.toFloat() + ) + } + + private fun getBasalData(start: Long, end: Long): List = + generateSequence(start) { it + T.mins(5).msecs() } + .takeWhile { it <= end } + .mapNotNull { time -> + val profile = profileFunction.getProfile(time) + if (profile == null) return@mapNotNull null + val basalData = iobCobCalculator.getBasalData(profile, time) + BasalDataPoint( + timestamp = Instant.fromEpochMilliseconds(time), + baselineBasal = basalData.basal.toFloat(), + tempBasalAbsolute = if (basalData.isTempBasalRunning) basalData.tempBasalAbsolute.toFloat() else null + ) + } + .fold(mutableListOf()) { acc, dp -> + if (acc.isEmpty() || + acc.last().baselineBasal != dp.baselineBasal || + acc.last().tempBasalAbsolute != dp.tempBasalAbsolute + ) acc += dp + acc + } + + private fun getTargetData(start: Long, end: Long): List { + val profile = profileFunction.getProfile() ?: return emptyList() + return generateSequence(start) { it + T.mins(1).msecs() } + .takeWhile { it <= end } + .map { time -> + val tt = persistenceLayer.getTemporaryTargetActiveAt(time) + TargetDataPoint( + timestamp = Instant.fromEpochMilliseconds(time), + target = (tt?.target() ?: ((profile.getTargetLowMgdl(time) + profile.getTargetHighMgdl(time)) / 2)).toFloat() + ) + } + .fold(mutableListOf()) { acc, dp -> + if (acc.isEmpty() || acc.last().target != dp.target) acc += dp + acc + } + } + + private fun getBgReadings(): List = + iobCobCalculator.ads.bgReadings.sortedBy { it.timestamp }.map { bgReading -> + BgReading( + timestamp = Instant.fromEpochMilliseconds(bgReading.timestamp), + id = bgReading.id, + value = bgReading.value.toFloat(), + trendArrow = convertAapsTrendToRemoraTrend(bgReading.trendArrow) + ) + } + + private fun getPredictions(): List { + val lastLoopRun = if (config.APS) loop.lastRun else null + val predictions = if (lastLoopRun?.request?.hasPredictions == true) lastLoopRun.constraintsProcessed?.predictionsAsGv?.sortedBy { it.timestamp } ?: emptyList() else emptyList() + return predictions.map { prediction -> + Prediction( + timestamp = Instant.fromEpochMilliseconds(prediction.timestamp), + value = prediction.value.toFloat(), + type = when (prediction.sourceSensor) { + SourceSensor.IOB_PREDICTION -> RemoraStatusData.PredictionType.IOB + SourceSensor.COB_PREDICTION -> RemoraStatusData.PredictionType.COB + SourceSensor.A_COB_PREDICTION -> RemoraStatusData.PredictionType.A_COB + SourceSensor.UAM_PREDICTION -> RemoraStatusData.PredictionType.UAM + SourceSensor.ZT_PREDICTION -> RemoraStatusData.PredictionType.ZT + else -> error("Invalid prediction type") + } + ) + } + } + + private fun getBoluses(start: Long, end: Long): List = + persistenceLayer.getBolusesFromTimeToTime(start, end, true).map { bolus -> + Bolus( + timestamp = Instant.fromEpochMilliseconds(bolus.timestamp), + id = bolus.id, + amount = bolus.amount.toFloat(), + type = when (bolus.type) { + BS.Type.NORMAL -> RemoraStatusData.BolusType.NORMAL + BS.Type.SMB -> RemoraStatusData.BolusType.SMB + BS.Type.PRIMING -> RemoraStatusData.BolusType.PRIMING + } + ) + } + + private fun getCarbs(start: Long, end: Long): List = + persistenceLayer.getCarbsFromTimeToTimeNotExpanded(start, end, true).map { carbs -> + CarbEntry( + timestamp = Instant.fromEpochMilliseconds(carbs.timestamp), + id = carbs.id, + amount = carbs.amount.toFloat(), + duration = carbs.duration.milliseconds + ) + } + + private fun getProfileSwitches(start: Long, end: Long) = + persistenceLayer.getEffectiveProfileSwitchesFromTimeToTime(start, end, true).map { profileSwitch -> + ProfileSwitch( + timestamp = Instant.fromEpochMilliseconds(profileSwitch.timestamp), + id = profileSwitch.id, + profileName = profileSwitch.originalProfileName, + timeshift = profileSwitch.originalTimeshift.milliseconds, + percentage = profileSwitch.originalPercentage, + duration = profileSwitch.originalDuration.milliseconds + ) + } + + private fun getExtendedBoluses(start: Long, end: Long): List = + persistenceLayer.getExtendedBolusesStartingFromTimeToTime(start, end, true) + .filter { it.duration != 0L } + .map { extendedBolus -> + ExtendedBolus( + timestamp = Instant.fromEpochMilliseconds(extendedBolus.timestamp), + id = extendedBolus.id, + amount = extendedBolus.amount.toFloat(), + duration = extendedBolus.duration.milliseconds + ) + } +} diff --git a/plugins/main/src/main/res/layout/remora_fragment.xml b/plugins/main/src/main/res/layout/remora_fragment.xml new file mode 100644 index 000000000000..7684f4a1647b --- /dev/null +++ b/plugins/main/src/main/res/layout/remora_fragment.xml @@ -0,0 +1,19 @@ + + + +